The events have the following semantics.
We can use this to determine if we're starting too many things and finishing
too few things. We can help re-oriented a team to stop starting and start finishing.
The objective is to classify Action details into Event categories.
Therefore, we must "classify" or "match" a number of similar Action instances.
A rule will map similar instances to a single kind of Event.
The classification is clearly a function of the input Action.
Less clearly, a classification is also a function of configuration details. We'd
like to avoid encoding Trello action strings into the rules if we can.
More fundamentally, we cannot encode the list names into the rules.
The list names must be run-time, dynamic values.
Further -- to make an attempt at being DRY -- we have two uses for the
Trello action strings.
- Transformation Action to Event class.
- Querying Trello for the Actions on a board.
These considerations (configuration and DRY) lead us to use
partial functions to define the rule. We can distinguish
between arguments used to configure the rule and arguments
that are applied as part of the final decision-making by the rule.
And we can extract the action strings from the rules to use for querying.
The rules are defined like this:
[(MATCH_RULE, (args, ...), event), ... ]
We provide a triple with the function, the configuration arguments,
and the resulting event.
The partial functions are used like this:
MATCH_RULE(*args)(action)
The first wave of argument processing, using MATCH_RULE(*args), creates a function that
makes the final match decision.
This first-wave function is subsequently applied to the action argument
for filtering or mapping to an event type.
Some of the rule types require the run-time input of the specific
lists which count as finished. We do this last-minute binding in a function
that emits a list of rules, some of which have the list names injected into them.
Match_Args = Union[Tuple[str], Tuple[str, List[str]]]
Filter_Action = Callable[[Action], bool]
Match_Rule = Union[
Callable[[str], Filter_Action],
Callable[[str, List[str]], Filter_Action],
]
- While this would be pleasant, it doesn't work:
- Match_Rule = Callable[[*Match_Args], Filter_Action]
Classifier_Rule_List = List[Tuple[Match_Rule, Match_Args, Event]]
This was once part of the code, but appears not used... Match_Action = Callable[[Action], Event].
There are several rule types:
- Matches if the text of the action type matches the Action.action field.
MATCH_ACTION_TYPE: Match_Rule = lambda type_text: lambda action: action.action == type_text
- Matches if the Action.list field is in the target lists.
MATCH_IN_LIST: Match_Rule = lambda list_names: lambda action: action.list in list_names
- Matches if the Action.list field is not in the target lists.
MATCH_NOT_LIST: Match_Rule = lambda list_names: lambda action: action.list not in list_names
- Matches if the Action.action text and the Action.list is in the target lists.
MATCH_ACTION_TYPE_IN_LIST: Match_Rule = (lambda type_text, list_names:
lambda action: action.action == parse_action(type_text) and action.list in list_names
)
- Matches if the Action.action text and the Action.list is not in the target lists.
MATCH_ACTION_TYPE_NOT_LIST: Match_Rule = (lambda type_text, list_names:
lambda action: action.action == parse_action(type_text) and action.list not in list_names
)
def build_action_event_rules(finished_lists: list[str]) -> Classifier_Rule_List:
"""
Build the Action->Event mapping rules. This requires injecting
the finished list into the rules.
:param finished_lists: The names of lists that indicate done-ness
:returns: sequence of three-tuples with rule function, configuration args, and final event.
"""
return [
(MATCH_ACTION_TYPE, ('copyCard',), Event.create),
(MATCH_ACTION_TYPE, ('createCard',), Event.create),
(MATCH_ACTION_TYPE, ('moveCardToBoard',), Event.create),
(MATCH_ACTION_TYPE, ('convertToCardFromCheckItem',), Event.create),
(MATCH_ACTION_TYPE, ('deleteCard',), Event.remove),
(MATCH_ACTION_TYPE, ('moveCardFromBoard',), Event.remove),
(MATCH_ACTION_TYPE_IN_LIST, ('updateCard:closed', finished_lists), Event.finish),
(MATCH_ACTION_TYPE_IN_LIST, ('updateCard:idList', finished_lists), Event.finish),
(MATCH_ACTION_TYPE_NOT_LIST, ('updateCard:idList', finished_lists), Event.ignore),
]
Each rule is focused on a single kind of input action. This leads to a number
of rules of a common form. The rules are independent, and we can, add, change, or
delete freely.
An alternative design would focus the rules on the output event type.
We might have a mapping from event type to a list of conditions that indicate the
defined event. This is a kind of kind of conjunctive normal form.
We might say Event.create if any(RULE(action) for rule in create_rules) else None.
This is a simple optimization. It doesn't have any material performance impact.
And it combines details into a larger structure, imposing some minor dependencies.
Also, it makes it difficult to get a simple list of actions to support a Trello
action query.
Here's how a collection of rules works.
>>> from action_counts import *
>>> finished_lists = ['Some List']
>>> EVENT_RULES = build_action_event_rules(finished_lists)
>>> action = Action('date', 'copyCard', 'card', 'Some List', None)
>>> list(filter(None, (rule_type(*args)(action) and event for rule_type, args, event in EVENT_RULES)))
[<Event.create: 2>]
>>> action = Action('date', 'updateCard', 'card', 'Some List', None)
>>> list(filter(None, (rule_type(*args)(action) and event for rule_type, args, event in EVENT_RULES)))
[<Event.finish: 4>, <Event.finish: 4>]
Given an Action instance, all of the rules in EVENT_RULES are applied.
First, they're applied to the fixed configuration arguments to create a decision function.
Then the resulting decision function is applied to the Action instance.
If the match result is True, we can use and event to return the Event type.
If the match result is False, that's the overall result.
Using filter(None, iterable) discards all "falsy" values, leaving the Event type.
Generally, there's only one match. In some cases, there is more than one because
our rule doesn't distinguish between moving and closing a card.
Note that each rule is completely independent of all other rules. A change
to one does not break another. There's no ripple effect. There are no
stateful variables.
We also need to exclude certain lists from analysis. This is a filter that's
applied early in the process to limit the number of Action instances that
are considered.
We can think about this as rejecting certain lists.
Or we can think about passing all lists which are not those reject lists.
We'll use type hints similar to the Match_Rule hints. These are somewhat
simpler in that we don't have a query string, merely a list of list names.
So far, there's only one rule. The generalization of this one rule
seems like quite a bit of overhead. It allows flexibility, and it
reveals some paralellisms between filtering and transforming Action instances.
Pass_Args = Tuple[List[str]]
Pass_Rule = Callable[[List[str]], Filter_Action]
Pass_Rule_List = List[Tuple[Pass_Rule, Pass_Args]]
def build_pass_rules(reject_lists: list[str]) -> Pass_Rule_List:
"""
Build the Action rejection rules. This requires injecting
the reject list into each rule to create a partial function.
The function can then be applied to the ``Action``.
The idea is that **all** rules must return True to process the row
further. Any False is rejection.
:param reject_lists: The names of lists to ignore
:returns: sequence of two-tuples with rule function, configuration args.
"""
return [
(cast(Pass_Rule, MATCH_NOT_LIST), (reject_lists,)),
]
We might have several criteria required for passing.
Currently, there's only a single rule. Since we've defined this as a list,
we can add rules easily.
>>> from action_counts import *
>>> reject_lists = ['Reject This List']
>>> PASS_RULES = build_pass_rules(reject_lists)
>>> action1 = Action('date', 'action', 'card', 'Reject This List', None)
>>> all(rule_type(*args)(action1) for rule_type, args in PASS_RULES)
False
>>> action2 = Action('date', 'action', 'card', 'Another List', None)
>>> all(rule_type(*args)(action2) for rule_type, args in PASS_RULES)
True
>>> raw_actions = [action1, action2]
>>> reject = lambda action: all(rule_type(*args)(action) for rule_type, args in PASS_RULES)
>>> passed_actions = filter(reject, raw_actions)
>>> list(passed_actions)
[Action(date='date', action='action', card='card', list='Another List', raw=None)]
The objective is to summarize Action details into Event categories.
We've combined the filter and the categorization into a single function.
The function is effectively this:
map(classifier, filter(reject, action_iter))
This will iterate over a source Action instances.
It will pass only those actions not on a reject list.
It will map all of the classifier rules to each action and pick the first non-false result.
Date = NewType('Date', datetime.date)
def action_event_iter(pass_rules: Pass_Rule_List,
classifier_rules: Classifier_Rule_List,
action_iter: Iterator[Action]) -> Iterator[Tuple[Date, Event, Action]]:
"""
Classify actions into event type categories.
:param pass_rules: Rules required to pass an action forward for processing
:param action_event_rules: Rules that identify an event summary for an action
:param action_iter: An iterator over the source actions.
:returns: Iterator over (date, event type, action) triples.
"""
# Remove the cards on any of the reject lists.
rule_partials = (rule_type(*args) for rule_type, args in pass_rules)
pass_filter: Callable[[Action], bool] = lambda action: all(rule(action) for rule in rule_partials)
# Create a list of (date, event) pairs for each rule that matches.
# Ideally, there's exactly one item in the list, and we take that one item.
# Since 0 or many matches are problems, a variation on :func:`first` might be appropriate.
classify = (lambda action:
first(
filter(None,
((action.date, event_classifier, action) if rule_type(*args)(action) else None
for rule_type, args, event_classifier in classifier_rules)
)
)
)
return map(classify, filter(pass_filter, action_iter))
The combining of filter and map represents an optimization that might be a bad idea.
Each operation is independent.
It seems, however, that there's some value in combining the two operations
because they're both essential to the event classification process.