diff --git a/routemaster/config/__init__.py b/routemaster/config/__init__.py index 8d689475..0d895790 100644 --- a/routemaster/config/__init__.py +++ b/routemaster/config/__init__.py @@ -9,7 +9,6 @@ Webhook, FeedConfig, NextStates, - TimeTrigger, NoNextStates, StateMachine, DatabaseConfig, @@ -18,8 +17,11 @@ MetadataTrigger, ConstantNextState, ContextNextStates, + SystemTimeTrigger, LoggingPluginConfig, + TimezoneAwareTrigger, ContextNextStatesOption, + MetadataTimezoneAwareTrigger, ) from routemaster.config.loader import load_config, load_database_config from routemaster.config.exceptions import ConfigError @@ -36,7 +38,6 @@ 'FeedConfig', 'NextStates', 'ConfigError', - 'TimeTrigger', 'NoNextStates', 'StateMachine', 'DatabaseConfig', @@ -45,6 +46,9 @@ 'MetadataTrigger', 'ConstantNextState', 'ContextNextStates', + 'SystemTimeTrigger', 'LoggingPluginConfig', + 'TimezoneAwareTrigger', 'ContextNextStatesOption', + 'MetadataTimezoneAwareTrigger', ) diff --git a/routemaster/config/loader.py b/routemaster/config/loader.py index dd05819a..3e3f2ec9 100644 --- a/routemaster/config/loader.py +++ b/routemaster/config/loader.py @@ -3,13 +3,14 @@ import os import re import datetime -from typing import Any, Dict, List, Iterable, Optional +from typing import Any, Dict, List, Union, Iterable, Optional import yaml import jsonschema import pkg_resources import jsonschema.exceptions +from routemaster.timezones import get_known_timezones from routemaster.text_utils import join_comma_or from routemaster.config.model import ( Gate, @@ -20,7 +21,6 @@ Webhook, FeedConfig, NextStates, - TimeTrigger, NoNextStates, StateMachine, DatabaseConfig, @@ -29,8 +29,11 @@ MetadataTrigger, ConstantNextState, ContextNextStates, + SystemTimeTrigger, LoggingPluginConfig, + TimezoneAwareTrigger, ContextNextStatesOption, + MetadataTimezoneAwareTrigger, ) from routemaster.exit_conditions import ExitConditionProgram from routemaster.config.exceptions import ConfigError @@ -249,11 +252,6 @@ def _load_gate(path: Path, yaml_state: Yaml, feed_names: List[str]) -> Gate: def _load_trigger(path: Path, yaml_trigger: Yaml) -> Trigger: - if len(yaml_trigger.keys()) > 1: # pragma: no branch - raise ConfigError( # pragma: no cover - f"Trigger at path {'.'.join(path)} cannot be of multiple types.", - ) - if 'time' in yaml_trigger: return _load_time_trigger(path, yaml_trigger) elif 'metadata' in yaml_trigger: @@ -269,7 +267,22 @@ def _load_trigger(path: Path, yaml_trigger: Yaml) -> Trigger: ) -def _load_time_trigger(path: Path, yaml_trigger: Yaml) -> TimeTrigger: +def _validate_known_timezone(path: Path, timezone: str) -> None: + if timezone not in get_known_timezones(): + raise ConfigError( + f"Timezone '{timezone}' at path {'.'.join(path)} is not a known " + f"timezone.", + ) + + +def _load_time_trigger( + path: Path, + yaml_trigger: Yaml, +) -> Union[ + SystemTimeTrigger, + TimezoneAwareTrigger, + MetadataTimezoneAwareTrigger, +]: format_ = '%Hh%Mm' try: dt = datetime.datetime.strptime(str(yaml_trigger['time']), format_) @@ -279,7 +292,21 @@ def _load_time_trigger(path: Path, yaml_trigger: Yaml) -> TimeTrigger: f"Time trigger '{yaml_trigger['time']}' at path {'.'.join(path)} " f"does not meet expected format: {format_}.", ) from None - return TimeTrigger(time=trigger) + + if 'timezone' in yaml_trigger: + timezone_path = path + ['timezone'] + timezone: str = yaml_trigger['timezone'] + if timezone.startswith('metadata.'): + _validate_context_lookups(timezone_path, [timezone], []) + return MetadataTimezoneAwareTrigger( + time=trigger, + timezone_metadata_path=timezone.split('.')[1:], + ) + else: + _validate_known_timezone(timezone_path, timezone) + return TimezoneAwareTrigger(time=trigger, timezone=timezone) + + return SystemTimeTrigger(time=trigger) RE_INTERVAL = re.compile( diff --git a/routemaster/config/model.py b/routemaster/config/model.py index bda3b222..e25695f9 100644 --- a/routemaster/config/model.py +++ b/routemaster/config/model.py @@ -22,9 +22,37 @@ from routemaster.context import Context # noqa -class TimeTrigger(NamedTuple): - """Time based trigger for exit condition evaluation.""" +class SystemTimeTrigger(NamedTuple): + """ + System time based trigger for exit condition evaluation. + + This trigger runs at the given time according to the system on which + routemaster is running. + """ + time: datetime.time + + +class TimezoneAwareTrigger(NamedTuple): + """ + Fixed timezone aware trigger for exit condition evaluation. + + This trigger runs at the time according to the named timezone. The timezone + should be spelled using an IANA name, for example: 'Europe/London'. + """ + time: datetime.time + timezone: str + + +class MetadataTimezoneAwareTrigger(NamedTuple): + """ + Metadata timezone aware trigger for exit condition evaluation. + + This trigger uses a label's metadata to determine the current timezone and + otherwise behaves like a `TimezoneAwareTrigger`. The timezone should be + spelled using an IANA name, for example: 'Europe/London'. + """ time: datetime.time + timezone_metadata_path: Sequence[str] class IntervalTrigger(NamedTuple): @@ -53,7 +81,14 @@ class OnEntryTrigger: """Trigger on entry to a given gate.""" -Trigger = Union[TimeTrigger, IntervalTrigger, MetadataTrigger, OnEntryTrigger] +Trigger = Union[ + SystemTimeTrigger, + TimezoneAwareTrigger, + MetadataTimezoneAwareTrigger, + IntervalTrigger, + MetadataTrigger, + OnEntryTrigger, +] class ConstantNextState(NamedTuple): diff --git a/routemaster/config/schema.yaml b/routemaster/config/schema.yaml index 5689b35d..4aa70919 100644 --- a/routemaster/config/schema.yaml +++ b/routemaster/config/schema.yaml @@ -68,6 +68,9 @@ properties: time: type: string pattern: '^[0-9]{1,2}h[0-9]{2}m$' + timezone: + type: string + pattern: '^([a-zA-Z]+/[a-zA-Z_]+|metadata\.\S+)$' required: - time additionalProperties: false diff --git a/routemaster/config/tests/test_loading.py b/routemaster/config/tests/test_loading.py index 93b5967d..c4a4d3f5 100644 --- a/routemaster/config/tests/test_loading.py +++ b/routemaster/config/tests/test_loading.py @@ -15,7 +15,6 @@ Webhook, FeedConfig, ConfigError, - TimeTrigger, NoNextStates, StateMachine, DatabaseConfig, @@ -24,8 +23,11 @@ MetadataTrigger, ConstantNextState, ContextNextStates, + SystemTimeTrigger, LoggingPluginConfig, + TimezoneAwareTrigger, ContextNextStatesOption, + MetadataTimezoneAwareTrigger, load_config, ) from routemaster.exit_conditions import ExitConditionProgram @@ -99,7 +101,15 @@ def test_realistic_config(): Gate( name='start', triggers=[ - TimeTrigger(time=datetime.time(18, 30)), + SystemTimeTrigger(time=datetime.time(18, 30)), + TimezoneAwareTrigger( + time=datetime.time(12, 25), + timezone='Europe/London', + ), + MetadataTimezoneAwareTrigger( + time=datetime.time(13, 37), + timezone_metadata_path=['timezone'], + ), MetadataTrigger(metadata_path='foo.bar'), IntervalTrigger( interval=datetime.timedelta(hours=1), @@ -228,6 +238,11 @@ def test_raises_for_invalid_time_format_in_trigger(): load_config(yaml_data('trigger_time_format_invalid')) +def test_raises_for_invalid_timezone_name_in_trigger(): + with assert_config_error("Could not validate config file against schema."): + load_config(yaml_data('trigger_timezone_name_invalid')) + + def test_raises_for_invalid_path_format_in_trigger(): with assert_config_error("Could not validate config file against schema."): load_config(yaml_data('path_format_context_trigger_invalid')) @@ -306,7 +321,15 @@ def test_environment_variables_override_config_file_for_database_config(): Gate( name='start', triggers=[ - TimeTrigger(time=datetime.time(18, 30)), + SystemTimeTrigger(time=datetime.time(18, 30)), + TimezoneAwareTrigger( + time=datetime.time(12, 25), + timezone='Europe/London', + ), + MetadataTimezoneAwareTrigger( + time=datetime.time(13, 37), + timezone_metadata_path=['timezone'], + ), MetadataTrigger(metadata_path='foo.bar'), IntervalTrigger( interval=datetime.timedelta(hours=1), diff --git a/routemaster/cron.py b/routemaster/cron.py index 587498f0..961959b1 100644 --- a/routemaster/cron.py +++ b/routemaster/cron.py @@ -4,7 +4,7 @@ import functools import itertools import threading -from typing import List, Callable, Iterable +from typing import Callable, Iterable import schedule from typing_extensions import Protocol @@ -14,12 +14,15 @@ Gate, State, Action, - TimeTrigger, StateMachine, IntervalTrigger, MetadataTrigger, + SystemTimeTrigger, + TimezoneAwareTrigger, + MetadataTimezoneAwareTrigger, ) from routemaster.state_machine import ( + LabelProvider, LabelStateProcessor, process_cron, process_gate, @@ -27,14 +30,13 @@ labels_in_state, labels_needing_metadata_update_retry_in_gate, ) +from routemaster.cron_processors import ( + TimezoneAwareProcessor, + MetadataTimezoneAwareProcessor, +) IsTerminating = Callable[[], bool] -# Note: This function will be called in a different transaction to where we -# iterate over the results, so to prevent confusion or the possible -# introduction of errors, we require all the data up-front. -LabelProvider = Callable[[App, StateMachine, State], List[str]] - class CronProcessor(Protocol): """Type signature for the cron processor callable.""" @@ -118,7 +120,7 @@ def _configure_schedule_for_state( ) elif isinstance(state, Gate): for trigger in state.triggers: - if isinstance(trigger, TimeTrigger): + if isinstance(trigger, SystemTimeTrigger): scheduler.every().day.at( f"{trigger.time.hour:02d}:{trigger.time.minute:02d}", ).do( @@ -126,6 +128,22 @@ def _configure_schedule_for_state( fn=process_gate, label_provider=labels_in_state, ) + elif isinstance(trigger, TimezoneAwareTrigger): + func = functools.partial( + processor, + fn=process_gate, + label_provider=labels_in_state, + ) + scheduler.every().minute.do( + TimezoneAwareProcessor(func, trigger), + ) + elif isinstance(trigger, MetadataTimezoneAwareTrigger): + scheduler.every().minute.do( + MetadataTimezoneAwareProcessor( + functools.partial(processor, fn=process_gate), + trigger, + ), + ) elif isinstance(trigger, IntervalTrigger): scheduler.every( trigger.interval.total_seconds(), diff --git a/routemaster/cron_processors.py b/routemaster/cron_processors.py new file mode 100644 index 00000000..69d82c13 --- /dev/null +++ b/routemaster/cron_processors.py @@ -0,0 +1,86 @@ +"""Processor classes to support cron scheduled jobs.""" + +import functools +from typing import Callable + +from typing_extensions import Protocol + +from routemaster.config import ( + TimezoneAwareTrigger, + MetadataTimezoneAwareTrigger, +) +from routemaster.timezones import where_is_this_the_time +from routemaster.state_machine import ( + LabelProvider, + labels_in_state_with_metadata, +) + + +class ProcessingSpecificCronProcessor(Protocol): + """Type signature for the a processing-specific cron processor callable.""" + + def __call__( + self, + *, + label_provider: LabelProvider, + ) -> None: + """Type signature for a processing-specific cron processor callable.""" + ... + + +class TimezoneAwareProcessor: + """ + Cron processor for the `TimezoneAwareTrigger`. + + This expects to be called regularly and it will only actually do any + processing if the time is correct for its' trigger timezone. + """ + def __init__( + self, + processor: Callable[[], None], + trigger: TimezoneAwareTrigger, + ) -> None: + self.processor = processor + self.trigger = trigger + + def __call__(self) -> None: + """Run the cron processing.""" + timezones = where_is_this_the_time(self.trigger.time) + + if self.trigger.timezone not in timezones: + return + + self.processor() + + +class MetadataTimezoneAwareProcessor: + """ + Cron processor for the `MetadataTimezoneAwareTrigger`. + + This expects to be called regularly and it will only actually do any + processing if the time is correct for any known timezone. The processing it + does is then filtered to labels whose timezone metadata is among the + matched timezones. + """ + def __init__( + self, + processor: ProcessingSpecificCronProcessor, + trigger: MetadataTimezoneAwareTrigger, + ) -> None: + self.processor = processor + self.trigger = trigger + + def __call__(self) -> None: + """Run the cron processing.""" + timezones = where_is_this_the_time(self.trigger.time) + + if not timezones: + return + + label_provider = functools.partial( + labels_in_state_with_metadata, + path=self.trigger.timezone_metadata_path, + values=timezones, + ) + + self.processor(label_provider=label_provider) diff --git a/routemaster/state_machine/__init__.py b/routemaster/state_machine/__init__.py index e5e3f54a..88f9f288 100644 --- a/routemaster/state_machine/__init__.py +++ b/routemaster/state_machine/__init__.py @@ -2,6 +2,7 @@ from routemaster.state_machine.api import ( LabelRef, + LabelProvider, LabelStateProcessor, list_labels, create_label, @@ -14,6 +15,7 @@ from routemaster.state_machine.gates import process_gate from routemaster.state_machine.utils import ( labels_in_state, + labels_in_state_with_metadata, labels_needing_metadata_update_retry_in_gate, ) from routemaster.state_machine.actions import process_action @@ -33,6 +35,7 @@ 'process_gate', 'DeletedLabel', 'UnknownLabel', + 'LabelProvider', 'process_action', 'get_label_state', 'labels_in_state', @@ -41,5 +44,6 @@ 'LabelStateProcessor', 'UnknownStateMachine', 'update_metadata_for_label', + 'labels_in_state_with_metadata', 'labels_needing_metadata_update_retry_in_gate', ) diff --git a/routemaster/state_machine/api.py b/routemaster/state_machine/api.py index 4a9eec89..ab3c0e8e 100644 --- a/routemaster/state_machine/api.py +++ b/routemaster/state_machine/api.py @@ -1,6 +1,6 @@ """The core of the state machine logic.""" -from typing import Callable, Iterable, Optional +from typing import List, Callable, Iterable, Optional from typing_extensions import Protocol @@ -27,6 +27,12 @@ ) from routemaster.state_machine.transitions import process_transitions +# Signature of a function to gather the labels to be operated upon when +# processing a cron task. This will be called in a different transaction to +# where we iterate over the results, so to prevent confusion or the possible +# introduction of errors, we require all the data up-front. +LabelProvider = Callable[[App, StateMachine, State], List[str]] + def list_labels(app: App, state_machine: StateMachine) -> Iterable[LabelRef]: """ diff --git a/routemaster/state_machine/tests/test_state_machine_utils.py b/routemaster/state_machine/tests/test_state_machine_utils.py index fabe43e8..227316d4 100644 --- a/routemaster/state_machine/tests/test_state_machine_utils.py +++ b/routemaster/state_machine/tests/test_state_machine_utils.py @@ -275,3 +275,73 @@ def test_labels_in_state(app, mock_test_feed, mock_webhook, create_label, create test_machine, gate, ) == [label_in_state.name] + + +def test_labels_in_state_with_metadata(app, mock_test_feed, mock_webhook, create_label, create_deleted_label, current_state): + label_matching_metadata = create_label('label_matching_metadata', 'test_machine', {'foo': 'bar'}) + label_other_metadata = create_label('label_other_metadata', 'test_machine', {'foo': 'other'}) + label_empty_metadata = create_label('label_empty_metadata', 'test_machine', {}) + label_deleted = create_deleted_label('label_deleted', 'test_machine') + + with mock_test_feed(), mock_webhook(): + label_not_in_state = create_label( + 'label_not_in_state', + 'test_machine', + {'should_progress': True}, + ) + + test_machine = app.config.state_machines['test_machine'] + gate = test_machine.states[0] + + assert current_state(label_matching_metadata) == 'start' + assert current_state(label_empty_metadata) == 'start' + assert current_state(label_other_metadata) == 'start' + assert current_state(label_deleted) is None + assert current_state(label_not_in_state) == 'end' + + # But only label_unprocessed should be pending a metadata update + with app.new_session(): + assert utils.labels_in_state_with_metadata( + app, + test_machine, + gate, + path=['foo'], + values=['bar', 'quox'], + ) == [label_matching_metadata.name] + + +def test_labels_in_state_with_metadata_nested(app, mock_test_feed, mock_webhook, create_label, create_deleted_label, current_state): + label_matching_metadata = create_label('label_matching_metadata', 'test_machine', {'foo': {'bar': 'quox'}}) + label_other_metadata_1 = create_label('label_other_metadata_1', 'test_machine', {'foo': {'bar': 'other'}}) + label_other_metadata_2 = create_label('label_other_metadata_2', 'test_machine', {'foo': 'bar'}) + label_other_metadata_3 = create_label('label_other_metadata_3', 'test_machine', {'foo': 'other'}) + label_empty_metadata = create_label('label_empty_metadata', 'test_machine', {}) + label_deleted = create_deleted_label('label_deleted', 'test_machine') + + with mock_test_feed(), mock_webhook(): + label_not_in_state = create_label( + 'label_not_in_state', + 'test_machine', + {'should_progress': True}, + ) + + test_machine = app.config.state_machines['test_machine'] + gate = test_machine.states[0] + + assert current_state(label_matching_metadata) == 'start' + assert current_state(label_empty_metadata) == 'start' + assert current_state(label_other_metadata_1) == 'start' + assert current_state(label_other_metadata_2) == 'start' + assert current_state(label_other_metadata_3) == 'start' + assert current_state(label_deleted) is None + assert current_state(label_not_in_state) == 'end' + + # But only label_unprocessed should be pending a metadata update + with app.new_session(): + assert utils.labels_in_state_with_metadata( + app, + test_machine, + gate, + path=['foo', 'bar'], + values=['quox'], + ) == [label_matching_metadata.name] diff --git a/routemaster/state_machine/utils.py b/routemaster/state_machine/utils.py index 2294d546..04ca7454 100644 --- a/routemaster/state_machine/utils.py +++ b/routemaster/state_machine/utils.py @@ -3,7 +3,7 @@ import datetime import functools import contextlib -from typing import Any, Dict, List, Tuple, Optional +from typing import Any, Dict, List, Tuple, Optional, Sequence, Collection import dateutil.tz from sqlalchemy import func @@ -136,6 +136,34 @@ def labels_in_state( return _labels_in_state(app, state_machine, state, True) +def labels_in_state_with_metadata( + app: App, + state_machine: StateMachine, + state: State, + path: Sequence[str], + values: Collection[str], +) -> List[str]: + """ + Util to get all the labels in a given state with some metadata value. + + The metadata lookup happens at the given path, allowing for any of the + posible values given. + """ + if not values: + raise ValueError("Must specify at least one possible value") + + metadata_lookup = Label.metadata + for part in path: + metadata_lookup = metadata_lookup[part] # type: ignore + + return _labels_in_state( + app, + state_machine, + state, + metadata_lookup.astext.in_(values), # type: ignore + ) + + def labels_needing_metadata_update_retry_in_gate( app: App, state_machine: StateMachine, diff --git a/routemaster/tests/test_cron.py b/routemaster/tests/test_cron.py index 2f1cb63e..f8af4115 100644 --- a/routemaster/tests/test_cron.py +++ b/routemaster/tests/test_cron.py @@ -8,11 +8,13 @@ from routemaster.config import ( Gate, Action, - TimeTrigger, NoNextStates, StateMachine, IntervalTrigger, MetadataTrigger, + SystemTimeTrigger, + TimezoneAwareTrigger, + MetadataTimezoneAwareTrigger, ) from routemaster.exit_conditions import ExitConditionProgram @@ -61,7 +63,7 @@ def test_gate_at_fixed_time(custom_app): 'fixed_time_gate', next_states=NoNextStates(), exit_condition=ExitConditionProgram('false'), - triggers=[TimeTrigger(datetime.time(18, 30))], + triggers=[SystemTimeTrigger(datetime.time(18, 30))], ) app = create_app(custom_app, [gate]) @@ -87,6 +89,146 @@ def processor(*, state, **kwargs): assert job.next_run == datetime.datetime(2018, 1, 2, 18, 30) +@freezegun.freeze_time('2018-01-01 12:00') +def test_gate_at_fixed_time_with_specific_timezone(custom_app): + gate = Gate( + 'fixed_time_gate', + next_states=NoNextStates(), + exit_condition=ExitConditionProgram('false'), + triggers=[TimezoneAwareTrigger( + datetime.time(12, 1), + timezone='Europe/London', + )], + ) + app = create_app(custom_app, [gate]) + + def processor(*, state, **kwargs): + assert state == gate + processor.called = True + + processor.called = False + + scheduler = schedule.Scheduler() + configure_schedule(app, scheduler, processor) + + assert len(scheduler.jobs) == 1, "Should have scheduled a single job" + job, = scheduler.jobs + + assert job.next_run == datetime.datetime(2018, 1, 1, 12, 1) + assert processor.called is False + + with freezegun.freeze_time(job.next_run): + job.run() + + assert processor.called is True + assert job.next_run == datetime.datetime(2018, 1, 1, 12, 2) + + +@freezegun.freeze_time('2018-01-01 12:00') +def test_gate_at_fixed_time_with_specific_timezone_other_time(custom_app): + gate = Gate( + 'fixed_time_gate', + next_states=NoNextStates(), + exit_condition=ExitConditionProgram('false'), + triggers=[TimezoneAwareTrigger( + datetime.time(13, 37), + timezone='Europe/London', + )], + ) + app = create_app(custom_app, [gate]) + + def processor(*, state, **kwargs): + assert state == gate + processor.called = True + + processor.called = False + + scheduler = schedule.Scheduler() + configure_schedule(app, scheduler, processor) + + assert len(scheduler.jobs) == 1, "Should have scheduled a single job" + job, = scheduler.jobs + + assert job.next_run == datetime.datetime(2018, 1, 1, 12, 1) + assert processor.called is False + + with freezegun.freeze_time(job.next_run): + job.run() + + assert processor.called is False + assert job.next_run == datetime.datetime(2018, 1, 1, 12, 2) + + +@freezegun.freeze_time('2018-01-01 12:00') +def test_gate_at_fixed_time_with_metadata_timezone(custom_app): + gate = Gate( + 'fixed_time_gate', + next_states=NoNextStates(), + exit_condition=ExitConditionProgram('false'), + triggers=[MetadataTimezoneAwareTrigger( + datetime.time(12, 1), + timezone_metadata_path='timezone', + )], + ) + app = create_app(custom_app, [gate]) + + def processor(*, state, **kwargs): + assert state == gate + processor.called = True + + processor.called = False + + scheduler = schedule.Scheduler() + configure_schedule(app, scheduler, processor) + + assert len(scheduler.jobs) == 1, "Should have scheduled a single job" + job, = scheduler.jobs + + assert job.next_run == datetime.datetime(2018, 1, 1, 12, 1) + assert processor.called is False + + with freezegun.freeze_time(job.next_run): + job.run() + + assert processor.called is True + assert job.next_run == datetime.datetime(2018, 1, 1, 12, 2) + + +@freezegun.freeze_time('2018-01-01 12:00') +def test_gate_at_fixed_time_with_metadata_timezone_other_time(custom_app): + gate = Gate( + 'fixed_time_gate', + next_states=NoNextStates(), + exit_condition=ExitConditionProgram('false'), + triggers=[MetadataTimezoneAwareTrigger( + datetime.time(13, 37), + timezone_metadata_path='timezone', + )], + ) + app = create_app(custom_app, [gate]) + + def processor(*, state, **kwargs): + assert state == gate + processor.called = True + + processor.called = False + + scheduler = schedule.Scheduler() + configure_schedule(app, scheduler, processor) + + assert len(scheduler.jobs) == 1, "Should have scheduled a single job" + job, = scheduler.jobs + + assert job.next_run == datetime.datetime(2018, 1, 1, 12, 1) + assert processor.called is False + + with freezegun.freeze_time(job.next_run): + job.run() + + assert processor.called is False + assert job.next_run == datetime.datetime(2018, 1, 1, 12, 2) + + @freezegun.freeze_time('2018-01-01 12:00') def test_gate_at_interval(custom_app): gate = Gate( @@ -157,7 +299,7 @@ def test_cron_job_gracefully_exit_signalling(custom_app): 'gate', next_states=NoNextStates(), exit_condition=ExitConditionProgram('false'), - triggers=[TimeTrigger(datetime.time(12, 0))], + triggers=[SystemTimeTrigger(datetime.time(12, 0))], ) app = create_app(custom_app, [gate]) state_machine = app.config.state_machines['test_machine'] @@ -193,7 +335,7 @@ def test_cron_job_does_not_forward_exceptions(custom_app): 'gate', next_states=NoNextStates(), exit_condition=ExitConditionProgram('false'), - triggers=[TimeTrigger(datetime.time(12, 0))], + triggers=[SystemTimeTrigger(datetime.time(12, 0))], ) app = create_app(custom_app, [gate]) state_machine = app.config.state_machines['test_machine'] diff --git a/routemaster/tests/test_cron_processors.py b/routemaster/tests/test_cron_processors.py new file mode 100644 index 00000000..d1e58dc3 --- /dev/null +++ b/routemaster/tests/test_cron_processors.py @@ -0,0 +1,128 @@ +from routemaster.state_machine import ( + labels_in_state_with_metadata, +) +import datetime + +from unittest import mock + +import freezegun + +from routemaster.config import ( + TimezoneAwareTrigger, + MetadataTimezoneAwareTrigger, +) +from routemaster.cron_processors import ( + TimezoneAwareProcessor, + MetadataTimezoneAwareProcessor, +) + +# Test TimezoneAwareProcessor + + +@freezegun.freeze_time('2019-08-01 12:00 UTC') +def test_timezone_aware_processor_runs_on_time() -> None: + mock_callable = mock.Mock() + trigger = TimezoneAwareTrigger(datetime.time(12, 0), 'Etc/UTC') + + processor = TimezoneAwareProcessor(mock_callable, trigger) + processor() + + mock_callable.assert_called_once_with() + + +@freezegun.freeze_time('2019-08-01 12:00 UTC') +def test_timezone_aware_processor_runs_on_time_other_timezone() -> None: + mock_callable = mock.Mock() + trigger = TimezoneAwareTrigger(datetime.time(13, 0), 'Europe/London') + + processor = TimezoneAwareProcessor(mock_callable, trigger) + processor() + + mock_callable.assert_called_once_with() + + +@freezegun.freeze_time('2019-08-01 12:00 UTC') +def test_timezone_aware_processor_doesnt_run_when_timezone_doesnt_match() -> None: + mock_callable = mock.Mock() + trigger = TimezoneAwareTrigger(datetime.time(12, 0), 'Europe/London') + + processor = TimezoneAwareProcessor(mock_callable, trigger) + processor() + + mock_callable.assert_not_called() + + +@freezegun.freeze_time('2019-08-01 15:00 UTC') +def test_timezone_aware_processor_doesnt_run_at_wrong_time() -> None: + mock_callable = mock.Mock() + trigger = TimezoneAwareTrigger(datetime.time(12, 0), 'Etc/UTC') + + processor = TimezoneAwareProcessor(mock_callable, trigger) + processor() + + mock_callable.assert_not_called() + + +# Test MetadataTimezoneAwareProcessor + + +@freezegun.freeze_time('2019-01-01 12:00 UTC') +def test_metadata_timezone_aware_processor_runs_on_time() -> None: + mock_callable = mock.Mock() + trigger = MetadataTimezoneAwareTrigger(datetime.time(12, 0), ['tz']) + + processor = MetadataTimezoneAwareProcessor(mock_callable, trigger) + + with mock.patch('functools.partial') as mock_partial: + processor() + + mock_partial.assert_called_once_with( + labels_in_state_with_metadata, + path=['tz'], + values=mock.ANY, + ) + + timezones = mock_partial.call_args[1]['values'] + + assert 'Etc/UTC' in timezones + assert 'Europe/London' in timezones + + mock_callable.assert_called_once_with(label_provider=mock.ANY) + + +@freezegun.freeze_time('2019-08-01 12:00 UTC') +def test_metadata_timezone_aware_processor_runs_on_time_other_timezone() -> None: + mock_callable = mock.Mock() + trigger = MetadataTimezoneAwareTrigger(datetime.time(13, 0), ['tz']) + + processor = MetadataTimezoneAwareProcessor(mock_callable, trigger) + + with mock.patch('functools.partial') as mock_partial: + processor() + + mock_partial.assert_called_once_with( + labels_in_state_with_metadata, + path=['tz'], + values=mock.ANY, + ) + + timezones = mock_partial.call_args[1]['values'] + + assert 'Etc/UTC' not in timezones + assert 'Europe/London' in timezones + + mock_callable.assert_called_once_with(label_provider=mock.ANY) + + +@freezegun.freeze_time('2019-08-01 12:05 UTC') +def test_metadata_timezone_processor_doesnt_run_at_wrong_time() -> None: + mock_callable = mock.Mock() + trigger = MetadataTimezoneAwareTrigger(datetime.time(12, 0), ['tz']) + + processor = MetadataTimezoneAwareProcessor(mock_callable, trigger) + + with mock.patch('functools.partial') as mock_partial: + processor() + + mock_partial.assert_not_called() + mock_callable.assert_not_called() diff --git a/routemaster/tests/test_layering.py b/routemaster/tests/test_layering.py index 94c59404..ed95662b 100644 --- a/routemaster/tests/test_layering.py +++ b/routemaster/tests/test_layering.py @@ -32,12 +32,18 @@ ('config', 'exit_conditions'), ('config', 'context'), ('config', 'text_utils'), + ('config', 'timezones'), ('config', 'utils'), ('db', 'config'), ('cron', 'app'), + ('cron', 'cron_processors'), ('cron', 'state_machine'), + ('cron_processors', 'app'), + ('cron_processors', 'state_machine'), + ('cron_processors', 'timezones'), + ('validation', 'app'), ('validation', 'config'), diff --git a/routemaster/tests/test_timezones.py b/routemaster/tests/test_timezones.py new file mode 100644 index 00000000..5338cb16 --- /dev/null +++ b/routemaster/tests/test_timezones.py @@ -0,0 +1,132 @@ +import datetime + +import freezegun +import dateutil.tz +from pytest import raises + +from routemaster.timezones import get_known_timezones, where_is_this_the_time + +UTC = dateutil.tz.gettz('UTC') + + +def test_smoke_get_known_timezones(): + get_known_timezones() + + +def test_reject_tz_aware_when() -> None: + when = datetime.time(12, 0, tzinfo=UTC) + with raises(ValueError): + where_is_this_the_time(when) + + +def test_reject_tz_naive_reference() -> None: + when = datetime.time(12, 0) + reference = datetime.datetime(2019, 8, 1, 12, 0) + with raises(ValueError): + where_is_this_the_time(when, now=reference) + + +@freezegun.freeze_time('2019-08-01 12:00') +def test_matches_utc() -> None: + timezones = where_is_this_the_time(datetime.time(12, 0)) + + assert 'Etc/UTC' in timezones + assert 'Europe/London' not in timezones + + +def test_matches_utc_with_reference() -> None: + timezones = where_is_this_the_time( + datetime.time(12, 0), + now=datetime.datetime(2019, 8, 1, 12, 0, tzinfo=UTC), + ) + + assert 'Etc/UTC' in timezones + assert 'Europe/London' not in timezones + + +def test_matches_within_delta_before() -> None: + timezones = where_is_this_the_time( + datetime.time(12, 1), + delta=datetime.timedelta(seconds=5), + now=datetime.datetime(2019, 1, 1, 12, 0, 58, tzinfo=UTC), + ) + + assert 'Etc/UTC' in timezones + assert 'Europe/London' in timezones + + +def test_matches_within_delta_after() -> None: + timezones = where_is_this_the_time( + datetime.time(12, 0), + delta=datetime.timedelta(seconds=5), + now=datetime.datetime(2019, 1, 1, 12, 0, 2, tzinfo=UTC), + ) + + assert 'Etc/UTC' in timezones + assert 'Europe/London' in timezones + + +def test_no_matches_outside_delta_before() -> None: + timezones = where_is_this_the_time( + datetime.time(12, 1), + delta=datetime.timedelta(seconds=1), + now=datetime.datetime(2019, 1, 1, 12, 0, 58, tzinfo=UTC), + ) + + assert 'Etc/UTC' not in timezones + assert 'Europe/London' not in timezones + + +def test_no_matches_outside_delta_after() -> None: + timezones = where_is_this_the_time( + datetime.time(12, 0), + delta=datetime.timedelta(seconds=1), + now=datetime.datetime(2019, 1, 1, 12, 0, 2, tzinfo=UTC), + ) + + assert 'Etc/UTC' not in timezones + assert 'Europe/London' not in timezones + + +def test_match_bst_reference() -> None: + london_timezone = dateutil.tz.gettz('Europe/London') + + timezones = where_is_this_the_time( + datetime.time(12, 0), + now=datetime.datetime(2019, 8, 1, 12, 0, tzinfo=london_timezone), + ) + + assert 'Etc/UTC' not in timezones + assert 'Europe/London' in timezones + + +def test_match_gmt_reference() -> None: + london_timezone = dateutil.tz.gettz('Europe/London') + + timezones = where_is_this_the_time( + datetime.time(12, 0), + now=datetime.datetime(2019, 1, 1, 12, 0, tzinfo=london_timezone), + ) + + assert 'Etc/UTC' in timezones + assert 'Europe/London' in timezones + + +def test_match_bst_time() -> None: + timezones = where_is_this_the_time( + datetime.time(12, 0), + now=datetime.datetime(2019, 8, 1, 11, 0, tzinfo=UTC), + ) + + assert 'Etc/UTC' not in timezones + assert 'Europe/London' in timezones + + +def test_match_gmt_time() -> None: + timezones = where_is_this_the_time( + datetime.time(12, 0), + now=datetime.datetime(2019, 1, 1, 12, 0, tzinfo=UTC), + ) + + assert 'Etc/UTC' in timezones + assert 'Europe/London' in timezones diff --git a/routemaster/timezones.py b/routemaster/timezones.py new file mode 100644 index 00000000..581aec45 --- /dev/null +++ b/routemaster/timezones.py @@ -0,0 +1,80 @@ +"""Helpers for working with timezones.""" + + +import datetime +import functools +from typing import Set, Optional, FrozenSet + +import dateutil.tz +import dateutil.zoneinfo + + +@functools.lru_cache(maxsize=1) +def get_known_timezones() -> FrozenSet[str]: + """ + Return a cached set of the known timezones. + + This actually pulls its list from the internal database inside `dateutil` + as there doesn't seem to be a nice way to pull the data from the system. + + These are expected to change sufficiently infrequently that this is ok. In + any case, `dateutil` falls back to using this data source anyway, so at + worst this is a strict subset of the available timezones. + """ + # Get a non-cached copy of the `ZoneInfoFile`, not because we care about + # the cache being out of date, but so that we're not stuck with a MB of + # redundant memory usage. + + # ignore types because `dateutil.zoneinfo` isn't present in the typeshed + info = dateutil.zoneinfo.ZoneInfoFile( # type: ignore + dateutil.zoneinfo.getzoneinfofile_stream(), # type: ignore + ) + + return frozenset(info.zones.keys()) + + +def where_is_this_the_time( + when: datetime.time, + delta: datetime.timedelta = datetime.timedelta(minutes=1), + now: Optional[datetime.datetime] = None, +) -> Set[str]: + """ + Find timezones that consider wall-clock time `when` to be the current time. + + Optionally takes: + - a maximum delta between the current and the expected time (defaulting to + one minute to match the granularity of our triggers) + - a reference for the reference current time (specified as a timezone-aware + `datetime.time`) + """ + + if when.tzinfo is not None: + raise ValueError( + "May only specify a wall-clock time as timezone naive", + ) + + if now is None: + now = datetime.datetime.now(dateutil.tz.tzutc()) + elif now.tzinfo is None: + raise ValueError("May only specify a timezone-aware reference time") + + delta = abs(delta) + + def is_matching_time(timezone: str, reference: datetime.datetime) -> bool: + tzinfo = dateutil.tz.gettz(timezone) + local = reference.astimezone(tzinfo) + # ignore type due to `tzinfo` argument not being in the version of the + # typeshed we have available. + desired = datetime.datetime.combine( # type: ignore + reference.date(), + when, + tzinfo, + ) + difference = abs(local - desired) + return difference <= delta + + return set( + timezone + for timezone in get_known_timezones() + if is_matching_time(timezone, now) + ) diff --git a/test_data/realistic.yaml b/test_data/realistic.yaml index 6f9154c0..57258a44 100644 --- a/test_data/realistic.yaml +++ b/test_data/realistic.yaml @@ -11,6 +11,10 @@ state_machines: - gate: start triggers: - time: 18h30m + - time: 12h25m + timezone: Europe/London + - time: 13h37m + timezone: metadata.timezone - metadata: foo.bar - interval: 1h - event: entry diff --git a/test_data/trigger_timezone_name_invalid.yaml b/test_data/trigger_timezone_name_invalid.yaml new file mode 100644 index 00000000..5e7d1431 --- /dev/null +++ b/test_data/trigger_timezone_name_invalid.yaml @@ -0,0 +1,8 @@ +state_machines: + example: + states: + - gate: start + triggers: + - time: 18h00m + timezone: IS_NOT_A_TIMEZONE + exit_condition: false