Skip to content

Commit

Permalink
Merge pull request #26 from thread/history-in-context
Browse files Browse the repository at this point in the history
History in context
  • Loading branch information
danpalmer committed Jan 16, 2018
2 parents d8e449b + a702980 commit aa13991
Show file tree
Hide file tree
Showing 14 changed files with 210 additions and 131 deletions.
4 changes: 2 additions & 2 deletions routemaster/config/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"""Loading of application configuration."""

from routemaster.config.model import (
Feed,
Gate,
State,
Action,
Config,
Trigger,
Webhook,
FeedConfig,
NextStates,
TimeTrigger,
NoNextStates,
Expand All @@ -26,13 +26,13 @@
__all__ = (
'load_config',
'load_database_config',
'Feed',
'Gate',
'State',
'Action',
'Config',
'Trigger',
'Webhook',
'FeedConfig',
'NextStates',
'ConfigError',
'TimeTrigger',
Expand Down
11 changes: 6 additions & 5 deletions routemaster/config/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@
import jsonschema.exceptions

from routemaster.config.model import (
Feed,
Gate,
State,
Action,
Config,
Trigger,
Webhook,
FeedConfig,
NextStates,
TimeTrigger,
NoNextStates,
Expand Down Expand Up @@ -100,11 +100,12 @@ def _load_state_machine(
name: str,
yaml_state_machine: Yaml,
) -> StateMachine:
feeds = [_load_feed(x) for x in yaml_state_machine.get('feeds', [])]
feeds = [_load_feed_config(x) for x in yaml_state_machine.get('feeds', [])]

if len(set(x.name for x in feeds)) < len(feeds):
raise ConfigError(
f"Feeds must have unique names at {'.'.join(path + ['feeds'])}",
f"FeedConfigs must have unique names at "
f"{'.'.join(path + ['feeds'])}",
)

return StateMachine(
Expand All @@ -128,8 +129,8 @@ def _load_webhook(yaml: Yaml) -> Webhook:
)


def _load_feed(yaml: Yaml) -> Feed:
return Feed(name=yaml['name'], url=yaml['url'])
def _load_feed_config(yaml: Yaml) -> FeedConfig:
return FeedConfig(name=yaml['name'], url=yaml['url'])


def _load_state(path: Path, yaml_state: Yaml) -> State:
Expand Down
4 changes: 2 additions & 2 deletions routemaster/config/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ class Action(NamedTuple):
State = Union[Action, Gate]


class Feed(NamedTuple):
class FeedConfig(NamedTuple):
"""
The definition of a feed of dynamic data to be included in a context.
"""
Expand All @@ -166,7 +166,7 @@ class StateMachine(NamedTuple):
"""A state machine."""
name: str
states: List[State]
feeds: List[Feed]
feeds: List[FeedConfig]
webhooks: List[Webhook]

def get_state(self, state_name: str) -> State:
Expand Down
8 changes: 4 additions & 4 deletions routemaster/config/tests/test_loading.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
import pytest

from routemaster.config import (
Feed,
Gate,
Action,
Config,
Webhook,
FeedConfig,
ConfigError,
TimeTrigger,
NoNextStates,
Expand Down Expand Up @@ -77,7 +77,7 @@ def test_realistic_config():
'example': StateMachine(
name='example',
feeds=[
Feed(name='data_feed', url='http://localhost/<label>'),
FeedConfig(name='data_feed', url='http://localhost/<label>'),
],
webhooks=[
Webhook(
Expand Down Expand Up @@ -233,7 +233,7 @@ def test_environment_variables_override_config_file_for_database_config():
'example': StateMachine(
name='example',
feeds=[
Feed(name='data_feed', url='http://localhost/<label>'),
FeedConfig(name='data_feed', url='http://localhost/<label>'),
],
webhooks=[
Webhook(
Expand Down Expand Up @@ -317,5 +317,5 @@ def test_raises_for_unparseable_database_port_in_environment_variable():


def test_multiple_feeds_same_name_invalid():
with assert_config_error("Feeds must have unique names at state_machines.example.feeds"):
with assert_config_error("FeedConfigs must have unique names at state_machines.example.feeds"):
load_config(yaml_data('multiple_feeds_same_name_invalid'))
13 changes: 4 additions & 9 deletions routemaster/config/tests/test_next_states.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import datetime

import pytest
import dateutil

from routemaster.config import (
NoNextStates,
Expand All @@ -11,8 +8,6 @@
)
from routemaster.context import Context

UTC_NOW = datetime.datetime.now(dateutil.tz.tzutc())


def test_constant_next_state():
next_states = ConstantNextState(state='foo')
Expand All @@ -27,7 +22,7 @@ def test_no_next_states_must_not_be_called():
next_states.next_state_for_label(None)


def test_context_next_states():
def test_context_next_states(make_context):
next_states = ContextNextStates(
path='metadata.foo',
destinations=[
Expand All @@ -36,13 +31,13 @@ def test_context_next_states():
],
)

context = Context('label1', {'foo': True}, UTC_NOW, None, [])
context = make_context(label='label1', metadata={'foo': True})

assert next_states.all_destinations() == ['1', '2']
assert next_states.next_state_for_label(context) == '1'


def test_context_next_states_raises_for_no_valid_state():
def test_context_next_states_raises_for_no_valid_state(make_context):
next_states = ContextNextStates(
path='metadata.foo',
destinations=[
Expand All @@ -51,7 +46,7 @@ def test_context_next_states_raises_for_no_valid_state():
],
)

context = Context('label1', {'foo': 'bar'}, UTC_NOW, None, [])
context = make_context(label='label1', metadata={'foo': 'bar'})

with pytest.raises(RuntimeError):
next_states.next_state_for_label(context)
22 changes: 20 additions & 2 deletions routemaster/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,26 @@
import os
import re
import json
import datetime
import contextlib
from typing import Any, Dict

import mock
import pytest
import httpretty
import dateutil.tz
from sqlalchemy import and_, select, create_engine

from routemaster import state_machine
from routemaster.db import labels, history, metadata
from routemaster.app import App
from routemaster.utils import dict_merge
from routemaster.config import (
Feed,
Gate,
Action,
Config,
Webhook,
FeedConfig,
NoNextStates,
StateMachine,
DatabaseConfig,
Expand All @@ -31,6 +33,7 @@
ContextNextStatesOption,
)
from routemaster.server import server
from routemaster.context import Context
from routemaster.webhooks import WebhookResult
from routemaster.state_machine import LabelRef
from routemaster.exit_conditions import ExitConditionProgram
Expand All @@ -47,7 +50,7 @@
'test_machine': StateMachine(
name='test_machine',
feeds=[
Feed(name='tests', url='http://localhost/tests'),
FeedConfig(name='tests', url='http://localhost/tests'),
],
webhooks=[
Webhook(
Expand Down Expand Up @@ -395,3 +398,18 @@ def _inner(label, update):

return new_metadata
return _inner


@pytest.fixture()
def make_context():
"""Factory for Contexts that provides sane defaults for testing."""
def _inner(**kwargs):
return Context(
label=kwargs['label'],
metadata=kwargs.get('metadata', {}),
now=kwargs.get('now', datetime.datetime.now(dateutil.tz.tzutc())),
feeds=kwargs.get('feeds', {}),
accessed_variables=kwargs.get('accessed_variables', []),
current_history_entry=kwargs.get('current_history_entry'),
)
return _inner
16 changes: 15 additions & 1 deletion routemaster/context.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Context definition for exit condition programs."""
import datetime
from typing import Any, Dict, Iterable, Sequence
from typing import Any, Dict, Iterable, Optional, Sequence

from routemaster.feeds import Feed
from routemaster.utils import get_path
Expand All @@ -11,11 +11,13 @@ class Context(object):

def __init__(
self,
*,
label: str,
metadata: Dict[str, Any],
now: datetime.datetime,
feeds: Dict[str, Feed],
accessed_variables: Iterable[str],
current_history_entry: Optional[Any],
) -> None:
"""Create an execution context."""
if now.tzinfo is None:
Expand All @@ -26,6 +28,7 @@ def __init__(
self.now = now
self.metadata = metadata
self.feeds = feeds
self.current_history_entry = current_history_entry

self._pre_warm_feeds(label, accessed_variables)

Expand All @@ -37,6 +40,7 @@ def lookup(self, path: Sequence[str]) -> Any:
return {
'metadata': self._lookup_metadata,
'feeds': self._lookup_feed_data,
'history': self._lookup_history,
}[location](rest)
except (KeyError, ValueError):
return None
Expand All @@ -48,6 +52,16 @@ def _lookup_feed_data(self, path: Sequence[str]) -> Any:
feed_name, *rest = path
return self.feeds[feed_name].lookup(rest)

def _lookup_history(self, path: Sequence[str]) -> Any:
if self.current_history_entry is None:
raise ValueError("Accessed uninitialised variable")

variable_name, = path
return {
'entered_state': self.current_history_entry.created,
'previous_state': self.current_history_entry.old_state,
}[variable_name]

def property_handler(self, property_name, value, **kwargs):
"""Handle a property in execution."""
if property_name == ('passed',):
Expand Down
62 changes: 46 additions & 16 deletions routemaster/exit_conditions/tests/test_integration.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
import textwrap
from typing import Optional, NamedTuple

import pytest
import dateutil.tz
Expand All @@ -19,33 +20,62 @@
("not true", False, ()),
("3h has passed since metadata.old_time", True, ('metadata.old_time',)),
("not 4 >= 6", True, ()),
("3h has not passed since metadata.old_time", False, ('metadata.old_time',)),
(
"3h has not passed since metadata.old_time",
False,
('metadata.old_time',),
),
("metadata.foo is defined", True, ('metadata.foo',)),
("metadata.bar is defined", False, ('metadata.bar',)),
("null is not defined", True, ()),
("(1 < 2) and (2 < metadata.foo)", True, ('metadata.foo',)),
("3 is not in metadata.objects", True, ('metadata.objects',)),
(
"12h has passed since history.entered_state",
True,
('history.entered_state',),
),
(
"1d12h has passed since history.entered_state",
False,
('history.entered_state',),
),
(
"history.previous_state = incorrect_state",
False,
('history.previous_state', 'incorrect_state'),
),
]


class FakeHistoryEntry(NamedTuple):
created: datetime.datetime
old_state: Optional[str]
new_state: Optional[str]


NOW = datetime.datetime(2017, 1, 1, 12, 0, 0, tzinfo=dateutil.tz.tzutc())
VARIABLES = {
'foo': 4,
'objects': (2, 4),
'old_time': NOW - datetime.timedelta(hours=4),
}

HISTORY_ENTRY = FakeHistoryEntry(
old_state='old_state',
new_state='new_state',
created=NOW - datetime.timedelta(hours=15),
)


@pytest.mark.parametrize("program, expected, variables", PROGRAMS)
def test_evaluate(program, expected, variables):
def test_evaluate(program, expected, variables, make_context):
program = ExitConditionProgram(program)
context = Context(
'label1',
VARIABLES,
NOW,
{},
program.accessed_variables(),
context = make_context(
label='label1',
metadata=VARIABLES,
now=NOW,
current_history_entry=HISTORY_ENTRY,
accessed_variables=program.accessed_variables(),
)
assert program.run(context) == expected

Expand Down Expand Up @@ -139,15 +169,15 @@ def test_accessed_variables(program, expected, variables):


@pytest.mark.parametrize("source, error", ERRORS)
def test_errors(source, error):
def test_errors(source, error, make_context):
with pytest.raises(ValueError) as compile_error:
program = ExitConditionProgram(source)
context = Context(
'label1',
VARIABLES,
NOW,
{},
program.accessed_variables(),
context = make_context(
label='label1',
metadata=VARIABLES,
now=NOW,
current_history_entry=HISTORY_ENTRY,
accessed_variables=program.accessed_variables(),
)
program.run(context)

Expand Down

0 comments on commit aa13991

Please sign in to comment.