~verterok/ubuntuone-client/fix-776386

« back to all changes in this revision

Viewing changes to canonical/ubuntuone/storage/syncdaemon/fsm/fsm.py

  • Committer: Rodney Dawes
  • Date: 2009-05-12 13:36:05 UTC
  • Revision ID: rodney.dawes@canonical.com-20090512133605-6aqs6e8xnnmp5u1p
        Import the code
        Hook up lint/trial tests in setup.py
        Use icontool now instead of including the render script
        Add missing python-gnome2-desktop to package dependencies
        Update debian/rules to fix the icon cache issue

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# canonical.ubuntuone.storage.syncdaemon.fsm.fsm - a fsm
 
2
#
 
3
# Author: Lucio Torre <lucio.torre@canonical.com>
 
4
#
 
5
# Copyright 2009 Canonical Ltd.
 
6
#
 
7
# This program is free software: you can redistribute it and/or modify it
 
8
# under the terms of the GNU General Public License version 3, as published
 
9
# by the Free Software Foundation.
 
10
#
 
11
# This program is distributed in the hope that it will be useful, but
 
12
# WITHOUT ANY WARRANTY; without even the implied warranties of
 
13
# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
 
14
# PURPOSE.  See the GNU General Public License for more details.
 
15
#
 
16
# You should have received a copy of the GNU General Public License along
 
17
# with this program.  If not, see <http://www.gnu.org/licenses/>.
 
18
"""
 
19
Will read the output produced by fsm_parser.parse or a .py serialization of
 
20
it and create and validate a state machine.
 
21
"""
 
22
import itertools
 
23
 
 
24
from canonical.ubuntuone.storage.syncdaemon import logger
 
25
 
 
26
try:
 
27
    product = itertools.product
 
28
except AttributeError:
 
29
    # taken from python docs for 2.6
 
30
    def product(*args, **kwds):
 
31
        "cartesian product"
 
32
        # product('ABCD', 'xy') --> Ax Ay Bx By Cx Cy Dx Dy
 
33
        # product(range(2), repeat=3) --> 000 001 010 011 100 101 110 111
 
34
        pools = map(tuple, args) * kwds.get('repeat', 1)
 
35
        result = [[]]
 
36
        for pool in pools:
 
37
            result = [x+[y] for x in result for y in pool]
 
38
        for prod in result:
 
39
            yield tuple(prod)
 
40
 
 
41
 
 
42
def hash_dict(d):
 
43
    "return a hashable representation of the dict"
 
44
    return tuple(sorted(d.items()))
 
45
 
 
46
class ValidationFailed(Exception):
 
47
    """signals that the specification is not correct"""
 
48
 
 
49
class ValidationError(object):
 
50
    """Contains validation errors"""
 
51
 
 
52
    def __init__(self, description):
 
53
        "create a validation error with description"
 
54
        self.description = description
 
55
 
 
56
    def __str__(self):
 
57
        "__str__"
 
58
        return "Validation Error: %s" % self.description
 
59
 
 
60
def build_combinations_from_varlist(varlist):
 
61
    """ create all posible variable values combinations
 
62
 
 
63
    takes a dict in the form {varname: [value, value2, *]}
 
64
    returns [{varname:value}, {varname:value2}, ...]
 
65
    """
 
66
    items = varlist.items()
 
67
    keys = [ x[0] for x in items ]
 
68
    # pylint: disable-msg=W0631
 
69
    values = [ x[1] for x in items ]
 
70
 
 
71
    possible_states = [dict(zip(keys, state))
 
72
                       for state in product(*values) ]
 
73
    return possible_states
 
74
 
 
75
def expand_var_list(varlist, values):
 
76
    """ exapand a state description
 
77
 
 
78
    takes a {varname:value} dict and returns a list of {varname:value} but with
 
79
    stars and bangs replaced for all its possible values
 
80
    """
 
81
    myvalues = values.copy()
 
82
    for name in myvalues:
 
83
        # star may be unicode
 
84
        if str(myvalues[name]) == "*":
 
85
            myvalues[name] = varlist[name]
 
86
        elif str(myvalues[name])[0] == "!":
 
87
            l = varlist[name].copy()
 
88
            l.remove(myvalues[name][1:])
 
89
            myvalues[name] = l
 
90
        else:
 
91
            myvalues[name] = [myvalues[name]]
 
92
    return build_combinations_from_varlist(myvalues)
 
93
 
 
94
class StateMachineRunner(object):
 
95
    """Reads a StateMachine descriptions and executes transitions."""
 
96
 
 
97
    def __init__(self, fsm, log=None):
 
98
        """Create a state machine based on fsm."""
 
99
        self.fsm = fsm
 
100
        if log is None:
 
101
            self.log = logger.root_logger
 
102
        else:
 
103
            self.log = log
 
104
 
 
105
    def on_event(self, event_name, parameters, *args):
 
106
        """Do the transition for this event."""
 
107
        # get the state
 
108
        self.log.debug("EVENT: %s:%s with ARGS:%s"%(
 
109
            event_name, parameters, args))
 
110
        try:
 
111
            enter_state = self.get_state()
 
112
        except KeyError, e:
 
113
            self.log.error("cant find current state: %s" % (
 
114
                self.get_state_values()))
 
115
            raise KeyError("Incorrect In State")
 
116
 
 
117
        # find the transition
 
118
        try:
 
119
            transition = enter_state.get_transition(event_name, parameters)
 
120
        except KeyError:
 
121
            self.log.error("Cant find transition %s:%s" %
 
122
                           (event_name, parameters))
 
123
            return
 
124
        action_func_name = transition.action_func
 
125
        # call the action_func
 
126
        af = getattr(self, action_func_name, None)
 
127
        if af is None:
 
128
            self.log.error("cant find ACTION_FUNC: %s" % (action_func_name))
 
129
        elif af == "pass":
 
130
            self.log.debug("passing")
 
131
        else:
 
132
            # pylint: disable-msg=W0703
 
133
            self.log.debug("Calling %s"%action_func_name)
 
134
            try:
 
135
                af(event_name, parameters, *args)
 
136
            except Exception, e:
 
137
                self.log.exception("Executing ACTION_FUNC '%s' "
 
138
                                   "gave an exception" %
 
139
                                   (action_func_name))
 
140
                self.on_error(event_name, parameters)
 
141
                return
 
142
        # validate the end state
 
143
        try:
 
144
            out_state = self.get_state()
 
145
        except KeyError:
 
146
            self.log.error("from state %s on %s:%s, "
 
147
                "cant find current out state: %s" % (
 
148
                    enter_state.values, event_name, parameters,
 
149
                    self.get_state_values()))
 
150
            self.on_error(event_name, parameters)
 
151
            raise KeyError("unknown out state")
 
152
 
 
153
        if out_state.values != transition.target:
 
154
            self.log.error(
 
155
                "in state %s with event %s:%s, out state is:"
 
156
                "%s and should be %s" % (
 
157
                enter_state.values, event_name, parameters,
 
158
                out_state.values, transition.target))
 
159
            raise ValueError("Incorrect out state")
 
160
        self.log.debug("Called %s"%action_func_name)
 
161
 
 
162
    def get_state(self):
 
163
        """Get the current state's object"""
 
164
        return self.fsm.get_state(self.get_state_values())
 
165
 
 
166
    def get_state_values(self):
 
167
        """Get the state variables values for this state.
 
168
 
 
169
        This has to be overridden on implementations of this class.
 
170
        """
 
171
        raise NotImplementedError()
 
172
 
 
173
    def on_error(self, event_name, parameters):
 
174
        """A Transition encontered an error. Cleanup.
 
175
        """
 
176
 
 
177
 
 
178
class StateMachine(object):
 
179
    """The state machine"""
 
180
 
 
181
    def __init__(self, input_data, event_filter=None):
 
182
        """create a fsm from filename.
 
183
 
 
184
        filename can be an .ods file or a dictionary
 
185
        event_filter, if not None, limits the events you want to parse.
 
186
        """
 
187
        self.errors = []
 
188
        self.event_filter = event_filter
 
189
        if isinstance(input_data, str):
 
190
            if input_data.endswith(".ods"):
 
191
                # fsm_parser depends on python-uno for reading ods documents
 
192
                # this shouldnt be called with an .ods file on production
 
193
                # environments
 
194
                from canonical.ubuntuone.storage.syncdaemon.fsm import \
 
195
                    fsm_parser
 
196
                spec = fsm_parser.parse(input_data)
 
197
            elif input_data.endswith(".py"):
 
198
                result = {}
 
199
                # pylint doesnt like exec
 
200
                # pylint: disable-msg=W0122
 
201
                exec open(input_data) in result
 
202
                spec = result["state_machine"]
 
203
            else:
 
204
                raise ValueError("Unknown input format")
 
205
        else:
 
206
            spec = input_data
 
207
        self.spec = spec
 
208
        self.events = {}
 
209
        self.states = {}
 
210
        self.state_vars = {}
 
211
        self.param_vars = {}
 
212
        self.build()
 
213
 
 
214
    def validate(self):
 
215
        """Raises an exception if the file had errors."""
 
216
        if self.errors:
 
217
            raise ValidationFailed("There are %s validation errors"%
 
218
                                   len(self.errors))
 
219
        return True
 
220
 
 
221
    def get_variable_values(self, kind, name):
 
222
        """Returns all the values a variable of kind in
 
223
        [STATE, PARAMETERS, STATE_OUT] with name name can take.
 
224
        """
 
225
        vals = set()
 
226
        for event in self.spec["events"].values():
 
227
            for state in event:
 
228
                try:
 
229
                    value = state[kind][name]
 
230
                except KeyError:
 
231
                    self.errors.append(ValidationError(
 
232
                        "variable name '%s' not found in section %s"%(
 
233
                           name, kind)))
 
234
                else:
 
235
                    if str(value).strip() == "=" and kind != "STATE_OUT":
 
236
                        self.errors.append(ValidationError(
 
237
                            "Cant have '=' in STATE or PARAMETERS section"
 
238
                        ))
 
239
                    if not str(value).strip() in ("*", "="):
 
240
                        if not str(value).strip()[0] == "!":
 
241
                            vals.add(value)
 
242
        return vals
 
243
 
 
244
 
 
245
    def build(self):
 
246
        """Do all the parsing and validating."""
 
247
        # build state variable posible values
 
248
        state_vars = {}
 
249
        for state_var in self.spec["state_vars"]:
 
250
            values = self.get_variable_values("STATE", state_var)
 
251
            values.update(self.get_variable_values("STATE_OUT", state_var))
 
252
            state_vars[state_var] = values
 
253
 
 
254
        self.state_vars = state_vars
 
255
 
 
256
        # build message parameter posible values
 
257
        parameters = {}
 
258
        for state_var in self.spec["parameters"]:
 
259
            values = self.get_variable_values("PARAMETERS", state_var)
 
260
            parameters[state_var] = values
 
261
 
 
262
        self.param_vars = parameters
 
263
 
 
264
        # build posible states
 
265
        possible_states = build_combinations_from_varlist(self.state_vars)
 
266
        # remove invalid
 
267
        for s in self.spec["invalid"]:
 
268
            for es in expand_var_list(self.state_vars, s):
 
269
                try:
 
270
                    possible_states.remove(es)
 
271
                except ValueError:
 
272
                    self.errors.append(
 
273
                        ValidationError("State %s already removed from invalid"%
 
274
                                        es)
 
275
                    )
 
276
 
 
277
        for stateval in possible_states:
 
278
            self.states[hash_dict(stateval)] = State(stateval)
 
279
 
 
280
        # build transitions
 
281
        for event_name, lines in self.spec["events"].items():
 
282
            if self.event_filter and not event_name in self.event_filter:
 
283
                continue
 
284
            event = Event(event_name, lines, self)
 
285
            self.events[event_name] = event
 
286
            tracker = event.get_tracker()
 
287
            for transition in event.transitions:
 
288
                # for each transition
 
289
                try:
 
290
                    state = self.states[hash_dict(transition.source)]
 
291
                except KeyError:
 
292
                    continue
 
293
                    # pylint: disable-msg=W0101
 
294
                    # we dont error, so * that cover invalid states still work
 
295
                    # XXX: lucio.torre:
 
296
                    # we should check that if the transition
 
297
                    # is not expanded or all the states it covers are
 
298
                    # invalid, because this is an error
 
299
                    self.errors.append(
 
300
                        ValidationError("Transitiont on %s with %s from '%s'"
 
301
                                          "cant find source state."%(
 
302
                                            transition.event,
 
303
                                            transition.parameters,
 
304
                                            transition.source
 
305
                                            )))
 
306
                    continue
 
307
                s = {}
 
308
                s.update(transition.source)
 
309
                s.update(transition.parameters)
 
310
                try:
 
311
                    tracker.remove(s)
 
312
                except ValueError:
 
313
                    self.errors.append(ValidationError(
 
314
                        "For event %s, the following transition was "
 
315
                                          "already covered: %s"%(
 
316
                                            event, transition)))
 
317
                else:
 
318
                    state.add_transition(transition)
 
319
            if tracker.empty():
 
320
                for s in tracker.pending:
 
321
                    self.errors.append(ValidationError(
 
322
                        "The following state x parameters where "
 
323
                                      "not covered for '%s': %s"%(
 
324
                                        event, s)))
 
325
 
 
326
    def get_state(self, vars_dict):
 
327
        """Get a state instance from a dict with {varname:value}"""
 
328
        return self.states[hash_dict(vars_dict)]
 
329
 
 
330
class Tracker(object):
 
331
    """Tracks a list of state_x_params combinations.
 
332
 
 
333
    Does the same that a list does, but its more explicit. it used to do more.
 
334
    """
 
335
    def __init__(self, state_x_params):
 
336
        """Create a tracker."""
 
337
        self.pending = state_x_params[:]
 
338
 
 
339
    def remove(self, case):
 
340
        """Remove a case."""
 
341
        self.pending.remove(case)
 
342
 
 
343
    def empty(self):
 
344
        """Check for pending cases."""
 
345
        return bool(self.pending)
 
346
 
 
347
class Event(object):
 
348
    """Represents events that may happen.
 
349
 
 
350
    Interesting properties:
 
351
    name: the name of the event
 
352
    state_vars: {varname:[value, value2, ...]} for state
 
353
    param_vars: {varname:[value, value2, ...]} for params
 
354
    transitions: all the transitions that this event produces
 
355
    draw_transitions: the transitions, but not expanded. for drawing.
 
356
    state_x_params: all the posible state_x_params this event may encounter
 
357
    """
 
358
    def __init__(self, name, lines, machine):
 
359
        state_vars = machine.state_vars
 
360
        param_vars = machine.param_vars
 
361
        self.invalid_states = machine.spec["invalid"]
 
362
        self.name = name
 
363
        self.state_vars = state_vars.copy()
 
364
        self.event_vars = param_vars.copy()
 
365
        # create transitions expanding *'s
 
366
        self.transitions = []
 
367
        # we have to remove parameters that have NA on all the rows
 
368
        invalid = set(param_vars.keys())
 
369
        # clean invalid list
 
370
        for line in lines:
 
371
            for k, v in line["PARAMETERS"].items():
 
372
                if str(v).strip() != "NA":
 
373
                    # this parameter has a value, remove from invalid list
 
374
                    if k in invalid:
 
375
                        invalid.remove(k)
 
376
 
 
377
        #remove invalids from lines
 
378
        for line in lines:
 
379
            for inv in invalid:
 
380
                if inv in line["PARAMETERS"]:
 
381
                    del line["PARAMETERS"][inv]
 
382
 
 
383
        # remove invalid from param_vars
 
384
        for inv in invalid:
 
385
            del self.event_vars[inv]
 
386
 
 
387
        # make list of state_x_parameters to cover
 
388
        vlist = {}
 
389
        vlist.update(self.state_vars)
 
390
        vlist.update(self.event_vars)
 
391
        self.state_x_params = build_combinations_from_varlist(vlist)
 
392
        # now we remove the lines that have been defines as invalid
 
393
        toremove = []
 
394
        for i in self.invalid_states:
 
395
            for ei in expand_var_list(state_vars, i):
 
396
                for sxp in self.state_x_params:
 
397
                    for k, v in ei.items():
 
398
                        if sxp[k] != v:
 
399
                            break
 
400
                    else:
 
401
                        if not sxp in toremove:
 
402
                            toremove.append(sxp)
 
403
 
 
404
        map(self.state_x_params.remove, toremove)
 
405
 
 
406
        # create transitions by expanding states
 
407
        for line in lines:
 
408
            state_exp = expand_var_list(state_vars, line["STATE"])
 
409
            param_exp = expand_var_list(param_vars, line["PARAMETERS"])
 
410
            for se in state_exp:
 
411
                for pe in param_exp:
 
412
                    new_line = line.copy()
 
413
                    # copy source state if dest state is '='
 
414
                    so = new_line["STATE_OUT"].copy()
 
415
                    for k in so:
 
416
                        if str(so[k]).strip() == "=":
 
417
                            so[k] = se[k]
 
418
                    new_line["STATE"] = se
 
419
                    new_line["PARAMETERS"] = pe
 
420
                    new_line["STATE_OUT"] = so
 
421
 
 
422
                    # here we have the expanded lines, remove from
 
423
                    # states_x_params the lines with action NA
 
424
                    if str(new_line["ACTION"]).strip() == "NA":
 
425
                        s_x_p = {}
 
426
                        s_x_p.update(new_line["STATE"])
 
427
                        s_x_p.update(new_line["PARAMETERS"])
 
428
                        if s_x_p in self.state_x_params:
 
429
                            self.state_x_params.remove(s_x_p)
 
430
                    else:
 
431
                        self.transitions.append(Transition(name, new_line))
 
432
 
 
433
        # create transitions by expanding states, but dont expand params
 
434
        # so we can use this transitions to draw them
 
435
        self.draw_transitions = []
 
436
        for line in lines:
 
437
            state_exp = expand_var_list(state_vars, line["STATE"])
 
438
            pe = line["PARAMETERS"]
 
439
            for se in state_exp:
 
440
                new_line = line.copy()
 
441
                # copy source state if dest state is '='
 
442
                so = new_line["STATE_OUT"].copy()
 
443
                for k in so:
 
444
                    if str(so[k]).strip() == "=":
 
445
                        so[k] = se[k]
 
446
                new_line["STATE"] = se
 
447
                new_line["PARAMETERS"] = pe
 
448
                new_line["STATE_OUT"] = so
 
449
 
 
450
                # here we have the expanded lines, remove from
 
451
                # states_x_params the lines with action NA
 
452
                if not str(new_line["ACTION"]).strip() == "NA":
 
453
                    self.draw_transitions.append(Transition(name, new_line))
 
454
 
 
455
 
 
456
    def __str__(self):
 
457
        """__str___"""
 
458
        return "<Event: %s>" % self.name
 
459
 
 
460
    def get_tracker(self):
 
461
        """Get a tracker for this state."""
 
462
        return Tracker(self.state_x_params)
 
463
 
 
464
 
 
465
class Transition(object):
 
466
    """A transition.
 
467
 
 
468
    For each expansion of a transition line in the original spreadsheet we
 
469
    get one of these. with the corresponding attributes for all sections
 
470
    and event name.
 
471
    """
 
472
    def __init__(self, event, line):
 
473
        """Create a transition for event event from line.
 
474
 
 
475
        line may be an expanded version of a source line.
 
476
        """
 
477
        self.event = event
 
478
        self.line = line
 
479
        self.source = line["STATE"]
 
480
        self.target = line["STATE_OUT"]
 
481
        self.parameters = line["PARAMETERS"]
 
482
        self.action_func = line["ACTION_FUNC"]
 
483
 
 
484
    def __str__(self):
 
485
        """___str___"""
 
486
        return "<Transition: %s: %s x %s>" % (
 
487
                self.event, self.source, self.parameters)
 
488
 
 
489
 
 
490
class State(object):
 
491
    """A State object.
 
492
 
 
493
    Represents a combination of state variable values.
 
494
    values: the state values
 
495
    transitions: the transitions that leave from this state
 
496
    """
 
497
 
 
498
    def __init__(self, values):
 
499
        """Create a state."""
 
500
        self.values = values
 
501
        self.transitions = {}
 
502
 
 
503
    def add_transition(self, transition):
 
504
        """Add a transition."""
 
505
        self.transitions[transition.event,
 
506
                         hash_dict(transition.parameters)] = transition
 
507
 
 
508
    def get_transition(self, event, parameters):
 
509
        """Get the transition for this events with these parameters."""
 
510
        return self.transitions[event, hash_dict(parameters)]
 
511
 
 
512
if __name__ == "__main__":
 
513
    import sys
 
514
    s = StateMachine(sys.argv[1], sys.argv[2:])
 
515
    if s.errors:
 
516
        for e in s.errors:
 
517
            print >> sys.stderr, e
 
518
        print "There are %s errors" % (len(s.errors))
 
519
        exit(1)
 
520
    else:
 
521
        print "validated ok."