ARIA-148 Enhance CLI show commands * Allow "--full" flag to provide a complete dump * Allow "--json" and "--yaml" flags for dump in those formats * Support for node graph and type hierarchies * Some fixes for YAML dump for our custom types * Also closes ARIA-186: "aria services show" command
Project: http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/repo Commit: http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/commit/fdd57c47 Tree: http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/tree/fdd57c47 Diff: http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/diff/fdd57c47 Branch: refs/heads/ARIA-208-Missing-back-refrences-for-models Commit: fdd57c47acfe63dc25c6f20bcb3785225b3f774d Parents: 60ea3eb Author: Tal Liron <tal.li...@gmail.com> Authored: Thu Apr 20 17:54:47 2017 -0500 Committer: Tal Liron <tal.li...@gmail.com> Committed: Thu May 11 12:42:40 2017 -0500 ---------------------------------------------------------------------- aria/cli/commands/service_templates.py | 72 ++++++---- aria/cli/commands/services.py | 51 ++++++- aria/cli/core/aria.py | 203 +++++++++++++++++++--------- aria/cli/helptexts.py | 12 +- aria/cli/table.py | 3 +- aria/modeling/service_instance.py | 4 +- aria/modeling/service_template.py | 2 +- aria/modeling/types.py | 5 + aria/utils/collections.py | 2 +- 9 files changed, 260 insertions(+), 94 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/fdd57c47/aria/cli/commands/service_templates.py ---------------------------------------------------------------------- diff --git a/aria/cli/commands/service_templates.py b/aria/cli/commands/service_templates.py index e459871..0a24907 100644 --- a/aria/cli/commands/service_templates.py +++ b/aria/cli/commands/service_templates.py @@ -23,11 +23,13 @@ from .. import utils from ..core import aria from ...core import Core from ...storage import exceptions as storage_exceptions +from ...parser import consumption +from ...utils import (formatting, collections, console) DESCRIPTION_FIELD_LENGTH_LIMIT = 20 SERVICE_TEMPLATE_COLUMNS = \ - ['id', 'name', 'description', 'main_file_name', 'created_at', 'updated_at'] + ('id', 'name', 'description', 'main_file_name', 'created_at', 'updated_at') @aria.group(name='service-templates') @@ -43,32 +45,52 @@ def service_templates(): @aria.argument('service-template-name') @aria.options.verbose() @aria.pass_model_storage +@aria.options.service_template_mode_full +@aria.options.mode_types +@aria.options.format_json +@aria.options.format_yaml @aria.pass_logger -def show(service_template_name, model_storage, logger): - """Show information for a specific service templates +def show(service_template_name, model_storage, mode_full, mode_types, format_json, format_yaml, + logger): + """Show information for a specific service template `SERVICE_TEMPLATE_NAME` is the name of the service template to show information on. """ - logger.info('Showing service template {0}...'.format(service_template_name)) service_template = model_storage.service_template.get_by_name(service_template_name) - service_template_dict = service_template.to_dict() - service_template_dict['#services'] = len(service_template.services) - column_formatters = \ - dict(description=table.trim_formatter_generator(DESCRIPTION_FIELD_LENGTH_LIMIT)) - columns = SERVICE_TEMPLATE_COLUMNS + ['#services'] - table.print_data(columns, service_template_dict, 'Service-template:', - column_formatters=column_formatters, col_max_width=50) - - if service_template_dict['description'] is not None: - logger.info('Description:') - logger.info('{0}{1}'.format(service_template_dict['description'].encode('UTF-8') or '', - os.linesep)) - - if service_template.services: - logger.info('Existing services:') - for service in service_template.services: - logger.info('\t{0}'.format(service.name)) + if format_json or format_yaml: + mode_full = True + + if mode_full: + consumption.ConsumptionContext() + if format_json: + console.puts(formatting.json_dumps(collections.prune(service_template.as_raw))) + elif format_yaml: + console.puts(formatting.yaml_dumps(collections.prune(service_template.as_raw))) + else: + service_template.dump() + elif mode_types: + consumption.ConsumptionContext() + service_template.dump_types() + else: + logger.info('Showing service template {0}...'.format(service_template_name)) + service_template_dict = service_template.to_dict() + service_template_dict['#services'] = len(service_template.services) + columns = SERVICE_TEMPLATE_COLUMNS + ('#services',) + column_formatters = \ + dict(description=table.trim_formatter_generator(DESCRIPTION_FIELD_LENGTH_LIMIT)) + table.print_data(columns, service_template_dict, 'Service-template:', + column_formatters=column_formatters, col_max_width=50) + + if service_template_dict['description'] is not None: + logger.info('Description:') + logger.info('{0}{1}'.format(service_template_dict['description'].encode('UTF-8') or '', + os.linesep)) + + if service_template.services: + logger.info('Existing services:') + for service in service_template.services: + logger.info('\t{0}'.format(service.name)) @service_templates.command(name='list', @@ -135,6 +157,7 @@ def store(service_template_path, service_template_name, service_template_filenam @aria.pass_logger def delete(service_template_name, model_storage, resource_storage, plugin_manager, logger): """Delete a service template + `SERVICE_TEMPLATE_NAME` is the name of the service template to delete. """ logger.info('Deleting service template {0}...'.format(service_template_name)) @@ -172,7 +195,7 @@ def validate(service_template, service_template_filename, model_storage, resource_storage, plugin_manager, logger): """Validate a service template - `SERVICE_TEMPLATE` is the path or url of the service template or archive to validate. + `SERVICE_TEMPLATE` is the path or URL of the service template or archive to validate. """ logger.info('Validating service template: {0}'.format(service_template)) service_template_path = service_template_utils.get(service_template, service_template_filename) @@ -188,11 +211,10 @@ def validate(service_template, service_template_filename, @aria.options.verbose() @aria.pass_logger def create_archive(service_template_path, destination, logger): - """Create a csar archive on the local file system + """Create a CSAR archive `service_template_path` is the path of the service template to create the archive from - - `destination` is the path of the output CSAR archive file + `destination` is the path of the output CSAR archive """ logger.info('Creating a CSAR archive') if not destination.endswith(csar.CSAR_FILE_EXTENSION): http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/fdd57c47/aria/cli/commands/services.py ---------------------------------------------------------------------- diff --git a/aria/cli/commands/services.py b/aria/cli/commands/services.py index 50b530a..24de7c5 100644 --- a/aria/cli/commands/services.py +++ b/aria/cli/commands/services.py @@ -25,9 +25,12 @@ from ..core import aria from ...core import Core from ...modeling import exceptions as modeling_exceptions from ...storage import exceptions as storage_exceptions +from ...parser import consumption +from ...utils import (formatting, collections, console) -SERVICE_COLUMNS = ['id', 'name', 'service_template_name', 'created_at', 'updated_at'] +DESCRIPTION_FIELD_LENGTH_LIMIT = 20 +SERVICE_COLUMNS = ('id', 'name', 'description', 'service_template_name', 'created_at', 'updated_at') @aria.group(name='services') @@ -38,6 +41,52 @@ def services(): pass +@services.command(name='show', + short_help='Display service information') +@aria.argument('service-name') +@aria.options.verbose() +@aria.options.service_mode_full +@aria.options.mode_graph +@aria.options.format_json +@aria.options.format_yaml +@aria.pass_model_storage +@aria.pass_logger +def show(service_name, model_storage, mode_full, mode_graph, format_json, format_yaml, logger): + """Show information for a specific service template + + `SERVICE_NAME` is the name of the service to display information on. + """ + service = model_storage.service.get_by_name(service_name) + + if format_json or format_yaml: + mode_full = True + + if mode_full: + consumption.ConsumptionContext() + if format_json: + console.puts(formatting.json_dumps(collections.prune(service.as_raw))) + elif format_yaml: + console.puts(formatting.yaml_dumps(collections.prune(service.as_raw))) + else: + service.dump() + elif mode_graph: + consumption.ConsumptionContext() + service.dump_graph() + else: + logger.info('Showing service {0}...'.format(service_name)) + service_dict = service.to_dict() + columns = SERVICE_COLUMNS + column_formatters = \ + dict(description=table.trim_formatter_generator(DESCRIPTION_FIELD_LENGTH_LIMIT)) + table.print_data(columns, service_dict, 'Service:', + column_formatters=column_formatters, col_max_width=50) + + if service_dict['description'] is not None: + logger.info('Description:') + logger.info('{0}{1}'.format(service_dict['description'].encode('UTF-8') or '', + os.linesep)) + + @services.command(name='list', short_help='List services') @aria.options.service_template_name() @aria.options.sort_by() http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/fdd57c47/aria/cli/core/aria.py ---------------------------------------------------------------------- diff --git a/aria/cli/core/aria.py b/aria/cli/core/aria.py index ed6afa1..56fe2f7 100644 --- a/aria/cli/core/aria.py +++ b/aria/cli/core/aria.py @@ -19,6 +19,7 @@ import sys import difflib import StringIO import traceback +import inspect from functools import wraps import click @@ -40,40 +41,57 @@ CLICK_CONTEXT_SETTINGS = dict( class MutuallyExclusiveOption(click.Option): - """Makes options mutually exclusive. The option must pass a `cls` argument - with this class name and a `mutually_exclusive` argument with a list of - argument names it is mutually exclusive with. - - NOTE: All mutually exclusive options must use this. It's not enough to - use it in just one of the options. - """ - def __init__(self, *args, **kwargs): - self.mutually_exclusive = set(kwargs.pop('mutually_exclusive', [])) - self.mutuality_error_message = \ - kwargs.pop('mutuality_error_message', - helptexts.DEFAULT_MUTUALITY_MESSAGE) - self.mutuality_string = ', '.join(self.mutually_exclusive) + self.mutually_exclusive = set(kwargs.pop('mutually_exclusive', tuple())) + self.mutuality_description = kwargs.pop('mutuality_description', + ', '.join(self.mutually_exclusive)) + self.mutuality_error = kwargs.pop('mutuality_error', + helptexts.DEFAULT_MUTUALITY_ERROR_MESSAGE) if self.mutually_exclusive: help = kwargs.get('help', '') - kwargs['help'] = ( - '{0}. This argument is mutually exclusive with ' - 'arguments: [{1}] ({2})'.format( - help, - self.mutuality_string, - self.mutuality_error_message)) + kwargs['help'] = '{0}. {1}'.format(help, self._message) super(MutuallyExclusiveOption, self).__init__(*args, **kwargs) def handle_parse_result(self, ctx, opts, args): - if self.mutually_exclusive.intersection(opts) and self.name in opts: - raise click.UsageError( - 'Illegal usage: `{0}` is mutually exclusive with ' - 'arguments: [{1}] ({2}).'.format( - self.name, - self.mutuality_string, - self.mutuality_error_message)) - return super(MutuallyExclusiveOption, self).handle_parse_result( - ctx, opts, args) + if (self.name in opts) and self.mutually_exclusive.intersection(opts): + raise click.UsageError('Illegal usage: {0}'.format(self._message)) + return super(MutuallyExclusiveOption, self).handle_parse_result(ctx, opts, args) + + @property + def _message(self): + return '{0} be used together with {1} ({2}).'.format( + '{0} cannot'.format(', '.join(self.opts)) if hasattr(self, 'opts') else 'Cannot', + self.mutuality_description, + self.mutuality_error) + + +def mutually_exclusive_option(*param_decls, **attrs): + """ + Decorator for mutually exclusive options. + + This decorator works similarly to `click.option`, but supports an extra ``mutually_exclusive`` + argument, which is a list of argument names with which the option is mutually exclusive. + + You can optionally also supply ``mutuality_description`` and ``mutuality_error`` to override the + default messages. + + NOTE: All mutually exclusive options must use this. It's not enough to use it in just one of the + options. + """ + + # NOTE: This code is copied and slightly modified from click.decorators.option and + # click.decorators._param_memo. Unfortunately, using click's ``cls`` parameter support does not + # work as is with extra decorator arguments. + + def decorator(func): + if 'help' in attrs: + attrs['help'] = inspect.cleandoc(attrs['help']) + param = MutuallyExclusiveOption(param_decls, **attrs) + if not hasattr(func, '__click_params__'): + func.__click_params__ = [] + func.__click_params__.append(param) + return func + return decorator def _format_version_data(version, @@ -105,13 +123,12 @@ def show_version(ctx, param, value): def inputs_callback(ctx, param, value): - """Allow to pass any inputs we provide to a command as - processed inputs instead of having to call `inputs_to_dict` - inside the command. + """ + Allow to pass any inputs we provide to a command as processed inputs instead of having to call + ``inputs_to_dict`` inside the command. - `@aria.options.inputs` already calls this callback so that - every time you use the option it returns the inputs as a - dictionary. + ``@aria.options.inputs`` already calls this callback so that every time you use the option it + returns the inputs as a dictionary. """ if not value: return {} @@ -127,7 +144,6 @@ def set_verbosity_level(ctx, param, value): def set_cli_except_hook(): - def recommend(possible_solutions): logger.info('Possible solutions:') for solution in possible_solutions: @@ -155,7 +171,8 @@ def set_cli_except_hook(): def pass_logger(func): - """Simply passes the logger to a command. + """ + Simply passes the logger to a command. """ # Wraps here makes sure the original docstring propagates to click @wraps(func) @@ -166,7 +183,8 @@ def pass_logger(func): def pass_plugin_manager(func): - """Simply passes the plugin manager to a command. + """ + Simply passes the plugin manager to a command. """ # Wraps here makes sure the original docstring propagates to click @wraps(func) @@ -177,7 +195,8 @@ def pass_plugin_manager(func): def pass_model_storage(func): - """Simply passes the model storage to a command. + """ + Simply passes the model storage to a command. """ # Wraps here makes sure the original docstring propagates to click @wraps(func) @@ -188,7 +207,8 @@ def pass_model_storage(func): def pass_resource_storage(func): - """Simply passes the resource storage to a command. + """ + Simply passes the resource storage to a command. """ # Wraps here makes sure the original docstring propagates to click @wraps(func) @@ -199,11 +219,11 @@ def pass_resource_storage(func): def pass_context(func): - """Make click context ARIA specific + """ + Make click context ARIA specific. - This exists purely for aesthetic reasons, otherwise - Some decorators are called `@click.something` instead of - `@aria.something` + This exists purely for aesthetic reasons, otherwise some decorators are called + ``@click.something`` instead of ``@aria.something``. """ return click.pass_context(func) @@ -227,7 +247,8 @@ class AliasedGroup(click.Group): ctx.fail('Too many matches: {0}'.format(', '.join(sorted(matches)))) def resolve_command(self, ctx, args): - """Override clicks ``resolve_command`` method + """ + Override clicks ``resolve_command`` method and appends *Did you mean ...* suggestions to the raised exception message. """ @@ -249,9 +270,9 @@ class AliasedGroup(click.Group): def group(name): - """Allow to create a group with a default click context - and a cls for click's `didyoueamn` without having to repeat - it for every group. + """ + Allow to create a group with a default click context and a cls for click's ``didyoueamn`` + without having to repeat it for every group. """ return click.group( name=name, @@ -260,34 +281,34 @@ def group(name): def command(*args, **kwargs): - """Make Click commands ARIA specific + """ + Make Click commands ARIA specific. - This exists purely for aesthetical reasons, otherwise - Some decorators are called `@click.something` instead of - `@aria.something` + This exists purely for aesthetic reasons, otherwise some decorators are called + ``@click.something`` instead of ``@aria.something``. """ return click.command(*args, **kwargs) def argument(*args, **kwargs): - """Make Click arguments ARIA specific + """ + Make Click arguments ARIA specific. - This exists purely for aesthetic reasons, otherwise - Some decorators are called `@click.something` instead of - `@aria.something` + This exists purely for aesthetic reasons, otherwise some decorators are called + ``@click.something`` instead of ``@aria.something`` """ return click.argument(*args, **kwargs) class Options(object): def __init__(self): - """The options api is nicer when you use each option by calling - `@aria.options.some_option` instead of `@aria.some_option`. + """ + The options API is nicer when you use each option by calling ``@aria.options.some_option`` + instead of ``@aria.some_option``. - Note that some options are attributes and some are static methods. - The reason for that is that we want to be explicit regarding how - a developer sees an option. It it can receive arguments, it's a - method - if not, it's an attribute. + Note that some options are attributes and some are static methods. The reason for that is + that we want to be explicit regarding how a developer sees an option. If it can receive + arguments, it's a method - if not, it's an attribute. """ self.version = click.option( '--version', @@ -325,6 +346,66 @@ class Options(object): default=defaults.SERVICE_TEMPLATE_FILENAME, help=helptexts.SERVICE_TEMPLATE_FILENAME) + self.service_template_mode_full = mutually_exclusive_option( + '-f', + '--full', + 'mode_full', + mutually_exclusive=('mode_types',), + is_flag=True, + help=helptexts.SHOW_FULL, + mutuality_description='-t, --types', + mutuality_error=helptexts.MODE_MUTUALITY_ERROR_MESSAGE) + + self.service_mode_full = mutually_exclusive_option( + '-f', + '--full', + 'mode_full', + mutually_exclusive=('mode_graph',), + is_flag=True, + help=helptexts.SHOW_FULL, + mutuality_description='-g, --graph', + mutuality_error=helptexts.MODE_MUTUALITY_ERROR_MESSAGE) + + self.mode_types = mutually_exclusive_option( + '-t', + '--types', + 'mode_types', + mutually_exclusive=('mode_full',), + is_flag=True, + help=helptexts.SHOW_TYPES, + mutuality_description='-f, --full', + mutuality_error=helptexts.MODE_MUTUALITY_ERROR_MESSAGE) + + self.mode_graph = mutually_exclusive_option( + '-g', + '--graph', + 'mode_graph', + mutually_exclusive=('mode_full',), + is_flag=True, + help=helptexts.SHOW_GRAPH, + mutuality_description='-f, --full', + mutuality_error=helptexts.MODE_MUTUALITY_ERROR_MESSAGE) + + self.format_json = mutually_exclusive_option( + '-j', + '--json', + 'format_json', + mutually_exclusive=('format_yaml',), + is_flag=True, + help=helptexts.SHOW_JSON, + mutuality_description='-y, --yaml', + mutuality_error=helptexts.FORMAT_MUTUALITY_ERROR_MESSAGE) + + self.format_yaml = mutually_exclusive_option( + '-y', + '--yaml', + 'format_yaml', + mutually_exclusive=('format_json',), + is_flag=True, + help=helptexts.SHOW_YAML, + mutuality_description='-j, --json', + mutuality_error=helptexts.FORMAT_MUTUALITY_ERROR_MESSAGE) + @staticmethod def verbose(expose_value=False): return click.option( http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/fdd57c47/aria/cli/helptexts.py ---------------------------------------------------------------------- diff --git a/aria/cli/helptexts.py b/aria/cli/helptexts.py index 8641822..74934db 100644 --- a/aria/cli/helptexts.py +++ b/aria/cli/helptexts.py @@ -14,7 +14,7 @@ # limitations under the License. -DEFAULT_MUTUALITY_MESSAGE = 'Cannot be used simultaneously' +DEFAULT_MUTUALITY_ERROR_MESSAGE = 'mutually exclusive' VERBOSE = \ "Show verbose output. You can supply this up to three times (i.e. -vvv)" @@ -29,7 +29,7 @@ EXECUTION_ID = "The unique identifier for the execution" SERVICE_TEMPLATE_PATH = "The path to the application's service template file" SERVICE_TEMPLATE_FILENAME = ( "The name of the archive's main service template file. " - "This is only relevant if uploading a (non-csar) archive") + "This is only relevant if uploading a (non-CSAR) archive") INPUTS_PARAMS_USAGE = ( '(Can be provided as wildcard based paths ' '(*.yaml, /my_inputs/, etc..) to YAML files, a JSON string or as ' @@ -48,3 +48,11 @@ SORT_BY = "Key for sorting the list" DESCENDING = "Sort list in descending order [default: False]" JSON_OUTPUT = "Output logs in a consumable JSON format" MARK_PATTERN = "Mark a regex pattern in the logs" + +SHOW_FULL = "Show full information" +SHOW_JSON = "Show in JSON format (implies --full)" +SHOW_YAML = "Show in YAML format (implies --full)" +SHOW_TYPES = "Show only the type hierarchies" +SHOW_GRAPH = "Show only the node graph" +MODE_MUTUALITY_ERROR_MESSAGE = 'only one mode is possible' +FORMAT_MUTUALITY_ERROR_MESSAGE = 'only one format is possible' http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/fdd57c47/aria/cli/table.py ---------------------------------------------------------------------- diff --git a/aria/cli/table.py b/aria/cli/table.py index 408f81e..d984c87 100644 --- a/aria/cli/table.py +++ b/aria/cli/table.py @@ -85,9 +85,10 @@ def _generate(cols, data, column_formatters=None, defaults=None): return val else: - return defaults[column] + return defaults.get(column) column_formatters = column_formatters or dict() + defaults = defaults or dict() pretty_table = PrettyTable(list(cols)) for datum in data: http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/fdd57c47/aria/modeling/service_instance.py ---------------------------------------------------------------------- diff --git a/aria/modeling/service_instance.py b/aria/modeling/service_instance.py index 1efe1e1..41a388d 100644 --- a/aria/modeling/service_instance.py +++ b/aria/modeling/service_instance.py @@ -1019,7 +1019,7 @@ class SubstitutionBase(InstanceModelMixin): @property def as_raw(self): return collections.OrderedDict(( - ('node_type_name', self.node_type_name), + ('node_type_name', self.node_type.name), ('mappings', formatting.as_raw_dict(self.mappings)))) def validate(self): @@ -1127,7 +1127,7 @@ class SubstitutionMappingBase(InstanceModelMixin): @property def as_raw(self): return collections.OrderedDict(( - ('name', self.name))) + ('name', self.name),)) def coerce_values(self, report_issues): pass http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/fdd57c47/aria/modeling/service_template.py ---------------------------------------------------------------------- diff --git a/aria/modeling/service_template.py b/aria/modeling/service_template.py index 7a192a7..7eb35bd 100644 --- a/aria/modeling/service_template.py +++ b/aria/modeling/service_template.py @@ -1063,7 +1063,7 @@ class SubstitutionTemplateMappingBase(TemplateModelMixin): @property def as_raw(self): return collections.OrderedDict(( - ('name', self.name))) + ('name', self.name),)) def coerce_values(self, report_issues): pass http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/fdd57c47/aria/modeling/types.py ---------------------------------------------------------------------- diff --git a/aria/modeling/types.py b/aria/modeling/types.py index 06f171c..7460f47 100644 --- a/aria/modeling/types.py +++ b/aria/modeling/types.py @@ -22,6 +22,7 @@ from sqlalchemy import ( event ) from sqlalchemy.ext import mutable +from ruamel import yaml from . import exceptions @@ -206,6 +207,8 @@ class _StrictDict(object): (_StrictDictMixin, _MutableDict), {'_key_cls': key_cls, '_value_cls': value_cls} ) + yaml.representer.RoundTripRepresenter.add_representer( + listener_cls, yaml.representer.RoundTripRepresenter.represent_list) self._strict_map[strict_dict_map_key] = _StrictValue(type_cls=strict_dict_cls, listener_cls=listener_cls) @@ -242,6 +245,8 @@ class _StrictList(object): (_StrictListMixin, _MutableList), {'_item_cls': item_cls} ) + yaml.representer.RoundTripRepresenter.add_representer( + listener_cls, yaml.representer.RoundTripRepresenter.represent_list) self._strict_map[item_cls] = _StrictValue(type_cls=strict_list_cls, listener_cls=listener_cls) http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/fdd57c47/aria/utils/collections.py ---------------------------------------------------------------------- diff --git a/aria/utils/collections.py b/aria/utils/collections.py index 03feabd..1e732aa 100644 --- a/aria/utils/collections.py +++ b/aria/utils/collections.py @@ -249,7 +249,7 @@ def prune(value, is_removable_function=is_removable): else: prune(v, is_removable_function) elif isinstance(value, dict): - for k, v in value.iteritems(): + for k, v in value.items(): if is_removable_function(value, k, v): del value[k] else: