From 6f11790f3c2ffd10aeae7ecebd574d726dc49420 Mon Sep 17 00:00:00 2001 From: Noorul Islam K M Date: Sun, 9 Feb 2014 00:25:44 +0530 Subject: [PATCH] Add plan manager and corresponding tests For m1 we are converting plan yaml file into json. This can be modified to use yaml itself once we have support for yaml in API layer. Change-Id: I96f986dde059d66633cbc24797a89619ea5bf52e --- openstack-common.conf | 1 + requirements.txt | 1 + solumclient/openstack/common/cliutils.py | 309 ++++++++++++++++++++++ solumclient/openstack/common/uuidutils.py | 37 +++ solumclient/solum.py | 48 ++-- solumclient/tests/test_solum.py | 19 ++ solumclient/tests/v1/test_plan.py | 184 +++++++++++++ solumclient/v1/client.py | 2 + solumclient/v1/plan.py | 107 ++++++++ 9 files changed, 690 insertions(+), 18 deletions(-) create mode 100644 solumclient/openstack/common/cliutils.py create mode 100644 solumclient/openstack/common/uuidutils.py create mode 100644 solumclient/tests/v1/test_plan.py create mode 100644 solumclient/v1/plan.py diff --git a/openstack-common.conf b/openstack-common.conf index c2d4097..984d4e4 100644 --- a/openstack-common.conf +++ b/openstack-common.conf @@ -2,6 +2,7 @@ # The list of modules to copy from oslo-incubator.git module=apiclient +module=cliutils module=log module=test diff --git a/requirements.txt b/requirements.txt index ecd85d2..499ad8e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ oslo.config>=1.2.0 iso8601>=0.1.8 requests>=1.1 python-keystoneclient>=0.6.0 +PyYAML>=3.1.0 stevedore>=0.14 diff --git a/solumclient/openstack/common/cliutils.py b/solumclient/openstack/common/cliutils.py new file mode 100644 index 0000000..264c2eb --- /dev/null +++ b/solumclient/openstack/common/cliutils.py @@ -0,0 +1,309 @@ +# Copyright 2012 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); 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. + +# W0603: Using the global statement +# W0621: Redefining name %s from outer scope +# pylint: disable=W0603,W0621 + +from __future__ import print_function + +import getpass +import inspect +import os +import sys +import textwrap + +import prettytable +import six +from six import moves + +from solumclient.openstack.common.apiclient import exceptions +from solumclient.openstack.common.gettextutils import _ +from solumclient.openstack.common import strutils +from solumclient.openstack.common import uuidutils + + +def validate_args(fn, *args, **kwargs): + """Check that the supplied args are sufficient for calling a function. + + >>> validate_args(lambda a: None) + Traceback (most recent call last): + ... + MissingArgs: Missing argument(s): a + >>> validate_args(lambda a, b, c, d: None, 0, c=1) + Traceback (most recent call last): + ... + MissingArgs: Missing argument(s): b, d + + :param fn: the function to check + :param arg: the positional arguments supplied + :param kwargs: the keyword arguments supplied + """ + argspec = inspect.getargspec(fn) + + num_defaults = len(argspec.defaults or []) + required_args = argspec.args[:len(argspec.args) - num_defaults] + + def isbound(method): + return getattr(method, 'im_self', None) is not None + + if isbound(fn): + required_args.pop(0) + + missing = [arg for arg in required_args if arg not in kwargs] + missing = missing[len(args):] + if missing: + raise exceptions.MissingArgs(missing) + + +def arg(*args, **kwargs): + """Decorator for CLI args. + + Example: + + >>> @arg("name", help="Name of the new entity") + ... def entity_create(args): + ... pass + """ + def _decorator(func): + add_arg(func, *args, **kwargs) + return func + return _decorator + + +def env(*args, **kwargs): + """Returns the first environment variable set. + + If all are empty, defaults to '' or keyword arg `default`. + """ + for arg in args: + value = os.environ.get(arg) + if value: + return value + return kwargs.get('default', '') + + +def add_arg(func, *args, **kwargs): + """Bind CLI arguments to a shell.py `do_foo` function.""" + + if not hasattr(func, 'arguments'): + func.arguments = [] + + # NOTE(sirp): avoid dups that can occur when the module is shared across + # tests. + if (args, kwargs) not in func.arguments: + # Because of the semantics of decorator composition if we just append + # to the options list positional options will appear to be backwards. + func.arguments.insert(0, (args, kwargs)) + + +def unauthenticated(func): + """Adds 'unauthenticated' attribute to decorated function. + + Usage: + + >>> @unauthenticated + ... def mymethod(f): + ... pass + """ + func.unauthenticated = True + return func + + +def isunauthenticated(func): + """Checks if the function does not require authentication. + + Mark such functions with the `@unauthenticated` decorator. + + :returns: bool + """ + return getattr(func, 'unauthenticated', False) + + +def print_list(objs, fields, formatters=None, sortby_index=0, + mixed_case_fields=None): + """Print a list or objects as a table, one row per object. + + :param objs: iterable of :class:`Resource` + :param fields: attributes that correspond to columns, in order + :param formatters: `dict` of callables for field formatting + :param sortby_index: index of the field for sorting table rows + :param mixed_case_fields: fields corresponding to object attributes that + have mixed case names (e.g., 'serverId') + """ + formatters = formatters or {} + mixed_case_fields = mixed_case_fields or [] + if sortby_index is None: + kwargs = {} + else: + kwargs = {'sortby': fields[sortby_index]} + pt = prettytable.PrettyTable(fields, caching=False) + pt.align = 'l' + + for o in objs: + row = [] + for field in fields: + if field in formatters: + row.append(formatters[field](o)) + else: + if field in mixed_case_fields: + field_name = field.replace(' ', '_') + else: + field_name = field.lower().replace(' ', '_') + data = getattr(o, field_name, '') + row.append(data) + pt.add_row(row) + + print(strutils.safe_encode(pt.get_string(**kwargs))) + + +def print_dict(dct, dict_property="Property", wrap=0): + """Print a `dict` as a table of two columns. + + :param dct: `dict` to print + :param dict_property: name of the first column + :param wrap: wrapping for the second column + """ + pt = prettytable.PrettyTable([dict_property, 'Value'], caching=False) + pt.align = 'l' + for k, v in six.iteritems(dct): + # convert dict to str to check length + if isinstance(v, dict): + v = six.text_type(v) + if wrap > 0: + v = textwrap.fill(six.text_type(v), wrap) + # if value has a newline, add in multiple rows + # e.g. fault with stacktrace + if v and isinstance(v, six.string_types) and r'\n' in v: + lines = v.strip().split(r'\n') + col1 = k + for line in lines: + pt.add_row([col1, line]) + col1 = '' + else: + pt.add_row([k, v]) + print(strutils.safe_encode(pt.get_string())) + + +def get_password(max_password_prompts=3): + """Read password from TTY.""" + verify = strutils.bool_from_string(env("OS_VERIFY_PASSWORD")) + pw = None + if hasattr(sys.stdin, "isatty") and sys.stdin.isatty(): + # Check for Ctrl-D + try: + for __ in moves.range(max_password_prompts): + pw1 = getpass.getpass("OS Password: ") + if verify: + pw2 = getpass.getpass("Please verify: ") + else: + pw2 = pw1 + if pw1 == pw2 and pw1: + pw = pw1 + break + except EOFError: + pass + return pw + + +def find_resource(manager, name_or_id, **find_args): + """Look for resource in a given manager. + + Used as a helper for the _find_* methods. + Example: + + def _find_hypervisor(cs, hypervisor): + #Get a hypervisor by name or ID. + return cliutils.find_resource(cs.hypervisors, hypervisor) + """ + # first try to get entity as integer id + try: + return manager.get(int(name_or_id)) + except (TypeError, ValueError, exceptions.NotFound): + pass + + # now try to get entity as uuid + try: + tmp_id = strutils.safe_encode(name_or_id) + + if uuidutils.is_uuid_like(tmp_id): + return manager.get(tmp_id) + except (TypeError, ValueError, exceptions.NotFound): + pass + + # for str id which is not uuid + if getattr(manager, 'is_alphanum_id_allowed', False): + try: + return manager.get(name_or_id) + except exceptions.NotFound: + pass + + try: + try: + return manager.find(human_id=name_or_id, **find_args) + except exceptions.NotFound: + pass + + # finally try to find entity by name + try: + resource = getattr(manager, 'resource_class', None) + name_attr = resource.NAME_ATTR if resource else 'name' + kwargs = {name_attr: name_or_id} + kwargs.update(find_args) + return manager.find(**kwargs) + except exceptions.NotFound: + msg = _("No %(name)s with a name or " + "ID of '%(name_or_id)s' exists.") % \ + { + "name": manager.resource_class.__name__.lower(), + "name_or_id": name_or_id + } + raise exceptions.CommandError(msg) + except exceptions.NoUniqueMatch: + msg = _("Multiple %(name)s matches found for " + "'%(name_or_id)s', use an ID to be more specific.") % \ + { + "name": manager.resource_class.__name__.lower(), + "name_or_id": name_or_id + } + raise exceptions.CommandError(msg) + + +def service_type(stype): + """Adds 'service_type' attribute to decorated function. + + Usage: + @service_type('volume') + def mymethod(f): + ... + """ + def inner(f): + f.service_type = stype + return f + return inner + + +def get_service_type(f): + """Retrieves service type from function.""" + return getattr(f, 'service_type', None) + + +def pretty_choice_list(l): + return ', '.join("'%s'" % i for i in l) + + +def exit(msg=''): + if msg: + print (msg, file=sys.stderr) + sys.exit(1) diff --git a/solumclient/openstack/common/uuidutils.py b/solumclient/openstack/common/uuidutils.py new file mode 100644 index 0000000..234b880 --- /dev/null +++ b/solumclient/openstack/common/uuidutils.py @@ -0,0 +1,37 @@ +# Copyright (c) 2012 Intel Corporation. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); 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. + +""" +UUID related utilities and helper functions. +""" + +import uuid + + +def generate_uuid(): + return str(uuid.uuid4()) + + +def is_uuid_like(val): + """Returns validation of a value as a UUID. + + For our purposes, a UUID is a canonical form string: + aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa + + """ + try: + return str(uuid.UUID(val)) == val + except (TypeError, ValueError, AttributeError): + return False diff --git a/solumclient/solum.py b/solumclient/solum.py index f0bc82f..c746e33 100644 --- a/solumclient/solum.py +++ b/solumclient/solum.py @@ -32,11 +32,14 @@ Notes: from __future__ import print_function import argparse +import json import sys import six +import yaml from solumclient.common import cli_utils +from solumclient.openstack.common import cliutils from solumclient.openstack.common import strutils SOLUM_CLI_VER = "2014-01-30" @@ -47,33 +50,42 @@ class AppCommands(cli_utils.CommandsBase): def create(self): """Create an application.""" - self.parser.add_argument('plan_name', - help="Tenant/project-wide unique plan name") - self.parser.add_argument('--repo', - help="Code repository URL") - self.parser.add_argument('--build', - default='yes', - help="Build flag") + self.parser.add_argument('plan_file', + help="Plan file") args = self.parser.parse_args() - #TODO(noorul): Add REST communications - print("app create plan_name=%s repo=%s build=%s" % ( - args.plan_name, - args.repo, - args.build)) + print("app create plan_file=%s" % args.plan_file) + with open(args.plan_file) as definition_file: + definition = definition_file.read() + + # Convert yaml to json until we add yaml support in API layer. + try: + data = yaml.load(definition) + except yaml.YAMLError as exc: + print("Error in plan file: %s", str(exc)) + sys.exit(1) + + json_data = json.dumps(data) + plan = self.client.plans.create(json_data) + + fields = ['uuid', 'name', 'description'] + data = dict([(f, getattr(plan, f, '')) + for f in fields]) + cliutils.print_dict(data, wrap=72) def delete(self): """Delete an application.""" - self.parser.add_argument('plan_name', - help="Tenant/project-wide unique plan name") + self.parser.add_argument('plan_uuid', + help="Tenant/project-wide unique plan uuid") args = self.parser.parse_args() - #TODO(noorul): Add REST communications - print("app delete plan_name=%s" % ( - args.plan_name)) + print("app delete plan_uuid=%s" % args.plan_uuid) + self.client.plans.delete(plan_id=args.plan_uuid) def list(self): """List all applications.""" - #TODO(noorul): Add REST communications print("app list") + fields = ['uuid', 'name', 'description'] + response = self.client.plans.list() + cliutils.print_list(response, fields) class AssemblyCommands(cli_utils.CommandsBase): diff --git a/solumclient/tests/test_solum.py b/solumclient/tests/test_solum.py index 9f7a4a1..ecf305c 100644 --- a/solumclient/tests/test_solum.py +++ b/solumclient/tests/test_solum.py @@ -25,6 +25,7 @@ from solumclient.openstack.common.apiclient import auth from solumclient import solum from solumclient.tests import base from solumclient.v1 import assembly +from solumclient.v1 import plan FAKE_ENV = {'OS_USERNAME': 'username', 'OS_PASSWORD': 'password', @@ -117,3 +118,21 @@ class TestSolum(base.TestCase): self.assertThat(out, matchers.MatchesRegex(r, self.re_options)) + + @mock.patch.object(plan.PlanManager, "create") + def test_app_create(self, mock_app_create): + self.make_env() + required = [ + '.*?^Solum Python Command Line Client', + '.*?^app create plan_file=/dev/null' + ] + + mock_app_create.side_effect = ( + lambda plan_content: [] + ) + + out = self.shell("app create /dev/null") + for r in required: + self.assertThat(out, + matchers.MatchesRegex(r, + self.re_options)) diff --git a/solumclient/tests/v1/test_plan.py b/solumclient/tests/v1/test_plan.py new file mode 100644 index 0000000..9c49df2 --- /dev/null +++ b/solumclient/tests/v1/test_plan.py @@ -0,0 +1,184 @@ +# Copyright 2013 - Noorul Islam K M +# +# Licensed under the Apache License, Version 2.0 (the "License"); 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 solumclient.openstack.common.apiclient import fake_client +from solumclient.tests import base +from solumclient.v1 import client as solumclient +from solumclient.v1 import plan + + +plan_list = [ + { + 'uri': 'http://example.com/v1/plans/p1', + 'name': 'Example plan 1', + 'type': 'plan', + 'tags': ['small'], + 'artifacts': ( + [{'name': 'My python app', + 'artifact_type': 'git_pull', + 'content': {'href': 'git://example.com/project.git'}, + 'requirements': [{ + 'requirement_type': 'git_pull', + 'language_pack': '1dae5a09ef2b4d8cbf3594b0eb4f6b94', + 'fulfillment': '1dae5a09ef2b4d8cbf3594b0eb4f6b94'}]}]), + 'services': [{'name': 'Build Service', + 'id': 'build', + 'characteristics': ['python_build_service']}], + 'project_id': '1dae5a09ef2b4d8cbf3594b0eb4f6b94', + 'user_id': '55f41cf46df74320b9486a35f5d28a11', + 'description': 'A plan with no services or artifacts shown' + }, + { + 'uri': 'http://example.com/v1/plans/p2', + 'name': 'Example plan 2', + 'type': 'plan', + 'tags': ['small'], + 'artifacts': ( + [{'name': 'My java app', + 'artifact_type': 'git_pull', + 'content': {'href': 'git://example.com/project.git'}, + 'requirements': [{ + 'requirement_type': 'git_pull', + 'language_pack': '1dae5a09ef2b4d8cbf3594b0eb4f6b94', + 'fulfillment': '1dae5a09ef2b4d8cbf3594b0eb4f6b94'}]}]), + 'services': [{'name': 'Build Service', + 'id': 'build', + 'characteristics': ['python_build_service']}], + 'project_id': '1dae5a09ef2b4d8cbf3594b0eb4f6b94', + 'user_id': '55f41cf46df74320b9486a35f5d28a11', + 'description': 'A plan with no services or artifacts shown' + }, +] + +artifacts = [{'name': 'My python app', + 'artifact_type': 'git_pull', + 'content': {'href': 'git://example.com/project.git'}, + 'requirements': [{ + 'requirement_type': 'git_pull', + 'language_pack': '1dae5a09ef2b4d8cbf3594b0eb4f6b94', + 'fulfillment': '1dae5a09ef2b4d8cbf3594b0eb4f6b94'}]}] + +services = [{'name': 'Build Service', + 'id': 'build', + 'characteristics': ['python_build_service']}] + +plan_fixture = { + 'uri': 'http://example.com/v1/plans/p1', + 'name': 'Example plan', + 'type': 'plan', + 'tags': ['small'], + 'artifacts': artifacts, + 'services': services, + 'project_id': '1dae5a09ef2b4d8cbf3594b0eb4f6b94', + 'user_id': '55f41cf46df74320b9486a35f5d28a11', + 'description': 'A plan with no services or artifacts shown' +} + +plan_file_fixture = ( + '{"artifacts": [{"artifact_type": "application.heroku", ' + '"content": {"href": "http://github.com/some/project"}, ' + '"name": "My Python App", "language-pack": "language-pack-id"}], ' + '"name": "My Python App"}') + +fixtures_list = { + '/v1/plans': { + 'GET': ( + {}, + plan_list + ), + } +} + + +fixtures_get = { + '/v1/plans/p1': { + 'GET': ( + {}, + plan_fixture + ), + } +} + + +fixtures_create = { + '/v1/plans': { + 'POST': ( + {}, + plan_fixture + ), + } +} + +fixtures_put = { + '/v1/plans/p1': { + 'PUT': ( + {}, + plan_fixture + ), + } +} + + +class PlanManagerTest(base.TestCase): + + def test_list_all(self): + fake_http_client = fake_client.FakeHTTPClient(fixtures=fixtures_list) + api_client = solumclient.Client(fake_http_client) + mgr = plan.PlanManager(api_client) + plans = mgr.list() + self.assertEqual(len(plans), 2) + self.assertIn('Plan', repr(plans[0])) + self.assertIn('Artifact', repr(plans[0].artifacts[0])) + self.assertIn('ServiceReference', repr(plans[0].services[0])) + self.assertEqual(plans[0].uri, plan_list[0]['uri']) + self.assertEqual(plans[1].uri, plan_list[1]['uri']) + + def test_create(self): + fake_http_client = fake_client.FakeHTTPClient(fixtures=fixtures_create) + api_client = solumclient.Client(fake_http_client) + mgr = plan.PlanManager(api_client) + plan_obj = mgr.create(plan_file_fixture) + self.assertIn('Plan', repr(plan_obj)) + self.assertIn('Artifact', repr(plan_obj.artifacts[0])) + self.assertIn('ServiceReference', repr(plan_obj.services[0])) + self.assertEqual(plan_obj.uri, plan_fixture['uri']) + self.assertEqual(plan_obj.type, plan_fixture['type']) + self.assertEqual(plan_obj.project_id, plan_fixture['project_id']) + self.assertEqual(plan_obj.user_id, plan_fixture['user_id']) + + def test_get(self): + fake_http_client = fake_client.FakeHTTPClient(fixtures=fixtures_get) + api_client = solumclient.Client(fake_http_client) + mgr = plan.PlanManager(api_client) + plan_obj = mgr.get(plan_id='p1') + self.assertIn('Plan', repr(plan_obj)) + self.assertIn('Artifact', repr(plan_obj.artifacts[0])) + self.assertIn('ServiceReference', repr(plan_obj.services[0])) + self.assertEqual(plan_obj.uri, plan_fixture['uri']) + self.assertEqual(plan_obj.type, plan_fixture['type']) + self.assertEqual(plan_obj.project_id, plan_fixture['project_id']) + self.assertEqual(plan_obj.user_id, plan_fixture['user_id']) + + def test_put(self): + fake_http_client = fake_client.FakeHTTPClient(fixtures=fixtures_put) + api_client = solumclient.Client(fake_http_client) + mgr = plan.PlanManager(api_client) + plan_obj = mgr.put(plan_file_fixture, plan_id='p1') + self.assertIn('Plan', repr(plan_obj)) + self.assertIn('Artifact', repr(plan_obj.artifacts[0])) + self.assertIn('ServiceReference', repr(plan_obj.services[0])) + self.assertEqual(plan_obj.uri, plan_fixture['uri']) + self.assertEqual(plan_obj.type, plan_fixture['type']) + self.assertEqual(plan_obj.project_id, plan_fixture['project_id']) + self.assertEqual(plan_obj.user_id, plan_fixture['user_id']) diff --git a/solumclient/v1/client.py b/solumclient/v1/client.py index 24a8974..bf14346 100644 --- a/solumclient/v1/client.py +++ b/solumclient/v1/client.py @@ -15,6 +15,7 @@ from solumclient.openstack.common.apiclient import client from solumclient.v1 import assembly from solumclient.v1 import component +from solumclient.v1 import plan from solumclient.v1 import platform @@ -29,3 +30,4 @@ class Client(client.BaseClient): self.assemblies = assembly.AssemblyManager(self) self.components = component.ComponentManager(self) self.platform = platform.PlatformManager(self) + self.plans = plan.PlanManager(self) diff --git a/solumclient/v1/plan.py b/solumclient/v1/plan.py new file mode 100644 index 0000000..e4e9b90 --- /dev/null +++ b/solumclient/v1/plan.py @@ -0,0 +1,107 @@ +# Copyright 2013 - Noorul Islam K M +# +# Licensed under the Apache License, Version 2.0 (the "License"); 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 six + +from solumclient.common import base as solum_base +from solumclient.openstack.common.apiclient import base as apiclient_base + + +class Requirement(apiclient_base.Resource): + def __repr__(self): + return "" % self._info + + +class ServiceReference(apiclient_base.Resource): + def __repr__(self): + return "" % self._info + + +class Artifact(apiclient_base.Resource): + def __repr__(self): + return "" % self._info + + def _add_requirements_details(self, req_list): + return [Requirement(None, res, loaded=True) + for res in req_list if req_list] + + def _add_details(self, info): + for (k, v) in six.iteritems(info): + try: + if k == 'requirements': + v = self._add_requirements_details(v) + setattr(self, k, v) + self._info[k] = v + except AttributeError: + # In this case we already defined the attribute on the class + pass + + +class Plan(apiclient_base.Resource): + def __repr__(self): + return "" % self._info + + def _add_artifact_details(self, artf_list): + return [Artifact(None, res, loaded=True) + for res in artf_list if artf_list] + + def _add_services_details(self, serv_list): + return [ServiceReference(None, res, loaded=True) + for res in serv_list if serv_list] + + def _add_details(self, info): + for (k, v) in six.iteritems(info): + try: + if k == 'artifacts': + v = self._add_artifact_details(v) + elif k == 'services': + v = self._add_services_details(v) + setattr(self, k, v) + self._info[k] = v + except AttributeError: + # In this case we already defined the attribute on the class + pass + + +class PlanManager(solum_base.CrudManager): + resource_class = Plan + collection_key = 'plans' + key = 'plan' + + def list(self, **kwargs): + return super(PlanManager, self).list(base_url="/v1", **kwargs) + + def create(self, plan, **kwargs): + kwargs = self._filter_kwargs(kwargs) + kwargs['data'] = plan + kwargs.setdefault("headers", kwargs.get("headers", {})) + kwargs['headers']['Content-Type'] = 'application/json' + body = self.client.post(self.build_url(base_url="/v1", **kwargs), + **kwargs).json() + return self.resource_class(self, body) + + def get(self, **kwargs): + return super(PlanManager, self).get(base_url="/v1", **kwargs) + + def put(self, plan, **kwargs): + kwargs = self._filter_kwargs(kwargs) + kwargs['data'] = plan + kwargs.setdefault("headers", kwargs.get("headers", {})) + kwargs['headers']['Content-Type'] = 'application/json' + body = self.client.put(self.build_url(base_url="/v1", **kwargs), + **kwargs).json() + return self.resource_class(self, body) + + def delete(self, **kwargs): + return super(PlanManager, self).delete(base_url="/v1", **kwargs)