Skip to content

Commit bdca9d8

Browse files
authored
Merge pull request #82 from thread/clarify-time-trigger
Clarify time trigger
2 parents 37b595f + 3c41229 commit bdca9d8

18 files changed

Lines changed: 835 additions & 31 deletions

routemaster/config/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
Webhook,
1010
FeedConfig,
1111
NextStates,
12-
TimeTrigger,
1312
NoNextStates,
1413
StateMachine,
1514
DatabaseConfig,
@@ -18,8 +17,11 @@
1817
MetadataTrigger,
1918
ConstantNextState,
2019
ContextNextStates,
20+
SystemTimeTrigger,
2121
LoggingPluginConfig,
22+
TimezoneAwareTrigger,
2223
ContextNextStatesOption,
24+
MetadataTimezoneAwareTrigger,
2325
)
2426
from routemaster.config.loader import load_config, load_database_config
2527
from routemaster.config.exceptions import ConfigError
@@ -36,7 +38,6 @@
3638
'FeedConfig',
3739
'NextStates',
3840
'ConfigError',
39-
'TimeTrigger',
4041
'NoNextStates',
4142
'StateMachine',
4243
'DatabaseConfig',
@@ -45,6 +46,9 @@
4546
'MetadataTrigger',
4647
'ConstantNextState',
4748
'ContextNextStates',
49+
'SystemTimeTrigger',
4850
'LoggingPluginConfig',
51+
'TimezoneAwareTrigger',
4952
'ContextNextStatesOption',
53+
'MetadataTimezoneAwareTrigger',
5054
)

routemaster/config/loader.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
import os
44
import re
55
import datetime
6-
from typing import Any, Dict, List, Iterable, Optional
6+
from typing import Any, Dict, List, Union, Iterable, Optional
77

88
import yaml
99
import jsonschema
1010
import pkg_resources
1111
import jsonschema.exceptions
1212

13+
from routemaster.timezones import get_known_timezones
1314
from routemaster.text_utils import join_comma_or
1415
from routemaster.config.model import (
1516
Gate,
@@ -20,7 +21,6 @@
2021
Webhook,
2122
FeedConfig,
2223
NextStates,
23-
TimeTrigger,
2424
NoNextStates,
2525
StateMachine,
2626
DatabaseConfig,
@@ -29,8 +29,11 @@
2929
MetadataTrigger,
3030
ConstantNextState,
3131
ContextNextStates,
32+
SystemTimeTrigger,
3233
LoggingPluginConfig,
34+
TimezoneAwareTrigger,
3335
ContextNextStatesOption,
36+
MetadataTimezoneAwareTrigger,
3437
)
3538
from routemaster.exit_conditions import ExitConditionProgram
3639
from routemaster.config.exceptions import ConfigError
@@ -249,11 +252,6 @@ def _load_gate(path: Path, yaml_state: Yaml, feed_names: List[str]) -> Gate:
249252

250253

251254
def _load_trigger(path: Path, yaml_trigger: Yaml) -> Trigger:
252-
if len(yaml_trigger.keys()) > 1: # pragma: no branch
253-
raise ConfigError( # pragma: no cover
254-
f"Trigger at path {'.'.join(path)} cannot be of multiple types.",
255-
)
256-
257255
if 'time' in yaml_trigger:
258256
return _load_time_trigger(path, yaml_trigger)
259257
elif 'metadata' in yaml_trigger:
@@ -269,7 +267,22 @@ def _load_trigger(path: Path, yaml_trigger: Yaml) -> Trigger:
269267
)
270268

271269

272-
def _load_time_trigger(path: Path, yaml_trigger: Yaml) -> TimeTrigger:
270+
def _validate_known_timezone(path: Path, timezone: str) -> None:
271+
if timezone not in get_known_timezones():
272+
raise ConfigError(
273+
f"Timezone '{timezone}' at path {'.'.join(path)} is not a known "
274+
f"timezone.",
275+
)
276+
277+
278+
def _load_time_trigger(
279+
path: Path,
280+
yaml_trigger: Yaml,
281+
) -> Union[
282+
SystemTimeTrigger,
283+
TimezoneAwareTrigger,
284+
MetadataTimezoneAwareTrigger,
285+
]:
273286
format_ = '%Hh%Mm'
274287
try:
275288
dt = datetime.datetime.strptime(str(yaml_trigger['time']), format_)
@@ -279,7 +292,21 @@ def _load_time_trigger(path: Path, yaml_trigger: Yaml) -> TimeTrigger:
279292
f"Time trigger '{yaml_trigger['time']}' at path {'.'.join(path)} "
280293
f"does not meet expected format: {format_}.",
281294
) from None
282-
return TimeTrigger(time=trigger)
295+
296+
if 'timezone' in yaml_trigger:
297+
timezone_path = path + ['timezone']
298+
timezone: str = yaml_trigger['timezone']
299+
if timezone.startswith('metadata.'):
300+
_validate_context_lookups(timezone_path, [timezone], [])
301+
return MetadataTimezoneAwareTrigger(
302+
time=trigger,
303+
timezone_metadata_path=timezone.split('.')[1:],
304+
)
305+
else:
306+
_validate_known_timezone(timezone_path, timezone)
307+
return TimezoneAwareTrigger(time=trigger, timezone=timezone)
308+
309+
return SystemTimeTrigger(time=trigger)
283310

284311

285312
RE_INTERVAL = re.compile(

routemaster/config/model.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,37 @@
2222
from routemaster.context import Context # noqa
2323

2424

25-
class TimeTrigger(NamedTuple):
26-
"""Time based trigger for exit condition evaluation."""
25+
class SystemTimeTrigger(NamedTuple):
26+
"""
27+
System time based trigger for exit condition evaluation.
28+
29+
This trigger runs at the given time according to the system on which
30+
routemaster is running.
31+
"""
32+
time: datetime.time
33+
34+
35+
class TimezoneAwareTrigger(NamedTuple):
36+
"""
37+
Fixed timezone aware trigger for exit condition evaluation.
38+
39+
This trigger runs at the time according to the named timezone. The timezone
40+
should be spelled using an IANA name, for example: 'Europe/London'.
41+
"""
42+
time: datetime.time
43+
timezone: str
44+
45+
46+
class MetadataTimezoneAwareTrigger(NamedTuple):
47+
"""
48+
Metadata timezone aware trigger for exit condition evaluation.
49+
50+
This trigger uses a label's metadata to determine the current timezone and
51+
otherwise behaves like a `TimezoneAwareTrigger`. The timezone should be
52+
spelled using an IANA name, for example: 'Europe/London'.
53+
"""
2754
time: datetime.time
55+
timezone_metadata_path: Sequence[str]
2856

2957

3058
class IntervalTrigger(NamedTuple):
@@ -53,7 +81,14 @@ class OnEntryTrigger:
5381
"""Trigger on entry to a given gate."""
5482

5583

56-
Trigger = Union[TimeTrigger, IntervalTrigger, MetadataTrigger, OnEntryTrigger]
84+
Trigger = Union[
85+
SystemTimeTrigger,
86+
TimezoneAwareTrigger,
87+
MetadataTimezoneAwareTrigger,
88+
IntervalTrigger,
89+
MetadataTrigger,
90+
OnEntryTrigger,
91+
]
5792

5893

5994
class ConstantNextState(NamedTuple):

routemaster/config/schema.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ properties:
6868
time:
6969
type: string
7070
pattern: '^[0-9]{1,2}h[0-9]{2}m$'
71+
timezone:
72+
type: string
73+
pattern: '^([a-zA-Z]+/[a-zA-Z_]+|metadata\.\S+)$'
7174
required:
7275
- time
7376
additionalProperties: false

routemaster/config/tests/test_loading.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
Webhook,
1616
FeedConfig,
1717
ConfigError,
18-
TimeTrigger,
1918
NoNextStates,
2019
StateMachine,
2120
DatabaseConfig,
@@ -24,8 +23,11 @@
2423
MetadataTrigger,
2524
ConstantNextState,
2625
ContextNextStates,
26+
SystemTimeTrigger,
2727
LoggingPluginConfig,
28+
TimezoneAwareTrigger,
2829
ContextNextStatesOption,
30+
MetadataTimezoneAwareTrigger,
2931
load_config,
3032
)
3133
from routemaster.exit_conditions import ExitConditionProgram
@@ -99,7 +101,15 @@ def test_realistic_config():
99101
Gate(
100102
name='start',
101103
triggers=[
102-
TimeTrigger(time=datetime.time(18, 30)),
104+
SystemTimeTrigger(time=datetime.time(18, 30)),
105+
TimezoneAwareTrigger(
106+
time=datetime.time(12, 25),
107+
timezone='Europe/London',
108+
),
109+
MetadataTimezoneAwareTrigger(
110+
time=datetime.time(13, 37),
111+
timezone_metadata_path=['timezone'],
112+
),
103113
MetadataTrigger(metadata_path='foo.bar'),
104114
IntervalTrigger(
105115
interval=datetime.timedelta(hours=1),
@@ -228,6 +238,11 @@ def test_raises_for_invalid_time_format_in_trigger():
228238
load_config(yaml_data('trigger_time_format_invalid'))
229239

230240

241+
def test_raises_for_invalid_timezone_name_in_trigger():
242+
with assert_config_error("Could not validate config file against schema."):
243+
load_config(yaml_data('trigger_timezone_name_invalid'))
244+
245+
231246
def test_raises_for_invalid_path_format_in_trigger():
232247
with assert_config_error("Could not validate config file against schema."):
233248
load_config(yaml_data('path_format_context_trigger_invalid'))
@@ -306,7 +321,15 @@ def test_environment_variables_override_config_file_for_database_config():
306321
Gate(
307322
name='start',
308323
triggers=[
309-
TimeTrigger(time=datetime.time(18, 30)),
324+
SystemTimeTrigger(time=datetime.time(18, 30)),
325+
TimezoneAwareTrigger(
326+
time=datetime.time(12, 25),
327+
timezone='Europe/London',
328+
),
329+
MetadataTimezoneAwareTrigger(
330+
time=datetime.time(13, 37),
331+
timezone_metadata_path=['timezone'],
332+
),
310333
MetadataTrigger(metadata_path='foo.bar'),
311334
IntervalTrigger(
312335
interval=datetime.timedelta(hours=1),

routemaster/cron.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import functools
55
import itertools
66
import threading
7-
from typing import List, Callable, Iterable
7+
from typing import Callable, Iterable
88

99
import schedule
1010
from typing_extensions import Protocol
@@ -14,27 +14,29 @@
1414
Gate,
1515
State,
1616
Action,
17-
TimeTrigger,
1817
StateMachine,
1918
IntervalTrigger,
2019
MetadataTrigger,
20+
SystemTimeTrigger,
21+
TimezoneAwareTrigger,
22+
MetadataTimezoneAwareTrigger,
2123
)
2224
from routemaster.state_machine import (
25+
LabelProvider,
2326
LabelStateProcessor,
2427
process_cron,
2528
process_gate,
2629
process_action,
2730
labels_in_state,
2831
labels_needing_metadata_update_retry_in_gate,
2932
)
33+
from routemaster.cron_processors import (
34+
TimezoneAwareProcessor,
35+
MetadataTimezoneAwareProcessor,
36+
)
3037

3138
IsTerminating = Callable[[], bool]
3239

33-
# Note: This function will be called in a different transaction to where we
34-
# iterate over the results, so to prevent confusion or the possible
35-
# introduction of errors, we require all the data up-front.
36-
LabelProvider = Callable[[App, StateMachine, State], List[str]]
37-
3840

3941
class CronProcessor(Protocol):
4042
"""Type signature for the cron processor callable."""
@@ -118,14 +120,30 @@ def _configure_schedule_for_state(
118120
)
119121
elif isinstance(state, Gate):
120122
for trigger in state.triggers:
121-
if isinstance(trigger, TimeTrigger):
123+
if isinstance(trigger, SystemTimeTrigger):
122124
scheduler.every().day.at(
123125
f"{trigger.time.hour:02d}:{trigger.time.minute:02d}",
124126
).do(
125127
processor,
126128
fn=process_gate,
127129
label_provider=labels_in_state,
128130
)
131+
elif isinstance(trigger, TimezoneAwareTrigger):
132+
func = functools.partial(
133+
processor,
134+
fn=process_gate,
135+
label_provider=labels_in_state,
136+
)
137+
scheduler.every().minute.do(
138+
TimezoneAwareProcessor(func, trigger),
139+
)
140+
elif isinstance(trigger, MetadataTimezoneAwareTrigger):
141+
scheduler.every().minute.do(
142+
MetadataTimezoneAwareProcessor(
143+
functools.partial(processor, fn=process_gate),
144+
trigger,
145+
),
146+
)
129147
elif isinstance(trigger, IntervalTrigger):
130148
scheduler.every(
131149
trigger.interval.total_seconds(),

0 commit comments

Comments
 (0)