Test Failed
Pull Request — master (#3204)
by W
03:58
created

PythonActionWrapper._get_action_instance()   C

Complexity

Conditions 7

Size

Total Lines 50

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
dl 0
loc 50
rs 5.5
c 0
b 0
f 0
1
# Licensed to the StackStorm, Inc ('StackStorm') under one or more
2
# contributor license agreements.  See the NOTICE file distributed with
3
# this work for additional information regarding copyright ownership.
4
# The ASF licenses this file to You under the Apache License, Version 2.0
5
# (the "License"); you may not use this file except in compliance with
6
# the License.  You may obtain a copy of the License at
7
#
8
#     http://www.apache.org/licenses/LICENSE-2.0
9
#
10
# Unless required by applicable law or agreed to in writing, software
11
# distributed under the License is distributed on an "AS IS" BASIS,
12
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
# See the License for the specific language governing permissions and
14
# limitations under the License.
15
16
import sys
17
18
# Note: This work-around is required to fix the issue with other Python modules which live
19
# inside this directory polluting and masking sys.path for Python runner actions.
20
# Since this module is ran as a Python script inside a subprocess, directory where the script
21
# lives gets added to sys.path and we don't want that.
22
# Note: We need to use just the suffix, because full path is different depending if the process
23
# is ran in virtualenv or not
24
RUNNERS_PATH_SUFFIX = 'st2common/runners'
25
if __name__ == '__main__':
26
    script_path = sys.path[0]
27
    if RUNNERS_PATH_SUFFIX in script_path:
28
        sys.path.pop(0)
29
30
import sys
0 ignored issues
show
Unused Code introduced by
The import sys was already done on line 16. You should be able to
remove this line.
Loading history...
31
import json
32
import argparse
33
import traceback
34
35
from oslo_config import cfg
36
37
from st2common import log as logging
38
from st2actions import config
39
from st2common.runners import base_action as legacy
40
from st2common.runners.utils import get_logger_for_python_runner_action
41
from st2common.runners.utils import get_action_class_instance
42
from st2common.util import loader as action_loader
43
from st2common.util.config_loader import ContentPackConfigLoader
44
from st2common.constants.action import ACTION_OUTPUT_RESULT_DELIMITER
45
from st2common.constants.keyvalue import SYSTEM_SCOPE
46
from st2common.constants.runners import PYTHON_RUNNER_INVALID_ACTION_STATUS_EXIT_CODE
47
from st2common.database_setup import db_setup
48
from st2forge.packs import actions as forge
49
50
__all__ = [
51
    'PythonActionWrapper',
52
    'ActionService'
53
]
54
55
LOG = logging.getLogger(__name__)
56
57
INVALID_STATUS_ERROR_MESSAGE = """
58
If this is an existing action which returns a tuple with two items, it needs to be updated to
59
either:
60
61
1. Return a list instead of a tuple
62
2. Return a tuple where a first items is a status flag - (True, ('item1', 'item2'))
63
64
For more information, please see: https://docs.stackstorm.com/upgrade_notes.html#st2-v1-6
65
""".strip()
66
67
68
class ActionService(object):
69
    """
70
    Instance of this class is passed to the action instance and exposes "public" methods which can
71
    be called by the action.
72
    """
73
74
    def __init__(self, action_wrapper):
75
        self._action_wrapper = action_wrapper
76
        self._datastore_service = None
77
78
    @property
79
    def datastore_service(self):
80
        # Late import to avoid very expensive in-direct import (~1 second) when this function is
81
        # not called / used
82
        from st2common.services.datastore import DatastoreService
83
84
        if not self._datastore_service:
85
            action_name = self._action_wrapper._class_name
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like _class_name was declared protected and should not be accessed from this context.

Prefixing a member variable _ is usually regarded as the equivalent of declaring it with protected visibility that exists in other languages. Consequentially, such a member should only be accessed from the same class or a child class:

class MyParent:
    def __init__(self):
        self._x = 1;
        self.y = 2;

class MyChild(MyParent):
    def some_method(self):
        return self._x    # Ok, since accessed from a child class

class AnotherClass:
    def some_method(self, instance_of_my_child):
        return instance_of_my_child._x   # Would be flagged as AnotherClass is not
                                         # a child class of MyParent
Loading history...
86
            logger = get_logger_for_python_runner_action(action_name=action_name)
87
            self._datastore_service = DatastoreService(logger=logger,
88
                                                       pack_name=self._action_wrapper._pack,
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like _pack was declared protected and should not be accessed from this context.

Prefixing a member variable _ is usually regarded as the equivalent of declaring it with protected visibility that exists in other languages. Consequentially, such a member should only be accessed from the same class or a child class:

class MyParent:
    def __init__(self):
        self._x = 1;
        self.y = 2;

class MyChild(MyParent):
    def some_method(self):
        return self._x    # Ok, since accessed from a child class

class AnotherClass:
    def some_method(self, instance_of_my_child):
        return instance_of_my_child._x   # Would be flagged as AnotherClass is not
                                         # a child class of MyParent
Loading history...
89
                                                       class_name=self._action_wrapper._class_name,
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like _class_name was declared protected and should not be accessed from this context.

Prefixing a member variable _ is usually regarded as the equivalent of declaring it with protected visibility that exists in other languages. Consequentially, such a member should only be accessed from the same class or a child class:

class MyParent:
    def __init__(self):
        self._x = 1;
        self.y = 2;

class MyChild(MyParent):
    def some_method(self):
        return self._x    # Ok, since accessed from a child class

class AnotherClass:
    def some_method(self, instance_of_my_child):
        return instance_of_my_child._x   # Would be flagged as AnotherClass is not
                                         # a child class of MyParent
Loading history...
90
                                                       api_username='action_service')
91
        return self._datastore_service
92
93
    ##################################
94
    # Methods for datastore management
95
    ##################################
96
97
    def list_values(self, local=True, prefix=None):
98
        return self.datastore_service.list_values(local, prefix)
99
100
    def get_value(self, name, local=True, scope=SYSTEM_SCOPE, decrypt=False):
101
        return self.datastore_service.get_value(name, local, scope=scope, decrypt=decrypt)
102
103
    def set_value(self, name, value, ttl=None, local=True, scope=SYSTEM_SCOPE, encrypt=False):
104
        return self.datastore_service.set_value(name, value, ttl, local, scope=scope,
105
                                                encrypt=encrypt)
106
107
    def delete_value(self, name, local=True, scope=SYSTEM_SCOPE):
108
        return self.datastore_service.delete_value(name, local)
109
110
111
class PythonActionWrapper(object):
112
    def __init__(self, pack, file_path, parameters=None, user=None, parent_args=None):
0 ignored issues
show
Comprehensibility Bug introduced by
user is re-defining a name which is already available in the outer-scope (previously defined on line 267).

It is generally a bad practice to shadow variables from the outer-scope. In most cases, this is done unintentionally and might lead to unexpected behavior:

param = 5

class Foo:
    def __init__(self, param):   # "param" would be flagged here
        self.param = param
Loading history...
Comprehensibility Bug introduced by
parent_args is re-defining a name which is already available in the outer-scope (previously defined on line 268).

It is generally a bad practice to shadow variables from the outer-scope. In most cases, this is done unintentionally and might lead to unexpected behavior:

param = 5

class Foo:
    def __init__(self, param):   # "param" would be flagged here
        self.param = param
Loading history...
Comprehensibility Bug introduced by
parameters is re-defining a name which is already available in the outer-scope (previously defined on line 265).

It is generally a bad practice to shadow variables from the outer-scope. In most cases, this is done unintentionally and might lead to unexpected behavior:

param = 5

class Foo:
    def __init__(self, param):   # "param" would be flagged here
        self.param = param
Loading history...
113
        """
114
        :param pack: Name of the pack this action belongs to.
115
        :type pack: ``str``
116
117
        :param file_path: Path to the action module.
118
        :type file_path: ``str``
119
120
        :param parameters: action parameters.
121
        :type parameters: ``dict`` or ``None``
122
123
        :param user: Name of the user who triggered this action execution.
124
        :type user: ``str``
125
126
        :param parent_args: Command line arguments passed to the parent process.
127
        :type parse_args: ``list``
128
        """
129
130
        self._pack = pack
131
        self._file_path = file_path
132
        self._parameters = parameters or {}
133
        self._user = user
134
        self._parent_args = parent_args or []
135
136
        self._class_name = None
137
        self._logger = logging.getLogger('PythonActionWrapper')
138
139
        try:
140
            config.parse_args(args=self._parent_args)
141
        except Exception as e:
142
            LOG.debug('Failed to parse config using parent args (parent_args=%s): %s' %
0 ignored issues
show
Coding Style Best Practice introduced by
Specify string format arguments as logging function parameters
Loading history...
143
                      (str(self._parent_args), str(e)))
144
145
        # We don't need to ensure indexes every subprocess because they should already be created
146
        # and ensured by other services
147
        db_setup(ensure_indexes=False)
148
149
        # Note: We can only set a default user value if one is not provided after parsing the
150
        # config
151
        if not self._user:
152
            self._user = cfg.CONF.system_user.user
153
154
    def run(self):
155
        action = self._get_action_instance()
156
        output = action.run(**self._parameters)
157
158
        if isinstance(output, tuple) and len(output) == 2:
159
            # run() method returned status and data - (status, data)
160
            action_status = output[0]
161
            action_result = output[1]
162
        else:
163
            # run() method returned only data, no status (pre StackStorm v1.6)
164
            action_status = None
165
            action_result = output
166
167
        action_output = {
168
            'result': action_result,
169
            'status': None
170
        }
171
172
        if action_status is not None and not isinstance(action_status, bool):
173
            sys.stderr.write('Status returned from the action run() method must either be '
174
                             'True or False, got: %s\n' % (action_status))
175
            sys.stderr.write(INVALID_STATUS_ERROR_MESSAGE)
176
            sys.exit(PYTHON_RUNNER_INVALID_ACTION_STATUS_EXIT_CODE)
177
178
        if action_status is not None and isinstance(action_status, bool):
179
            action_output['status'] = action_status
180
181
            # Special case if result object is not JSON serializable - aka user wanted to return a
182
            # non-simple type (e.g. class instance or other non-JSON serializable type)
183
            try:
184
                json.dumps(action_output['result'])
185
            except TypeError:
186
                action_output['result'] = str(action_output['result'])
187
188
        try:
189
            print_output = json.dumps(action_output)
190
        except Exception:
191
            print_output = str(action_output)
192
193
        # Print output to stdout so the parent can capture it
194
        sys.stdout.write(ACTION_OUTPUT_RESULT_DELIMITER)
195
        sys.stdout.write(print_output + '\n')
196
        sys.stdout.write(ACTION_OUTPUT_RESULT_DELIMITER)
197
        sys.stdout.flush()
198
199
    def _get_action_instance(self):
200
        try:
201
            actions_cls = action_loader.register_plugin(legacy.Action, self._file_path)
202
        except Exception as e:
203
            tb_msg = traceback.format_exc()
204
            msg = ('Failed to load action class from file "%s" (action file most likely doesn\'t '
205
                   'exist or contains invalid syntax): %s' % (self._file_path, str(e)))
206
            msg += '\n\n' + tb_msg
207
            exc_cls = type(e)
208
            raise exc_cls(msg)
209
210
        action_cls = actions_cls[0] if actions_cls and len(actions_cls) > 0 else None
211
212
        if not action_cls:
213
            raise Exception('File "%s" has no action class or the file doesn\'t exist.' %
214
                            (self._file_path))
215
216
        self._class_name = action_cls.__class__.__name__
217
218
        config_loader = ContentPackConfigLoader(pack_name=self._pack, user=self._user)
219
        config = config_loader.get_config()
0 ignored issues
show
Comprehensibility Bug introduced by
config is re-defining a name which is already available in the outer-scope (previously defined on line 38).

It is generally a bad practice to shadow variables from the outer-scope. In most cases, this is done unintentionally and might lead to unexpected behavior:

param = 5

class Foo:
    def __init__(self, param):   # "param" would be flagged here
        self.param = param
Loading history...
220
221
        if config:
222
            LOG.info('Found config for action "%s"' % (self._file_path))
0 ignored issues
show
Coding Style Best Practice introduced by
Specify string format arguments as logging function parameters
Loading history...
223
        else:
224
            LOG.info('No config found for action "%s"' % (self._file_path))
0 ignored issues
show
Coding Style Best Practice introduced by
Specify string format arguments as logging function parameters
Loading history...
225
            config = None
226
227
        action_service = ActionService(action_wrapper=self)
228
229
        # The base python action class st2common.runners.base_action.Action
230
        # will be deprecated in future releases. Python action based on this
231
        # class is supported here for backward compatibility.
232
        if issubclass(action_cls, legacy.Action):
233
            action_instance = get_action_class_instance(
234
                action_cls=action_cls,
235
                config=config,
236
                action_service=action_service
237
            )
238
        elif issubclass(action_cls, forge.Action):
239
            action_instance = get_action_class_instance(
240
                action_cls=action_cls,
241
                config=config,
242
                action_service=action_service,
243
                logger=get_logger_for_python_runner_action(action_name=action_cls.__name__)
244
            )
245
        else:
246
            raise TypeError('Python action is not a subclass of supported types.')
247
248
        return action_instance
249
250
251
if __name__ == '__main__':
252
    parser = argparse.ArgumentParser(description='Python action runner process wrapper')
253
    parser.add_argument('--pack', required=True,
254
                        help='Name of the pack this action belongs to')
255
    parser.add_argument('--file-path', required=True,
256
                        help='Path to the action module')
257
    parser.add_argument('--parameters', required=False,
258
                        help='Serialized action parameters')
259
    parser.add_argument('--user', required=False,
260
                        help='User who triggered the action execution')
261
    parser.add_argument('--parent-args', required=False,
262
                        help='Command line arguments passed to the parent process')
263
    args = parser.parse_args()
264
265
    parameters = args.parameters
266
    parameters = json.loads(parameters) if parameters else {}
267
    user = args.user
268
    parent_args = json.loads(args.parent_args) if args.parent_args else []
269
270
    assert isinstance(parent_args, list)
271
    obj = PythonActionWrapper(pack=args.pack,
272
                              file_path=args.file_path,
273
                              parameters=parameters,
274
                              user=user,
275
                              parent_args=parent_args)
276
277
    obj.run()
278