From 1dde96592da8dbe4a0fce03d8cc706cf1360d59a Mon Sep 17 00:00:00 2001 From: Allen Reese Date: Fri, 14 Aug 2020 08:28:51 -0700 Subject: [PATCH 1/4] Add databricks scim command to expose the SCIM api: https://docs.databricks.com/dev-tools/api/latest/scim/index.html --- databricks_cli/click_types.py | 53 +++++ databricks_cli/scim/__init__.py | 0 databricks_cli/scim/api.py | 269 +++++++++++++++++++++++ databricks_cli/scim/cli.py | 328 +++++++++++++++++++++++++++++ databricks_cli/scim/exceptions.py | 26 +++ databricks_cli/sdk/__init__.py | 1 + databricks_cli/sdk/api_client.py | 6 + databricks_cli/sdk/scim_service.py | 161 ++++++++++++++ databricks_cli/utils.py | 23 ++ tests/scim/test_user_args.py | 23 ++ 10 files changed, 890 insertions(+) create mode 100644 databricks_cli/scim/__init__.py create mode 100644 databricks_cli/scim/api.py create mode 100644 databricks_cli/scim/cli.py create mode 100644 databricks_cli/scim/exceptions.py create mode 100644 databricks_cli/sdk/scim_service.py create mode 100644 tests/scim/test_user_args.py diff --git a/databricks_cli/click_types.py b/databricks_cli/click_types.py index 75c77d1a..58a747b6 100644 --- a/databricks_cli/click_types.py +++ b/databricks_cli/click_types.py @@ -20,10 +20,13 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import logging import click from click import ParamType, Option, MissingParameter, UsageError +logger = logging.getLogger() + class OutputClickType(ParamType): name = 'FORMAT' @@ -120,6 +123,18 @@ def handle_parse_result(self, ctx, opts, args): return super(OneOfOption, self).handle_parse_result(ctx, opts, args) +class OptionalOneOfOption(Option): + def __init__(self, *args, **kwargs): + self.one_of = kwargs.pop('one_of') + super(OptionalOneOfOption, self).__init__(*args, **kwargs) + + def handle_parse_result(self, ctx, opts, args): + cleaned_opts = set([o.replace('_', '-') for o in opts.keys()]) + if len(cleaned_opts.intersection(set(self.one_of))) > 1: + raise UsageError('Only one of {} should be provided.'.format(self.one_of)) + return super(OptionalOneOfOption, self).handle_parse_result(ctx, opts, args) + + class ContextObject(object): def __init__(self): self._profile = None @@ -127,6 +142,30 @@ def __init__(self): def set_debug(self, debug=False): self._debug = debug + if debug: + self.enable_debug_logging() + + def enable_debug_logging(self): + # These two lines enable debugging at httplib level (requests->urllib3->http.client) + # You will see the REQUEST, including HEADERS and DATA, and + # RESPONSE with HEADERS but without DATA. + # The only thing missing will be the response.body which is not logged. + try: + import http.client as http_client + except ImportError: + # Python 2 + import httplib as http_client + + # pylint:disable=superfluous-parens + print("HTTP debugging enabled") + http_client.HTTPConnection.debuglevel = 1 + + # You must initialize logging, otherwise you'll not see debug output. + logging.basicConfig() + logging.getLogger().setLevel(logging.DEBUG) + requests_log = logging.getLogger("requests.packages.urllib3") + requests_log.setLevel(logging.DEBUG) + requests_log.propagate = True if not self._debug: return @@ -156,3 +195,17 @@ def set_profile(self, profile): def get_profile(self): return self._profile + + +class RequiredOptions(Option): + def __init__(self, *args, **kwargs): + self.one_of = kwargs.pop('one_of') + super(RequiredOptions, self).__init__(*args, **kwargs) + + def handle_parse_result(self, ctx, opts, args): + cleaned_opts = set([o.replace('_', '-') for o in opts.keys()]) + if len(cleaned_opts.intersection(set(self.one_of))) == 0: + raise MissingParameter('One of {} must be provided.'.format(self.one_of)) + if len(cleaned_opts.intersection(set(self.one_of))) > 1: + raise UsageError('Only one of {} should be provided.'.format(self.one_of)) + return super(RequiredOptions, self).handle_parse_result(ctx, opts, args) diff --git a/databricks_cli/scim/__init__.py b/databricks_cli/scim/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/databricks_cli/scim/api.py b/databricks_cli/scim/api.py new file mode 100644 index 00000000..d294b529 --- /dev/null +++ b/databricks_cli/scim/api.py @@ -0,0 +1,269 @@ +# Databricks CLI +# Copyright 2017 Databricks, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"), except +# that the use of services to which certain application programming +# interfaces (each, an "API") connect requires that the user first obtain +# a license for the use of the APIs from Databricks, Inc. ("Databricks"), +# by creating an account at www.databricks.com and agreeing to either (a) +# the Community Edition Terms of Service, (b) the Databricks Terms of +# Service, or (c) another written agreement between Licensee and Databricks +# for the use of the APIs. +# +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from databricks_cli.scim.exceptions import ScimError +from databricks_cli.sdk import ScimService +from databricks_cli.utils import is_int, debug + + +class ScimApi(object): + GROUP_NAME_FILTER = 'displayName eq {}' + USER_NAME_FILTER = 'userName eq {}' + + def __init__(self, api_client): + self.client = ScimService(api_client) + + def get_group(self, group_id=None, group_name=None): + if group_id is not None: + content = self.get_group_by_id(group_id) + else: + filters = self.GROUP_NAME_FILTER.format(group_name) + content = self.list_groups(filters=filters) + debug('get_group', content) + return content + + def get_user(self, user_id=None, user_name=None): + if user_id is not None: + content = self.get_user_by_id(user_id) + else: + filters = self.USER_NAME_FILTER.format(user_name) + content = self.list_users(filters=filters) + return content + + def get_user_id_for_user(self, user_name): + content = self.get_user(user_id=None, user_name=user_name) + return self._parse_id_from_json(name='user', value=user_name, + filters=self.USER_NAME_FILTER.format(user_name), + data=content) + + def get_group_id_for_group(self, group_name): + content = self.get_group(group_name=group_name) + debug('get_group_id_for_group', content) + return self._parse_id_from_json(name='group', value=group_name, + filters=self.GROUP_NAME_FILTER.format(group_name), + data=content) + + def get_group_name_for_group(self, group_id): + content = self.get_group(group_id=group_id) + debug('get_group_id_for_group', content) + return self._parse_name_from_json(id_value=group_id, value=group_id, data=content) + + def delete_user(self, user_id=None, user_name=None): + if user_id is None: + user_id = self.get_user_id_for_user(user_name) + content = self.delete_user_by_id(user_id) + + return content + + def list_users(self, filters=None, active=None): + # https://help.databricks.com/hc/en-us/requests/24688 + # filtering on active doesn't work. + # if active is not None: + # active_flag = 'active eq {}'.format(active) + # + # if filters is not None: + # filters = filters + ' and {}'.format(active_flag) + # else: + # filters = active_flag + + debug('list_users', 'filters: {}'.format(filters)) + content = self.client.users(filters) + # debug('list_users', 'content: {}'.format(content)) + return self.filter_active_only(content, active) + + def get_user_by_id(self, user_id): + return self.client.user_by_id(user_id) + + def create_user(self, user_name=None, groups=None, entitlements=None, roles=None): + return self.client.create_user(user_name=user_name, groups=groups, + entitlements=entitlements, roles=roles) + + def create_user_json(self, json): + return self.client.create_user_json(data=json) + + def update_user_by_id(self, user_id, operation, path, values): + return self.client.update_user_by_id(user_id, operation, path, values) + + def overwrite_user_by_id(self, user_id, user_name, groups, entitlements, roles): + return self.client.overwrite_user_by_id(user_id, user_name, groups, entitlements, roles) + + def delete_user_by_id(self, user_id): + return self.client.delete_user_by_id(user_id) + + def list_groups(self, filters=None, group_id=None, group_name=None): + extra_filter = None + if group_id is not None: + # id is not supported. + # extra_filter = 'id eq {}'.format(group_id) + group = self.get_group(group_id=group_id) + group_name = group.get('displayName') + + if group_name is not None: + extra_filter = 'displayName eq {}'.format(group_name) + + if extra_filter is not None: + if filters is not None: + filters = filters + ' and {}'.format(extra_filter) + else: + filters = extra_filter + + debug('list_groups', 'filters: {}'.format(filters)) + return self.client.groups(filters) + + def get_group_by_id(self, group_id): + return self.client.group_by_id(group_id) + + def create_group(self, group_name, users): + user_ids = self.users_to_user_ids(users) + return self.client.create_group_internal(group_name=group_name, members=user_ids) + + def update_group_by_id(self, group_id, operation, values): + return self.client.update_group_by_id(group_id=group_id, operation=operation, values=values) + + def delete_group(self, group_id=None, group_name=None): + if group_id is None: + group_id = self.get_group_id_for_group(group_name) + content = self.delete_group_by_id(group_id) + + return content + + def delete_group_by_id(self, group_id): + return self.client.delete_group_by_id(group_id) + + def add_user_to_group(self, group_id, group_name, user_id, user_name): + if group_id is None: + group_id = self.get_group_id_for_group(group_name) + if user_id is None: + user_id = self.get_user_id_for_user(user_name) + + return self.group_operation(op='add', group_id=group_id, user_id=user_id) + + def remove_user_from_group(self, group_id, group_name, user_id, user_name): + if group_id is None: + group_id = self.get_group_id_for_group(group_name) + if user_id is None: + user_id = self.get_user_id_for_user(user_name) + + return self.group_operation(op='remove', group_id=group_id, user_id=[user_id]) + + def group_operation(self, op, group_id, user_id): + return self.update_group_by_id(group_id=group_id, operation=op, values=[user_id]) + + @classmethod + def users_to_user_ids(cls, users): + """ + Convert a list of user names and or user ids to a list of user ids + :param users: list of user names or ids + :return: list of user ids + """ + # we need a list of ids. + return cls.members_to_ids(users, cls.get_user_id_for_user) + + @classmethod + def groups_to_group_ids(cls, groups): + """ + Convert a list of user names and or user ids to a list of user ids + :param groups: list of user names or ids + :return: list of user ids + """ + # we need a list of ids. + return cls.members_to_ids(groups, cls.get_group_id_for_group) + + @classmethod + def filter_active_only(cls, content, active): + resources = content.get('Resources') + if active is not None and resources: + debug('filter_active_only', 'Filtering for active == {}'.format(active)) + content['Resources'] = [ + resource for resource in resources if resource.get('active') == active + ] + + return content + + @classmethod + def _parse_id_from_json(cls, name, value, filters, data): + if not data: + raise ScimError( + 'Failed to find {} {} using filter {}, no response'.format(name, value, filters)) + + resources = data.get('Resources') + if not resources: + debug('{} no resources'.format(name), resources) + raise ScimError('Failed to find resources in json data for response: {}'.format(data)) + + if len(resources) != 1: + raise ScimError( + 'Expected only 1 resource using filter {} in json data: {}'.format(filters, data)) + + resource = resources[0] + resource_id = resource.get('id') + if not resource_id: + debug('{name} no {name}'.format(name=name), resource) + raise ScimError( + 'Expected {} id in resource using filter {} in json data: {}'.format(name, + filters, data)) + + debug('{name} {name}: '.format(name=name), resource_id) + return resource_id + + @classmethod + def _parse_name_from_json(cls, id_value, value, data): + if not data: + raise ScimError('Failed to find {} {}, no response'.format(id_value, value)) + + resources = data.get('Resources') + if not resources: + debug('{} no resources'.format(id_value), resources) + raise ScimError('Failed to find resources in json data for response: {}'.format(data)) + + resource_name = None + for resource in resources: + if resource.get('id') == id_value: + resource_name = resource.get('displayName') + break + + debug('{name} {name}: '.format(name=id_value), resource_name) + return resource_name + + @classmethod + def members_to_ids(cls, members, function): + """ + Convert a list of user names and or user ids to a list of user ids + :param members: list of names or ids + :param function: function to get a list of ids for a name + + :return: list of user ids + """ + # we need a list of ids. + list_of_ids = [] + for member in members: + # see if it's a number + if is_int(member): + member_id = member + else: + # we need to get the user_id + member_id = function(member) + + list_of_ids.append(member_id) + + return list_of_ids diff --git a/databricks_cli/scim/cli.py b/databricks_cli/scim/cli.py new file mode 100644 index 00000000..bbb67fd3 --- /dev/null +++ b/databricks_cli/scim/cli.py @@ -0,0 +1,328 @@ +# Databricks CLI +# Copyright 2017 Databricks, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"), except +# that the use of services to which certain application programming +# interfaces (each, an "API") connect requires that the user first obtain +# a license for the use of the APIs from Databricks, Inc. ("Databricks"), +# by creating an account at www.databricks.com and agreeing to either (a) +# the Community Edition Terms of Service, (b) the Databricks Terms of +# Service, or (c) another written agreement between Licensee and Databricks +# for the use of the APIs. +# +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from json import loads as json_loads + +import click + +from databricks_cli.click_types import OneOfOption, OutputClickType, JsonClickType, \ + OptionalOneOfOption +from databricks_cli.configure.config import provide_api_client, profile_option, debug_option +from databricks_cli.scim.api import ScimApi +from databricks_cli.utils import eat_exceptions, CONTEXT_SETTINGS, pretty_format +from databricks_cli.version import print_version_callback, version + +FILTERS_HELP = 'Filters for filtering the list of users: ' + \ + 'https://docs.databricks.com/api/latest/scim.html#filters' +USER_OPTIONS = ['user-id', 'user-name'] +JSON_FILE_OPTIONS = ['json-file', 'json'] +CREATE_USER_OPTIONS = JSON_FILE_OPTIONS + ['user-name'] + +GROUP_OPTIONS = ['group-id', 'group-name'] + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='List users using the scim api') +@click.option('--filters', default=None, required=False, help=FILTERS_HELP) +@click.option('--output', default=None, help=OutputClickType.help, type=OutputClickType()) +# having an active flag would be really nice. +@click.option('--active/--inactive', default=None, required=False, + help="display only active or inactive users") +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +# pylint:disable=unused-argument +def list_users_cli(api_client, filters, active, output): + content = ScimApi(api_client).list_users(filters=filters, active=active) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Get user information by id or name') +@click.option('--user-id', metavar='', cls=OneOfOption, default=None, one_of=USER_OPTIONS) +@click.option('--user-name', metavar='', cls=OneOfOption, default=None, one_of=USER_OPTIONS) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def get_user_cli(api_client, user_id, user_name): + content = ScimApi(api_client).get_user(user_id=user_id, user_name=user_name) + click.echo(pretty_format(content)) + + +def validate_user_params(json_file, json, user_name, groups, entitlements, roles): + if not bool(user_name) and (bool(groups) or bool(entitlements) or bool(roles)): + raise RuntimeError('Either --user-name or --json-file or --json should be provided') + + if not bool(user_name) and not bool(json_file) ^ bool(json): + raise RuntimeError('Either --user-name or --json-file or --json should be provided') + + if bool(user_name) and (bool(json_file) or bool(json)): + raise RuntimeError('Either --user-name or --json-file or --json should be provided') + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Create a user') +@click.option('--json-file', cls=OneOfOption, default=None, type=click.Path(), + one_of=CREATE_USER_OPTIONS, + help='File containing JSON request to POST to /api/2.0/preview/scim/v2/Users.') +@click.option('--json', cls=OneOfOption, default=None, type=JsonClickType(), + one_of=CREATE_USER_OPTIONS, + help=JsonClickType.help('/api/2.0/preview/scim/v2/Users')) +@click.option('--user-name', metavar='', cls=OneOfOption, default=None, + one_of=CREATE_USER_OPTIONS) +@click.option('--groups', metavar='', is_flag=False, default=None, type=click.STRING, + help='Groups the user should be a member of', multiple=True, required=False) +@click.option('--entitlements', metavar='', is_flag=False, default=None, type=click.STRING, + help='Entitlements the user should have', multiple=True, required=False) +@click.option('--roles', metavar='', is_flag=False, default=None, type=click.STRING, + help='Roles the user should have', multiple=True, required=False) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def create_user_cli(api_client, json_file, json, user_name, groups, entitlements, roles): + validate_user_params(json_file, json, user_name, groups, entitlements, roles) + + client = ScimApi(api_client) + if user_name: + content = client.create_user(user_name=user_name, groups=groups, entitlements=entitlements, + roles=roles) + else: + if json_file: + with open(json_file, 'r') as f: + json = f.read() + deser_json = json_loads(json) + content = client.create_user_json(json=deser_json) + + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='NOT IMPLEMENTED') +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +# pylint:disable=unused-argument +def update_user_by_id_cli(api_client, user_id, operation, path, values): + content = ScimApi(api_client) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='NOT IMPLEMENTED') +@click.option("--force", required=False, default=False) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +# pylint:disable=unused-argument +def overwrite_user_by_id_cli(api_client, user_id, user_name, groups, entitlements, roles): + content = ScimApi(api_client) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Delete a user. User is retained for 30 days then purged.') +@click.option('--user-id', metavar='', cls=OneOfOption, default=None, one_of=USER_OPTIONS) +@click.option('--user-name', metavar='', cls=OneOfOption, default=None, one_of=USER_OPTIONS) +@click.option("--force", required=False, default=False) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def delete_user_cli(api_client, user_id, user_name, force): + if not force and not click.confirm( + 'Do you want to remove "{}"'.format(user_name)): + click.echo('Not removing "{}"'.format(user_name)) + return + + click.echo('REMOVING "{}"'.format(user_name)) + content = ScimApi(api_client).delete_user(user_id=user_id, user_name=user_name) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='List groups') +@click.option('--filters', default=None, required=False, help=FILTERS_HELP) +@click.option('--group-id', metavar='', cls=OptionalOneOfOption, default=None, + one_of=GROUP_OPTIONS) +@click.option('--group-name', metavar='', cls=OptionalOneOfOption, default=None, + one_of=GROUP_OPTIONS) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def groups_list_cli(api_client, filters, group_id, group_name): + content = ScimApi(api_client).list_groups(filters=filters, group_id=group_id, + group_name=group_name) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Get group information by id or name') +@click.option('--group-id', metavar='', cls=OneOfOption, default=None, one_of=GROUP_OPTIONS) +@click.option('--group-name', metavar='', cls=OneOfOption, default=None, + one_of=GROUP_OPTIONS) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def groups_get_cli(api_client, group_id, group_name): + content = ScimApi(api_client).get_group(group_id=group_id, group_name=group_name) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Create a group') +@click.option('--group-name', required=True) +@click.option('--member', 'members', is_flag=False, default=None, metavar='', + type=click.STRING, + help='members to add to the group', multiple=True, required=False) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def groups_create_cli(api_client, group_name, members): + content = ScimApi(api_client).create_group(group_name, members) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='NOT IMPLEMENTED') +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +# pylint:disable=unused-argument +def groups_update_cli(api_client, group_id, operation, path, values): + content = ScimApi(api_client) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Delete a group.') +@click.option('--group-id', metavar='', cls=OneOfOption, default=None, one_of=GROUP_OPTIONS) +@click.option('--group-name', metavar='', cls=OneOfOption, default=None, + one_of=GROUP_OPTIONS) +@click.option("--force", required=False, default=False) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def groups_delete_cli(api_client, group_id, group_name): + content = ScimApi(api_client).delete_group(group_id=group_id, group_name=group_name) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Add a user to a group') +@click.option('--group-id', metavar='', cls=OneOfOption, default=None, one_of=GROUP_OPTIONS) +@click.option('--group-name', metavar='', cls=OneOfOption, default=None, + one_of=GROUP_OPTIONS) +@click.option('--user-id', metavar='', cls=OneOfOption, default=None, one_of=USER_OPTIONS) +@click.option('--user-name', metavar='', cls=OneOfOption, default=None, one_of=USER_OPTIONS) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def groups_add_user_cli(api_client, group_id, group_name, user_id, user_name): + content = ScimApi(api_client).add_user_to_group(group_id=group_id, group_name=group_name, + user_id=user_id, + user_name=user_name) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Remove a user from a group.') +@click.option('--group-id', metavar='', cls=OneOfOption, default=None, one_of=GROUP_OPTIONS) +@click.option('--group-name', metavar='', cls=OneOfOption, default=None, + one_of=GROUP_OPTIONS) +@click.option('--user-id', metavar='', cls=OneOfOption, default=None, one_of=USER_OPTIONS) +@click.option('--user-name', metavar='', cls=OneOfOption, default=None, one_of=USER_OPTIONS) +@click.option("--force", required=False, default=False) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def groups_remove_user_cli(api_client, group_id, group_name, user_id, user_name, force): + if not force and not click.confirm( + 'Do you want to remove "{}" from group "{}"'.format(user_name, group_name)): + click.echo('Not removing "{}" from group "{}"'.format(user_name, group_name)) + return + + click.echo('REMOVING "{}" from group "{}"'.format(user_name, group_name)) + content = ScimApi(api_client).remove_user_from_group(group_id=group_id, group_name=group_name, + user_id=user_id, + user_name=user_name) + click.echo(pretty_format(content)) + + +@click.group(context_settings=CONTEXT_SETTINGS, + short_help='Utility to interact with Databricks scim api.\n') +@click.option('--version', '-v', is_flag=True, callback=print_version_callback, + expose_value=False, is_eager=True, help=version) +@debug_option +@profile_option +@eat_exceptions +def scim_group(): + """Provide utility to interact with Databricks scim api.""" + pass + + +@click.group(context_settings=CONTEXT_SETTINGS, + short_help='Utility to interact with Databricks scim api.\n') +@click.option('--version', '-v', is_flag=True, callback=print_version_callback, + expose_value=False, is_eager=True, help=version) +@debug_option +@profile_option +@eat_exceptions +def scim_groups_group(): + """Provide utility to interact with Databricks scim groups api.""" + pass + + +scim_group.add_command(list_users_cli, name='list-users') +scim_group.add_command(get_user_cli, name='get-user') +scim_group.add_command(create_user_cli, name='create-user') +scim_group.add_command(update_user_by_id_cli, name='update-user') +scim_group.add_command(overwrite_user_by_id_cli, name='overwrite-user') +scim_group.add_command(delete_user_cli, name='delete-user') +scim_group.add_command(scim_groups_group, name='groups') + +# add the aliases +scim_group.add_command(groups_list_cli, name='list-groups') +scim_group.add_command(groups_get_cli, name='get-group') +scim_group.add_command(groups_create_cli, name='create-group') +scim_group.add_command(groups_update_cli, name='update-group') +scim_group.add_command(groups_delete_cli, name='delete-group') + +scim_groups_group.add_command(groups_list_cli, name='list') +scim_groups_group.add_command(groups_get_cli, name='get') +scim_groups_group.add_command(groups_create_cli, name='create') +scim_groups_group.add_command(groups_add_user_cli, name='add-user') +scim_groups_group.add_command(groups_remove_user_cli, name='remove-user') +scim_groups_group.add_command(groups_delete_cli, name='delete') +# scim_groups_group.add_command(group_add_user_cli, name='delete') diff --git a/databricks_cli/scim/exceptions.py b/databricks_cli/scim/exceptions.py new file mode 100644 index 00000000..f90e0885 --- /dev/null +++ b/databricks_cli/scim/exceptions.py @@ -0,0 +1,26 @@ +# Databricks CLI +# Copyright 2018 Databricks, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"), except +# that the use of services to which certain application programming +# interfaces (each, an "API") connect requires that the user first obtain +# a license for the use of the APIs from Databricks, Inc. ("Databricks"), +# by creating an account at www.databricks.com and agreeing to either (a) +# the Community Edition Terms of Service, (b) the Databricks Terms of +# Service, or (c) another written agreement between Licensee and Databricks +# for the use of the APIs. +# +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class ScimError(Exception): + pass diff --git a/databricks_cli/sdk/__init__.py b/databricks_cli/sdk/__init__.py index 2e42a61c..dbd63536 100644 --- a/databricks_cli/sdk/__init__.py +++ b/databricks_cli/sdk/__init__.py @@ -52,4 +52,5 @@ help(JobsService) """ from .service import * +from .scim_service import * from .api_client import ApiClient diff --git a/databricks_cli/sdk/api_client.py b/databricks_cli/sdk/api_client.py index d5870a6a..c68bb58f 100644 --- a/databricks_cli/sdk/api_client.py +++ b/databricks_cli/sdk/api_client.py @@ -30,6 +30,7 @@ import base64 import json +import logging import warnings import requests import ssl @@ -127,6 +128,11 @@ def perform_query(self, method, path, data = {}, headers = None): except ValueError: pass raise requests.exceptions.HTTPError(message, response=e.response) + + # delete returns nothing. + if method.lower() == 'delete': + return {} + return resp.json() diff --git a/databricks_cli/sdk/scim_service.py b/databricks_cli/sdk/scim_service.py new file mode 100644 index 00000000..49724453 --- /dev/null +++ b/databricks_cli/sdk/scim_service.py @@ -0,0 +1,161 @@ +# from typing import List, Optional +# +# assert List +# assert Optional + +from databricks_cli.utils import debug + + +class ScimService(object): + ACCEPT_TYPE = 'application/scim+json' + PREVIEW_BASE = '/preview/scim/v2/' + + FILTERS = { + # Operator: Description, # Behavior + "eq": "equals", # Attribute and operator values must be identical. + "ne": "not_equal", # equal to Attribute and operator values are not identical. + "co": "contains", # Operator value must be a substring of attribute value. + "sw": "starts_with", # with Attribute must start with and contain operator value. + "and": "and", # AND Match when all expressions evaluate to true. + "or": "or", # OR Match when any expression evaluates to true. + } + + def __init__(self, client): + self.client = client + + @classmethod + def _add_accept(cls, headers): + if headers is None: + headers = {} + headers.update({'Accept': 'application/scim+json'}) + return headers + + def users(self, filters=None, headers=None): + headers = self._add_accept(headers) + if filters is not None: + filters = '?filter=' + filters + else: + filters = '' + return self.client.perform_query('GET', '{}Users{}'.format(self.PREVIEW_BASE, filters), headers=headers) + + def user_by_id(self, user_id, headers=None): + headers = self._add_accept(headers) + return self.client.perform_query('GET', '{}Users/{}'.format(self.PREVIEW_BASE, user_id), headers=headers) + + @classmethod + def _update_user_data(cls, _data, groups, entitlements, roles): + + if groups is not None: + _data.update({'groups': cls.ids_list_to_values_list(groups)}) + if entitlements is not None: + _data.update({'entitlements': cls.ids_list_to_values_list(entitlements)}) + if roles is not None: + _data.update({'roles': cls.ids_list_to_values_list(roles)}) + + return _data + + def create_user(self, user_name, groups, entitlements, roles, headers=None): + headers = self._add_accept(headers) + _data = self._update_user_data({ + 'schemas': [ + 'urn:ietf:params:scim:schemas:core:2.0:User' + ], + 'userName': user_name, + }, groups=groups, entitlements=entitlements, roles=roles) + + return self.create_user_json(_data, headers) + + def create_user_json(self, data, headers=None): + headers = self._add_accept(headers) + return self.client.perform_query('POST', '{}Users'.format(self.PREVIEW_BASE), data=data, headers=headers) + + def update_user_by_id(self, user_id, operation, path, values, headers=None): + headers = self._add_accept(headers) + _data = { + 'schemas': [ + 'urn:ietf:params:scim:api:messages:2.0:PatchOp' + ], + 'op': operation, + 'path': path, + 'value': self.ids_list_to_values_list(values) + } + return self.client.perform_query('PATCH', '{}Users/{}'.format(self.PREVIEW_BASE, user_id), data=_data, + headers=headers) + + def overwrite_user_by_id(self, user_id, user_name, groups, entitlements, roles, headers=None): + headers = self._add_accept(headers) + + _data = self._update_user_data({ + 'schemas': [ + 'urn:ietf:params:scim:schemas:core:2.0:User' + ], + 'userName': user_name, + }, groups=groups, entitlements=entitlements, roles=roles) + + return self.client.perform_query('PUT', '{}Users/{}'.format(self.PREVIEW_BASE, user_id), data=_data, + headers=headers) + + def delete_user_by_id(self, user_id, headers=None): + headers = self._add_accept(headers) + return self.client.perform_query('DELETE', '{}Users/{}'.format(self.PREVIEW_BASE, user_id), headers=headers) + + def groups(self, filters=None, headers=None): + headers = self._add_accept(headers) + if filters is not None: + if isinstance(filters, list): + filters = ' '.join(filters) + filters = '?filter=' + filters + else: + filters = '' + return self.client.perform_query('GET', '{}Groups{}'.format(self.PREVIEW_BASE, filters), headers=headers) + + def group_by_id(self, group_id, headers=None): + headers = self._add_accept(headers) + return self.client.perform_query('GET', '{}Groups/{}'.format(self.PREVIEW_BASE, group_id), headers=headers) + + def create_group_internal(self, group_name, members, headers=None): + headers = self._add_accept(headers) + + _data = { + 'schemas': [ + 'urn:ietf:params:scim:schemas:core:2.0:Group' + ], + 'displayName': group_name, + # members is a list of ids, we need to convert it to a list of hashes. + 'members': self.ids_list_to_values_list(members) + } + + return self.client.perform_query('POST', '{}Groups'.format(self.PREVIEW_BASE), data=_data, headers=headers) + + def update_group_by_id(self, group_id, operation, values, headers=None): + headers = self._add_accept(headers) + + operation_data = { + 'op': operation, + 'value': { + 'members': self.ids_list_to_values_list(values) + } + } + + # https://help.databricks.com/hc/en-us/requests/24702 remove requires a path + if operation == 'remove': + operation_data['path'] = 'members' + + _data = { + 'schemas': [ + 'urn:ietf:params:scim:api:messages:2.0:PatchOp' + ], + 'Operations': [operation_data] + } + + debug('update_group_by_id', 'data: {}'.format(_data)) + return self.client.perform_query('PATCH', '{}Groups/{}'.format(self.PREVIEW_BASE, group_id), data=_data, + headers=headers) + + def delete_group_by_id(self, group_id, headers=None): + headers = self._add_accept(headers) + return self.client.perform_query('DELETE', '{}Groups/{}'.format(self.PREVIEW_BASE, group_id), headers=headers) + + @classmethod + def ids_list_to_values_list(cls, ids_list): + return [{'value': id_value} for id_value in ids_list] diff --git a/databricks_cli/utils.py b/databricks_cli/utils.py index 86b31674..c9c496bc 100644 --- a/databricks_cli/utils.py +++ b/databricks_cli/utils.py @@ -1,3 +1,4 @@ + # Databricks CLI # Copyright 2017 Databricks, Inc. # @@ -21,6 +22,7 @@ # See the License for the specific language governing permissions and # limitations under the License. + import sys import traceback from json import dumps as json_dumps, loads as json_loads @@ -85,6 +87,18 @@ def decorator(*args, **kwargs): return decorator +def is_debug_mode(): + ctx = click.get_current_context() + context_object = ctx.ensure_object(ContextObject) + return context_object.debug_mode + + +def debug(name, content): + if is_debug_mode(): + # pylint:disable=superfluous-parens + print('{}: {}'.format(name, json_dumps(content, indent=4))) + + def error_and_quit(message): ctx = click.get_current_context() context_object = ctx.ensure_object(ContextObject) @@ -131,3 +145,12 @@ def for_profile(profile): 'Please configure by entering ' '`{argv} configure --profile {profile}`').format( profile=profile, argv=sys.argv[0])) + + +def is_int(s): + # type: (str) -> bool + try: + int(s) + return True + except ValueError: + return False diff --git a/tests/scim/test_user_args.py b/tests/scim/test_user_args.py new file mode 100644 index 00000000..e6b346fa --- /dev/null +++ b/tests/scim/test_user_args.py @@ -0,0 +1,23 @@ +import pytest + +from databricks_cli.scim.cli import validate_user_params + + +@pytest.mark.parametrize('json_file, json, user_name, groups, entitlements, roles, should_fail', [ + # json_file, json, user_name, groups, entitlements, roles, should_fail + [None, None, None, None, None, None, True], + [None, None, 'ac', None, None, None, False], + [None, '{}', None, None, None, None, False], + [None, '{}', 'ac', None, None, None, True], + ['so', None, None, None, None, None, False], + ['so', None, 'ac', None, None, None, True], + ['so', '{}', None, None, None, None, True], + ['so', '{}', 'ac', None, None, None, True], + +]) +def test_user_args(json_file, json, user_name, groups, entitlements, roles, should_fail): + if should_fail: + with pytest.raises(RuntimeError): + validate_user_params(json_file, json, user_name, groups, entitlements, roles) + else: + validate_user_params(json_file, json, user_name, groups, entitlements, roles) From cd51f8fb9eaf40677751aad43b9dcafadf8cfd2f Mon Sep 17 00:00:00 2001 From: Allen Reese Date: Fri, 14 Aug 2020 09:14:40 -0700 Subject: [PATCH 2/4] Add scim group --- databricks_cli/cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/databricks_cli/cli.py b/databricks_cli/cli.py index 9c527ffd..471ef247 100644 --- a/databricks_cli/cli.py +++ b/databricks_cli/cli.py @@ -25,6 +25,7 @@ from databricks_cli.configure.config import profile_option, debug_option from databricks_cli.libraries.cli import libraries_group +from databricks_cli.scim.cli import scim_group from databricks_cli.version import print_version_callback, version from databricks_cli.utils import CONTEXT_SETTINGS from databricks_cli.configure.cli import configure_cli @@ -61,6 +62,7 @@ def cli(): cli.add_command(secrets_group, name='secrets') cli.add_command(stack_group, name='stack') cli.add_command(groups_group, name='groups') +cli.add_command(scim_group, name='scim') cli.add_command(instance_pools_group, name="instance-pools") cli.add_command(pipelines_group, name='pipelines') From 3cfbcbb7f020529b2f7e4bde888c7cf57eb94c43 Mon Sep 17 00:00:00 2001 From: Allen Reese Date: Wed, 30 Sep 2020 15:16:52 -0700 Subject: [PATCH 3/4] New files should have the correct copyright headers --- databricks_cli/sdk/scim_service.py | 27 +++++++++++++++++++++++---- tests/scim/test_user_args.py | 24 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/databricks_cli/sdk/scim_service.py b/databricks_cli/sdk/scim_service.py index 49724453..108aeb29 100644 --- a/databricks_cli/sdk/scim_service.py +++ b/databricks_cli/sdk/scim_service.py @@ -1,8 +1,27 @@ -# from typing import List, Optional # -# assert List -# assert Optional - +# Databricks CLI +# Copyright 2020 Databricks, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"), except +# that the use of services to which certain application programming +# interfaces (each, an "API") connect requires that the user first obtain +# a license for the use of the APIs from Databricks, Inc. ("Databricks"), +# by creating an account at www.databricks.com and agreeing to either (a) +# the Community Edition Terms of Service, (b) the Databricks Terms of +# Service, or (c) another written agreement between Licensee and Databricks +# for the use of the APIs. +# +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# from databricks_cli.utils import debug diff --git a/tests/scim/test_user_args.py b/tests/scim/test_user_args.py index e6b346fa..ac51e374 100644 --- a/tests/scim/test_user_args.py +++ b/tests/scim/test_user_args.py @@ -1,3 +1,27 @@ +# +# Databricks CLI +# Copyright 2020 Databricks, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"), except +# that the use of services to which certain application programming +# interfaces (each, an "API") connect requires that the user first obtain +# a license for the use of the APIs from Databricks, Inc. ("Databricks"), +# by creating an account at www.databricks.com and agreeing to either (a) +# the Community Edition Terms of Service, (b) the Databricks Terms of +# Service, or (c) another written agreement between Licensee and Databricks +# for the use of the APIs. +# +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# import pytest from databricks_cli.scim.cli import validate_user_params From 6a30f2540009a544ff17f7daa729c0f67712a593 Mon Sep 17 00:00:00 2001 From: Allen Reese Date: Fri, 9 Oct 2020 10:06:53 -0700 Subject: [PATCH 4/4] Remove debug logging from the scim api that was only required during development Fix delete method to only return an empty dict if there was no body in the response. --- databricks_cli/click_types.py | 27 --------------------------- databricks_cli/scim/api.py | 30 +++++++----------------------- databricks_cli/scim/exceptions.py | 26 -------------------------- databricks_cli/sdk/api_client.py | 5 +++-- databricks_cli/sdk/scim_service.py | 2 -- databricks_cli/utils.py | 6 ------ 6 files changed, 10 insertions(+), 86 deletions(-) delete mode 100644 databricks_cli/scim/exceptions.py diff --git a/databricks_cli/click_types.py b/databricks_cli/click_types.py index 58a747b6..ff508a74 100644 --- a/databricks_cli/click_types.py +++ b/databricks_cli/click_types.py @@ -20,13 +20,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -import logging import click from click import ParamType, Option, MissingParameter, UsageError -logger = logging.getLogger() - class OutputClickType(ParamType): name = 'FORMAT' @@ -142,30 +139,6 @@ def __init__(self): def set_debug(self, debug=False): self._debug = debug - if debug: - self.enable_debug_logging() - - def enable_debug_logging(self): - # These two lines enable debugging at httplib level (requests->urllib3->http.client) - # You will see the REQUEST, including HEADERS and DATA, and - # RESPONSE with HEADERS but without DATA. - # The only thing missing will be the response.body which is not logged. - try: - import http.client as http_client - except ImportError: - # Python 2 - import httplib as http_client - - # pylint:disable=superfluous-parens - print("HTTP debugging enabled") - http_client.HTTPConnection.debuglevel = 1 - - # You must initialize logging, otherwise you'll not see debug output. - logging.basicConfig() - logging.getLogger().setLevel(logging.DEBUG) - requests_log = logging.getLogger("requests.packages.urllib3") - requests_log.setLevel(logging.DEBUG) - requests_log.propagate = True if not self._debug: return diff --git a/databricks_cli/scim/api.py b/databricks_cli/scim/api.py index d294b529..ea7cf2d6 100644 --- a/databricks_cli/scim/api.py +++ b/databricks_cli/scim/api.py @@ -21,9 +21,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from databricks_cli.scim.exceptions import ScimError from databricks_cli.sdk import ScimService -from databricks_cli.utils import is_int, debug +from databricks_cli.utils import is_int + + +class ScimError(Exception): + pass class ScimApi(object): @@ -39,7 +42,6 @@ def get_group(self, group_id=None, group_name=None): else: filters = self.GROUP_NAME_FILTER.format(group_name) content = self.list_groups(filters=filters) - debug('get_group', content) return content def get_user(self, user_id=None, user_name=None): @@ -58,14 +60,12 @@ def get_user_id_for_user(self, user_name): def get_group_id_for_group(self, group_name): content = self.get_group(group_name=group_name) - debug('get_group_id_for_group', content) return self._parse_id_from_json(name='group', value=group_name, filters=self.GROUP_NAME_FILTER.format(group_name), data=content) def get_group_name_for_group(self, group_id): content = self.get_group(group_id=group_id) - debug('get_group_id_for_group', content) return self._parse_name_from_json(id_value=group_id, value=group_id, data=content) def delete_user(self, user_id=None, user_name=None): @@ -76,19 +76,10 @@ def delete_user(self, user_id=None, user_name=None): return content def list_users(self, filters=None, active=None): + # filtering on active doesn't work: # https://help.databricks.com/hc/en-us/requests/24688 - # filtering on active doesn't work. - # if active is not None: - # active_flag = 'active eq {}'.format(active) - # - # if filters is not None: - # filters = filters + ' and {}'.format(active_flag) - # else: - # filters = active_flag - - debug('list_users', 'filters: {}'.format(filters)) content = self.client.users(filters) - # debug('list_users', 'content: {}'.format(content)) + return self.filter_active_only(content, active) def get_user_by_id(self, user_id): @@ -127,7 +118,6 @@ def list_groups(self, filters=None, group_id=None, group_name=None): else: filters = extra_filter - debug('list_groups', 'filters: {}'.format(filters)) return self.client.groups(filters) def get_group_by_id(self, group_id): @@ -193,7 +183,6 @@ def groups_to_group_ids(cls, groups): def filter_active_only(cls, content, active): resources = content.get('Resources') if active is not None and resources: - debug('filter_active_only', 'Filtering for active == {}'.format(active)) content['Resources'] = [ resource for resource in resources if resource.get('active') == active ] @@ -208,7 +197,6 @@ def _parse_id_from_json(cls, name, value, filters, data): resources = data.get('Resources') if not resources: - debug('{} no resources'.format(name), resources) raise ScimError('Failed to find resources in json data for response: {}'.format(data)) if len(resources) != 1: @@ -218,12 +206,10 @@ def _parse_id_from_json(cls, name, value, filters, data): resource = resources[0] resource_id = resource.get('id') if not resource_id: - debug('{name} no {name}'.format(name=name), resource) raise ScimError( 'Expected {} id in resource using filter {} in json data: {}'.format(name, filters, data)) - debug('{name} {name}: '.format(name=name), resource_id) return resource_id @classmethod @@ -233,7 +219,6 @@ def _parse_name_from_json(cls, id_value, value, data): resources = data.get('Resources') if not resources: - debug('{} no resources'.format(id_value), resources) raise ScimError('Failed to find resources in json data for response: {}'.format(data)) resource_name = None @@ -242,7 +227,6 @@ def _parse_name_from_json(cls, id_value, value, data): resource_name = resource.get('displayName') break - debug('{name} {name}: '.format(name=id_value), resource_name) return resource_name @classmethod diff --git a/databricks_cli/scim/exceptions.py b/databricks_cli/scim/exceptions.py deleted file mode 100644 index f90e0885..00000000 --- a/databricks_cli/scim/exceptions.py +++ /dev/null @@ -1,26 +0,0 @@ -# Databricks CLI -# Copyright 2018 Databricks, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"), except -# that the use of services to which certain application programming -# interfaces (each, an "API") connect requires that the user first obtain -# a license for the use of the APIs from Databricks, Inc. ("Databricks"), -# by creating an account at www.databricks.com and agreeing to either (a) -# the Community Edition Terms of Service, (b) the Databricks Terms of -# Service, or (c) another written agreement between Licensee and Databricks -# for the use of the APIs. -# -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -class ScimError(Exception): - pass diff --git a/databricks_cli/sdk/api_client.py b/databricks_cli/sdk/api_client.py index c68bb58f..5fa06eb6 100644 --- a/databricks_cli/sdk/api_client.py +++ b/databricks_cli/sdk/api_client.py @@ -129,8 +129,9 @@ def perform_query(self, method, path, data = {}, headers = None): pass raise requests.exceptions.HTTPError(message, response=e.response) - # delete returns nothing. - if method.lower() == 'delete': + if method.lower() == 'delete' and not resp.content: + # it is possible for delete to return a completely empty body. + # If it does, return an empty dict rather than have resp.json throw. return {} return resp.json() diff --git a/databricks_cli/sdk/scim_service.py b/databricks_cli/sdk/scim_service.py index 108aeb29..a1fa3114 100644 --- a/databricks_cli/sdk/scim_service.py +++ b/databricks_cli/sdk/scim_service.py @@ -22,7 +22,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from databricks_cli.utils import debug class ScimService(object): @@ -167,7 +166,6 @@ def update_group_by_id(self, group_id, operation, values, headers=None): 'Operations': [operation_data] } - debug('update_group_by_id', 'data: {}'.format(_data)) return self.client.perform_query('PATCH', '{}Groups/{}'.format(self.PREVIEW_BASE, group_id), data=_data, headers=headers) diff --git a/databricks_cli/utils.py b/databricks_cli/utils.py index c78dbd0a..e6a4fe50 100644 --- a/databricks_cli/utils.py +++ b/databricks_cli/utils.py @@ -94,12 +94,6 @@ def is_debug_mode(): return context_object.debug_mode -def debug(name, content): - if is_debug_mode(): - # pylint:disable=superfluous-parens - print('{}: {}'.format(name, json_dumps(content, indent=4))) - - def error_and_quit(message): ctx = click.get_current_context() context_object = ctx.ensure_object(ContextObject)