From e4b0b4ee55ba593a40a27ae60788dfa3e864bf85 Mon Sep 17 00:00:00 2001 From: Andreas Motl Date: Fri, 19 Nov 2021 23:35:19 +0100 Subject: [PATCH] Completely rework the sequencing machinery Features: - Allow relative timestamps / time ranges also for ``start`` and ``stop`` parameters. Plumbing: - Large refactoring. All utility functions related to times and time rangers are now located in the `timeutil.py` and `model.py` files. - Add new model `AnimationFrame` - Fix frame file sort order: Add `index` to each filename in order to reflect the position of the `AnimationSequence` in a list of many. - Add "--dry-run" parameter - Add a few more software tests --- CHANGES.rst | 3 + doc/backlog.rst | 18 ++- grafanimate/animations.py | 183 ++++------------------------- grafanimate/commands.py | 9 +- grafanimate/core.py | 12 +- grafanimate/grafana.py | 24 ++-- grafanimate/marionette.py | 4 +- grafanimate/model.py | 111 +++++++++++++++--- grafanimate/postprocessing.py | 2 +- grafanimate/scenarios.py | 20 +++- grafanimate/spool.py | 11 +- grafanimate/timecontrol.py | 2 +- grafanimate/timeutil.py | 211 ++++++++++++++++++++++++++++++++++ grafanimate/util.py | 44 +------ setup.py | 2 + tests/test_animations.py | 131 --------------------- tests/test_model.py | 180 +++++++++++++++++++++++++++++ tests/test_timeutil.py | 127 ++++++++++++++++++++ 18 files changed, 709 insertions(+), 385 deletions(-) create mode 100644 grafanimate/timeutil.py delete mode 100644 tests/test_animations.py create mode 100644 tests/test_model.py create mode 100644 tests/test_timeutil.py diff --git a/CHANGES.rst b/CHANGES.rst index b86a5c6..83c417b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,6 +7,9 @@ in progress =========== - Improve scope of values for ``every`` parameter. It will now accept relative humanized timestamps like ``2m30s``, ``1d12h`` or ``1.5 days``. +- Allow relative timestamps / time ranges also for ``start`` and ``stop`` + parameters. Accepted are humanized values like outlined above (``2m30s``), + combined with, e.g., ``stop=start+2m30s`` or ``start=-1h, stop=now``. 2021-11-17 0.6.0 diff --git a/doc/backlog.rst b/doc/backlog.rst index b2d21bf..f8a305f 100644 --- a/doc/backlog.rst +++ b/doc/backlog.rst @@ -8,14 +8,12 @@ Prio 1.25 ********* - [x] Rename ``AnimationScenario.steps`` to ``AnimationScenario.sequences`` - [x] Rename ``dtstart``/``dtuntil`` to ``start``/``stop`` and ``interval`` to ``every`` -- [o] Allow relative time range for ``stop`` parameter - > The every parameter supports all valid duration units, including calendar months (1mo) and years (1y). +- [x] Allow relative time range for ``stop`` parameter + > The ``every`` parameter supports all valid duration units, including calendar months (1mo) and years (1y). > -- https://docs.influxdata.com/flux/v0.x/spec/types/#duration-types -- [o] Mix relative with absolute timestamps: ``range(start:2019-01-31T23:00:00Z, stop:-1h)`` +- [x] Mix relative with absolute timestamps: ``range(start:2019-01-31T23:00:00Z, stop:-1h)`` -- https://community.hiveeyes.org/t/improving-time-range-control-for-grafanimate/1783/11 - [o] README: Drop some words about "Animation Speed" (--framerate) -- [o] Specify number of frames to capture, instead of --stop -- [o] Add ``--reverse`` parameter - [o] Implement "ad-hoc" mode Until implemented, please use scenario mode. @@ -29,11 +27,19 @@ Prio 1.25 -- https://community.hiveeyes.org/t/improving-time-range-control-for-grafanimate/1783/13 - [o] Panel spinner is visible again -- [o] Avoid collisions in output directory, e.g. take sequencing mode into account +- [o] Avoid collisions in output directory, e.g. take sequencing mode and start/stop timestamps into account - [o] Final tests - [o] Release 0.7.0 +******** +Prio 1.3 +******** +- [o] Support for months and years: Follow up on https://github.com/onegreyonewhite/pytimeparse2/pull/1 +- [o] Specify number of frames to capture, instead of --stop +- [o] Add ``--reverse`` parameter; hm, or use negative ``stop`` parameter instead + + ******** Prio 1.5 ******** diff --git a/grafanimate/animations.py b/grafanimate/animations.py index 8bd0c3f..1daf37e 100644 --- a/grafanimate/animations.py +++ b/grafanimate/animations.py @@ -1,39 +1,24 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- # (c) 2018-2021 Andreas Motl # License: GNU Affero General Public License, Version 3 import logging import time -from datetime import timedelta -from operator import attrgetter -from typing import Tuple - -from dateutil.relativedelta import relativedelta -from dateutil.rrule import ( - DAILY, - HOURLY, - MINUTELY, - MONTHLY, - SECONDLY, - WEEKLY, - YEARLY, - rrule, -) -from munch import munchify -from pytimeparse2 import parse as parse_human_time + +from munch import Munch, munchify from grafanimate.grafana import GrafanaWrapper -from grafanimate.model import AnimationSequence, SequencingMode -from grafanimate.util import get_relativedelta +from grafanimate.model import AnimationFrame, AnimationSequence logger = logging.getLogger(__name__) class SequentialAnimation: - def __init__(self, grafana: GrafanaWrapper, dashboard_uid=None, options=None): + def __init__(self, grafana: GrafanaWrapper, dashboard_uid: str = None, options: Munch = None): self.grafana = grafana self.dashboard_uid = dashboard_uid self.options = options or None + self.dry_run: bool = self.options.get("dry-run", False) def start(self): self.log("Opening dashboard") @@ -46,43 +31,18 @@ def log(self, message): def run(self, sequence: AnimationSequence): - self.log("Starting animation: {}".format(sequence)) - - # Destructure `AnimationSequence` instance. - start, stop, every, mode = attrgetter("start", "stop", "every", "mode")(sequence) - - if start > stop: - message = "Timestamp start={} is after stop={}".format(start, stop) - raise ValueError(message) - - rr_freq, rr_interval, dtdelta = self.get_freq_delta(every) - - # until = datetime.now() - if mode == SequencingMode.CUMULATIVE: - stop += dtdelta - - # Compute complete date range. - logger.info("Creating rrule: dtstart=%s, until=%s, freq=%s, interval=%s", start, stop, rr_freq, rr_interval) - daterange = list(rrule(dtstart=start, until=stop, freq=rr_freq, interval=rr_interval)) - # logger.info('Date range is: %s', daterange) + if not isinstance(sequence, AnimationSequence): + return - # Iterate date range. - for date in daterange: - - logger.info("=" * 42) - logger.info("Datetime step: %s", date) - - # Compute start and end dates based on mode. + self.log("Starting animation: {}".format(sequence)) - if mode == SequencingMode.WINDOW: - start = date - stop = date + dtdelta + frame: AnimationFrame = None + for frame in sequence.get_frames(): - elif mode == SequencingMode.CUMULATIVE: - stop = date + # logger.info("=" * 42) # Render image. - image = self.render(start, stop, every) + image = self.render(frame) # Build item model. item = munchify( @@ -91,130 +51,33 @@ def run(self, sequence: AnimationSequence): "grafana": self.grafana, "scenario": self.options["scenario"], "dashboard": self.dashboard_uid, - "every": every, + "every": frame.timerange.recurrence.every, }, "data": { - "start": start, - "stop": stop, + "start": frame.timerange.start, + "stop": frame.timerange.stop, "image": image, }, + "frame": frame, } ) - yield item - if self.options["exposure-time"] > 0: logger.info("Waiting for {} seconds (exposure time)".format(self.options["exposure-time"])) time.sleep(self.options["exposure-time"]) + yield item + self.log("Animation finished") - @staticmethod - def get_freq_delta(interval: str) -> Tuple[int, int, timedelta]: - - rr_freq = MINUTELY - rr_interval = 1 - - # 1. Attempt to parse time using `pytimeparse` module. - # https://pypi.org/project/pytimeparse/ - duration = parse_human_time(interval) - if duration: - delta = get_relativedelta(seconds=duration) - - if delta.years: - rr_freq = YEARLY - rr_interval = delta.years - elif delta.months: - rr_freq = MONTHLY - rr_interval = delta.months - elif delta.days: - rr_freq = DAILY - rr_interval = delta.days - elif delta.hours: - rr_freq = HOURLY - rr_interval = delta.hours - elif delta.minutes: - rr_freq = MINUTELY - rr_interval = delta.minutes - else: - rr_freq = SECONDLY - rr_interval = delta.seconds - - if rr_freq != SECONDLY: - delta -= relativedelta(seconds=1) - - return rr_freq, rr_interval, delta - - # 2. Compute parameters from specific labels, expression periods. - - # Secondly - if interval == "secondly": - rr_freq = SECONDLY - delta = timedelta(seconds=1) - - # Minutely - elif interval == "minutely": - rr_freq = MINUTELY - delta = timedelta(minutes=1) - timedelta(seconds=1) - - # Each 5 minutes - elif interval == "5min": - rr_freq = MINUTELY - rr_interval = 5 - delta = timedelta(minutes=5) - timedelta(seconds=1) - - # Each 10 minutes - elif interval == "10min": - rr_freq = MINUTELY - rr_interval = 10 - delta = timedelta(minutes=10) - timedelta(seconds=1) - - # Each 30 minutes - elif interval == "30min": - rr_freq = MINUTELY - rr_interval = 30 - delta = timedelta(minutes=30) - timedelta(seconds=1) - - # Hourly - elif interval == "hourly": - rr_freq = HOURLY - delta = timedelta(hours=1) - timedelta(seconds=1) - - # Daily - elif interval == "daily": - rr_freq = DAILY - delta = timedelta(days=1) - timedelta(seconds=1) - - # Weekly - elif interval == "weekly": - rr_freq = WEEKLY - delta = timedelta(weeks=1) - timedelta(seconds=1) - - # Monthly - elif interval == "monthly": - rr_freq = MONTHLY - delta = relativedelta(months=+1) - relativedelta(seconds=1) - - # Yearly - elif interval == "yearly": - rr_freq = YEARLY - delta = relativedelta(years=+1) - relativedelta(seconds=1) - - else: - raise ValueError('Unknown interval "{}"'.format(interval)) - - if isinstance(delta, timedelta): - delta = get_relativedelta(seconds=delta.total_seconds()) - - return rr_freq, rr_interval, delta - - def render(self, start, stop, every): + def render(self, frame: AnimationFrame): logger.debug("Adjusting time range control") - self.grafana.timewarp(start, stop, every) + self.grafana.timewarp(frame, self.dry_run) logger.debug("Rendering image") - return self.make_image() + if not self.dry_run: + return self.make_image() def make_image(self): image = self.grafana.render_image() diff --git a/grafanimate/commands.py b/grafanimate/commands.py index 910b0cb..7c38703 100644 --- a/grafanimate/commands.py +++ b/grafanimate/commands.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# (c) 2018 Andreas Motl +# (c) 2018-2021 Andreas Motl # License: GNU Affero General Public License, Version 3 import json import logging @@ -91,7 +91,7 @@ def run(): FFmpeg's `-filter_complex` options. [default: 10] --gif-width= Width of the gif in pixels. [default: 480] - + --dry-run Enable dry-run mode --debug Enable debug logging -h --help Show this screen @@ -179,5 +179,6 @@ def run(): # Run rendering sequences, produce composite media artifacts. scenario.dashboard_title = grafana.get_dashboard_title() - results = produce_artifacts(input=storage.workdir, output=output, scenario=scenario, options=render_options) - log.info("Produced %s results\n%s", len(results), json.dumps(results, indent=2)) + if not options.dry_run: + results = produce_artifacts(input=storage.workdir, output=output, scenario=scenario, options=render_options) + log.info("Produced %s results\n%s", len(results), json.dumps(results, indent=2)) diff --git a/grafanimate/core.py b/grafanimate/core.py index 96f4df2..15b4fce 100644 --- a/grafanimate/core.py +++ b/grafanimate/core.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# (c) 2018-2021 Andreas Motl +# (c) 2018-2021 Andreas Motl # License: GNU Affero General Public License, Version 3 import logging from pathlib import Path @@ -82,7 +82,7 @@ def resolve_reference(module, symbol): def run_animation_scenario(scenario: AnimationScenario, grafana: GrafanaWrapper, options: Munch) -> TemporaryStorage: - log.info("Running animation scenario {}".format(scenario)) + log.info(f"Running animation scenario at {scenario.grafana_url}, with dashboard UID {scenario.dashboard_uid}") storage = TemporaryStorage() @@ -105,9 +105,11 @@ def run_animation_scenario(scenario: AnimationScenario, grafana: GrafanaWrapper, animation.start() # Run animation scenario. - for sequence in scenario.sequences: - results = animation.run(sequence) - storage.save_items(results) + for index, sequence in enumerate(scenario.sequences): + sequence.index = index + results = list(animation.run(sequence)) + if not options.dry_run: + storage.save_items(results) return storage diff --git a/grafanimate/grafana.py b/grafanimate/grafana.py index 5017d98..df4a457 100644 --- a/grafanimate/grafana.py +++ b/grafanimate/grafana.py @@ -1,5 +1,5 @@ -# -*- coding: utf-8 -*- -# (c) 2018 Andreas Motl +# -*- coding: utf-8 -*- +# (c) 2018-2021 Andreas Motl # License: GNU Affero General Public License, Version 3 import json import logging @@ -10,7 +10,8 @@ from pkg_resources import resource_stream from grafanimate.marionette import FirefoxMarionetteBase -from grafanimate.util import format_date_grafana +from grafanimate.model import AnimationFrame +from grafanimate.timeutil import format_date_grafana log = logging.getLogger(__name__) @@ -20,9 +21,10 @@ class GrafanaWrapper(FirefoxMarionetteBase): https://marionette-client.readthedocs.io/en/master/interactive.html """ - def __init__(self, baseurl=None, use_panel_events=False): + def __init__(self, baseurl: str = None, use_panel_events: bool = False, dry_run: bool = False): self.baseurl = baseurl self.use_panel_events = use_panel_events + self.dry_run = dry_run log.info("Starting GrafanaWrapper on %s", baseurl) FirefoxMarionetteBase.__init__(self) @@ -117,21 +119,25 @@ def condition(marionette): def clear_all_data_received(self): return self.calljs("grafanaStudio.hasAllData", False) - def timewarp(self, start, stop, every): + def timewarp(self, frame: AnimationFrame, dry_run: bool = False): """ Navigate the Dashboard to the designated point in time and wait for refreshing all child components including data. """ # Notify user. - message = "Timewarp to {} -> {}".format(start, stop) + message = "Timewarp to {} -> {}".format(frame.timerange.start, frame.timerange.stop) log.info(message) self.console_log(message) # Perform timewarp. - self.clear_all_data_received() - self.timerange_set(format_date_grafana(start, every), format_date_grafana(stop, every)) - self.wait_for_frame_finished() + if not dry_run: + self.clear_all_data_received() + self.timerange_set( + format_date_grafana(frame.timerange.start, frame.timerange.recurrence), + format_date_grafana(frame.timerange.stop, frame.timerange.recurrence), + ) + self.wait_for_frame_finished() def timerange_set(self, starttime, endtime): """ diff --git a/grafanimate/marionette.py b/grafanimate/marionette.py index 9bc44f2..6a418f4 100644 --- a/grafanimate/marionette.py +++ b/grafanimate/marionette.py @@ -1,5 +1,5 @@ -# -*- coding: utf-8 -*- -# (c) 2018 Andreas Motl +# -*- coding: utf-8 -*- +# (c) 2018-2021 Andreas Motl # License: GNU Affero General Public License, Version 3 import atexit import json diff --git a/grafanimate/model.py b/grafanimate/model.py index bddf4e2..87fba06 100644 --- a/grafanimate/model.py +++ b/grafanimate/model.py @@ -1,13 +1,24 @@ # -*- coding: utf-8 -*- -# (c) 2021 Andreas Motl +# (c) 2018-2021 Andreas Motl # License: GNU Affero General Public License, Version 3 import dataclasses +import logging from datetime import datetime from enum import Enum -from typing import List, Optional, Union +from typing import Generator, List, Optional, Union -import dateutil.parser +import pytz from dataclass_property import dataclass +from dateutil.rrule import rrule + +from grafanimate.timeutil import ( + RecurrenceInfo, + Timerange, + convert_input_timestamp, + get_freq_delta, +) + +logger = logging.getLogger(__name__) class SequencingMode(Enum): @@ -15,11 +26,38 @@ class SequencingMode(Enum): CUMULATIVE = "cumulative" +@dataclasses.dataclass +class AnimationFrame: + sequence: "AnimationSequence" + timerange: Timerange + + @dataclass class AnimationSequence: every: str + index: Optional[int] = 0 mode: Optional[SequencingMode] = SequencingMode.WINDOW - milliseconds: int = 0 + recurrence: Optional[RecurrenceInfo] = None + + def __post_init__(self): + + # Convert start/stop timestamps, resolving relative timestamps. + now = datetime.now(tz=pytz.UTC) + self._start = convert_input_timestamp(self.__start, relative_to=now) + if isinstance(self.__stop, str) and self.__stop.startswith("start"): + stop = self.__stop.replace("start", "") + self._stop = convert_input_timestamp(stop, relative_to=self._start) + else: + self._stop = convert_input_timestamp(self.__stop, relative_to=now) + + # Sanity checks. + if self._start > self._stop: + message = "Timestamp start={} is after stop={}".format(self._start, self._stop) + raise ValueError(message) + + # Analyze `every` parameter and converge into `RecurrenceInfo`. + # From `every` (interval designator), compute frequency, interval and delta. + self.recurrence = get_freq_delta(self.every) @property def start(self) -> datetime: @@ -31,22 +69,61 @@ def stop(self) -> datetime: @start.setter def start(self, value: Union[datetime, str]): - self._start = self.convert_timestamp(value) + self.__start = value @stop.setter def stop(self, value: Union[datetime, str]): - self._stop = self.convert_timestamp(value) - - def convert_timestamp(self, value: Union[datetime, str]) -> datetime: - if isinstance(value, datetime): - pass - elif isinstance(value, int): - value = datetime.fromtimestamp(value) - elif isinstance(value, str): - value = dateutil.parser.parse(value) - else: - raise TypeError("Unknown data type for `start` or `stop` value: {} ({})".format(value, type(value))) - return value + self.__stop = value + + def get_frames(self) -> Generator[AnimationFrame, None, None]: + + timerange = Timerange(start=self.start, stop=self.stop, recurrence=self.recurrence) + + # until = datetime.now() + if self.mode == SequencingMode.CUMULATIVE: + timerange.stop += self.recurrence.duration + + # Compute complete date range. + logger.info( + "Creating rrule: dtstart=%s, until=%s, freq=%s, interval=%s", + timerange.start, + timerange.stop, + self.recurrence.frequency, + self.recurrence.interval, + ) + daterange = list( + rrule( + dtstart=timerange.start, + until=timerange.stop, + freq=self.recurrence.frequency, + interval=self.recurrence.interval, + ) + ) + # logger.info('Date range is: %s', daterange) + + # Iterate date range. + for date in daterange: + + # Compute start and end dates based on mode. + + if self.mode == SequencingMode.WINDOW: + start = date + stop = date + self.recurrence.duration + + elif self.mode == SequencingMode.CUMULATIVE: + start = timerange.start + stop = date + + frame = AnimationFrame( + sequence=self, timerange=Timerange(start=start, stop=stop, recurrence=self.recurrence) + ) + yield frame + + def get_timeranges_isoformat(self) -> Generator[str, None, None]: + for frame in self.get_frames(): + item = f"{frame.timerange.start.isoformat()}/{frame.timerange.stop.isoformat()}" + # print(f'"{item}",') + yield item @dataclasses.dataclass diff --git a/grafanimate/postprocessing.py b/grafanimate/postprocessing.py index fa71c7b..b6dd221 100644 --- a/grafanimate/postprocessing.py +++ b/grafanimate/postprocessing.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- # (c) 2018-2021 Andreas Motl # License: GNU Affero General Public License, Version 3 import logging diff --git a/grafanimate/scenarios.py b/grafanimate/scenarios.py index d4efe5d..e8e4b7e 100644 --- a/grafanimate/scenarios.py +++ b/grafanimate/scenarios.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- # (c) 2018-2021 Andreas Motl # License: GNU Affero General Public License, Version 3 """ @@ -90,6 +90,24 @@ def playdemo_advanced(): every="4m5s", mode=SequencingMode.CUMULATIVE, ), + AnimationSequence( + start="-30m", + stop="+30m", + every="5m", + mode=SequencingMode.CUMULATIVE, + ), + AnimationSequence( + start="-14d", + stop="start+7d", + every="1d", + mode=SequencingMode.CUMULATIVE, + ), + AnimationSequence( + start="-14d", + stop="now", + every="1d", + mode=SequencingMode.CUMULATIVE, + ), ], ) diff --git a/grafanimate/spool.py b/grafanimate/spool.py index 182639c..7430e04 100644 --- a/grafanimate/spool.py +++ b/grafanimate/spool.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# (c) 2018-2021 Andreas Motl +# (c) 2018-2021 Andreas Motl # License: GNU Affero General Public License, Version 3 import logging import os.path @@ -7,7 +7,7 @@ from tempfile import mkdtemp from typing import List -from grafanimate.util import format_date_human +from grafanimate.timeutil import format_date_filename logger = logging.getLogger(__name__) @@ -15,7 +15,7 @@ class TemporaryStorage: def __init__(self): self.workdir = mkdtemp() - self.imagefile_template = "{uid}_{start}_{stop}.png" + self.imagefile_template = "{uid}_{seq}_{start}_{stop}.png" def save_items(self, results) -> List[str]: files = [] @@ -32,8 +32,9 @@ def save_item(self, item) -> str: # Compute image sequence file name. imagename = self.imagefile_template.format( uid=item.meta.dashboard, - start=format_date_human(item.data.start), - stop=format_date_human(item.data.stop), + seq=str(item.frame.sequence.index).zfill(4), + start=format_date_filename(item.data.start), + stop=format_date_filename(item.data.stop), ) imagefile = os.path.join(self.workdir, imagename) diff --git a/grafanimate/timecontrol.py b/grafanimate/timecontrol.py index af0f16a..c95b607 100644 --- a/grafanimate/timecontrol.py +++ b/grafanimate/timecontrol.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- # (c) 2019 Andreas Motl # License: GNU Affero General Public License, Version 3 from datetime import datetime, timedelta diff --git a/grafanimate/timeutil.py b/grafanimate/timeutil.py new file mode 100644 index 0000000..d78bb02 --- /dev/null +++ b/grafanimate/timeutil.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- +# (c) 2018-2021 Andreas Motl +# License: GNU Affero General Public License, Version 3 +import dataclasses +from datetime import datetime, timedelta +from typing import Optional, Union + +import dateutil.parser +import pytz +from dateutil.relativedelta import relativedelta +from dateutil.rrule import DAILY, HOURLY, MINUTELY, MONTHLY, SECONDLY, WEEKLY, YEARLY +from pytimeparse2 import parse as parse_human_time + + +@dataclasses.dataclass +class RecurrenceInfo: + """ + For feeding data to `dateutil.rrule.rrule`. + """ + + # Original interval/windowing label. + every: str + + # One of `rrules`s DAILY, HOURLY, ... + frequency: int + + # The segment duration in seconds. + interval: int + + # The segment duration, expressed as `relativedelta`. + duration: relativedelta + + +@dataclasses.dataclass +class Timerange: + start: datetime + stop: datetime + recurrence: RecurrenceInfo + + +def get_freq_delta(every: str) -> RecurrenceInfo: + rr_freq = MINUTELY + rr_interval = 1 + + # 1. Attempt to parse time using `pytimeparse` module. + # https://pypi.org/project/pytimeparse/ + duration = parse_human_time(every) + if duration: + delta = get_relativedelta(seconds=duration) + + if delta.years: + rr_freq = YEARLY + rr_interval = delta.years + elif delta.months: + rr_freq = MONTHLY + rr_interval = delta.months + elif delta.days: + rr_freq = DAILY + rr_interval = delta.days + elif delta.hours: + rr_freq = HOURLY + rr_interval = delta.hours + elif delta.minutes: + rr_freq = MINUTELY + rr_interval = delta.minutes + else: + rr_freq = SECONDLY + rr_interval = delta.seconds + + if rr_freq != SECONDLY: + delta -= relativedelta(seconds=1) + + return RecurrenceInfo(every=every, frequency=rr_freq, interval=rr_interval, duration=delta) + + # 2. Compute parameters from specific labels, expression periods. + + # Secondly + if every == "secondly": + rr_freq = SECONDLY + delta = timedelta(seconds=1) + + # Minutely + elif every == "minutely": + rr_freq = MINUTELY + delta = timedelta(minutes=1) - timedelta(seconds=1) + + # Each 5 minutes + elif every == "5min": + rr_freq = MINUTELY + rr_interval = 5 + delta = timedelta(minutes=5) - timedelta(seconds=1) + + # Each 10 minutes + elif every == "10min": + rr_freq = MINUTELY + rr_interval = 10 + delta = timedelta(minutes=10) - timedelta(seconds=1) + + # Each 30 minutes + elif every == "30min": + rr_freq = MINUTELY + rr_interval = 30 + delta = timedelta(minutes=30) - timedelta(seconds=1) + + # Hourly + elif every == "hourly": + rr_freq = HOURLY + delta = timedelta(hours=1) - timedelta(seconds=1) + + # Daily + elif every == "daily": + rr_freq = DAILY + delta = timedelta(days=1) - timedelta(seconds=1) + + # Weekly + elif every == "weekly": + rr_freq = WEEKLY + delta = timedelta(weeks=1) - timedelta(seconds=1) + + # Monthly + elif every == "monthly": + rr_freq = MONTHLY + delta = relativedelta(months=+1) - relativedelta(seconds=1) + + # Yearly + elif every == "yearly": + rr_freq = YEARLY + delta = relativedelta(years=+1) - relativedelta(seconds=1) + + else: + raise ValueError('Unknown interval "{}"'.format(every)) + + if isinstance(delta, timedelta): + delta = get_relativedelta(seconds=delta.total_seconds()) + + return RecurrenceInfo(every=every, frequency=rr_freq, interval=rr_interval, duration=delta) + + +def get_relativedelta(seconds: int): + # TODO: Add to `pytimeparse2`? + # https://stackoverflow.com/questions/16977768/elegant-way-to-convert-python-datetime-timedelta-to-dateutil-relativedelta + + seconds_in = { + "year": 365 * 24 * 60 * 60, + "month": 30 * 24 * 60 * 60, + "day": 24 * 60 * 60, + "hour": 60 * 60, + "minute": 60, + } + + years, rem = divmod(seconds, seconds_in["year"]) + months, rem = divmod(rem, seconds_in["month"]) + days, rem = divmod(rem, seconds_in["day"]) + hours, rem = divmod(rem, seconds_in["hour"]) + minutes, rem = divmod(rem, seconds_in["minute"]) + seconds = rem + + return relativedelta( + years=years, months=months, days=days, hours=hours, minutes=minutes, seconds=seconds + ).normalized() + + +def format_date_filename(date, every=None): + # pattern = '%Y-%m-%d' + pattern = "%Y-%m-%dT%H-%M-%S" + # if every in ['secondly', 'minutely', 'hourly']: + # pattern = '%Y-%m-%dT%H-%M-%S' + date_formatted = date.strftime(pattern) + return date_formatted + + +def format_date_grafana(date: datetime, recurrence: RecurrenceInfo): + pattern = "%Y-%m-%d" + if recurrence.frequency in [SECONDLY, MINUTELY, HOURLY]: + pattern = "%Y-%m-%dT%H:%M:%S" + date_formatted = date.strftime(pattern) + return date_formatted + + +def convert_absolute_timestamp(value: Union[datetime, str]) -> datetime: + """ + Read and convert absolute timestamps. + """ + if isinstance(value, datetime): + pass + elif isinstance(value, int): + value = datetime.fromtimestamp(value) + elif isinstance(value, str): + value = dateutil.parser.parse(value) + else: + raise TypeError("Unknown data type for `start` or `stop` value: {} ({})".format(value, type(value))) + return value + + +def convert_input_timestamp(value: Union[datetime, str], relative_to: Optional[datetime] = None) -> datetime: + """ + Read and convert absolute or relative (humanized) timestamps. + """ + if isinstance(value, str): + if value == "now": + return datetime.now(tz=pytz.UTC) + try: + delta = parse_human_time(value) + if not delta: + raise ValueError(f"Unable to parse {value}") + return relative_to + timedelta(seconds=delta) + except ValueError as ex: + if "Unable to parse" not in str(ex): + raise + + return convert_absolute_timestamp(value) diff --git a/grafanimate/util.py b/grafanimate/util.py index 8f1d08f..3e1d075 100644 --- a/grafanimate/util.py +++ b/grafanimate/util.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- # (c) 2018-2021 Andreas Motl # License: GNU Affero General Public License, Version 3 import logging @@ -9,7 +9,6 @@ from contextlib import closing from pathlib import Path -from dateutil.relativedelta import relativedelta from munch import munchify from unidecode import unidecode @@ -59,23 +58,6 @@ def check_socket(host, port): return False -def format_date_human(date, every=None): - # pattern = '%Y-%m-%d' - pattern = "%Y-%m-%dT%H-%M-%S" - # if every in ['secondly', 'minutely', 'hourly']: - # pattern = '%Y-%m-%dT%H-%M-%S' - date_formatted = date.strftime(pattern) - return date_formatted - - -def format_date_grafana(date, every=None): - pattern = "%Y-%m-%d" - if every in ["secondly", "minutely", "hourly"] or every.endswith("min"): - pattern = "%Y-%m-%dT%H:%M:%S" - date_formatted = date.strftime(pattern) - return date_formatted - - def filter_dict(data, keys): return {k: v for k, v in data.items() if k in keys} @@ -157,27 +139,3 @@ def import_module(name: str, path: str): spec.loader.exec_module(mod) return mod - - -def get_relativedelta(seconds: int): - # TODO: Add to `pytimeparse2`? - # https://stackoverflow.com/questions/16977768/elegant-way-to-convert-python-datetime-timedelta-to-dateutil-relativedelta - - seconds_in = { - "year": 365 * 24 * 60 * 60, - "month": 30 * 24 * 60 * 60, - "day": 24 * 60 * 60, - "hour": 60 * 60, - "minute": 60, - } - - years, rem = divmod(seconds, seconds_in["year"]) - months, rem = divmod(rem, seconds_in["month"]) - days, rem = divmod(rem, seconds_in["day"]) - hours, rem = divmod(rem, seconds_in["hour"]) - minutes, rem = divmod(rem, seconds_in["minute"]) - seconds = rem - - return relativedelta( - years=years, months=months, days=days, hours=hours, minutes=minutes, seconds=seconds - ).normalized() diff --git a/setup.py b/setup.py index af1b070..21cfc05 100644 --- a/setup.py +++ b/setup.py @@ -20,11 +20,13 @@ "python-dateutil>=2.7,<3", "datetime-interval==0.2", "pytimeparse2>=1.3,<2", + "pytz>=2021.3", ] extras = { "test": [ "pytest>=6,<7", + "freezegun>=1,<2", ], } diff --git a/tests/test_animations.py b/tests/test_animations.py deleted file mode 100644 index d2438dc..0000000 --- a/tests/test_animations.py +++ /dev/null @@ -1,131 +0,0 @@ -from dateutil.relativedelta import relativedelta -from dateutil.rrule import DAILY, HOURLY, MINUTELY, MONTHLY, SECONDLY, WEEKLY, YEARLY - -from grafanimate.animations import SequentialAnimation - - -def test_freq_delta_legacy(): - - get_freq_delta = SequentialAnimation.get_freq_delta - - freq, interval, delta = get_freq_delta("secondly") - assert freq == SECONDLY - assert interval == 1 - assert delta == relativedelta(seconds=+1) - - freq, interval, delta = get_freq_delta("minutely") - assert freq == MINUTELY - assert interval == 1 - assert delta == relativedelta(seconds=+59) - - freq, interval, delta = get_freq_delta("5min") - assert freq == MINUTELY - assert interval == 5 - assert delta == relativedelta(minutes=+5, seconds=-1) - - freq, interval, delta = get_freq_delta("10min") - assert freq == MINUTELY - assert interval == 10 - assert delta == relativedelta(minutes=+10, seconds=-1) - - freq, interval, delta = get_freq_delta("30min") - assert freq == MINUTELY - assert interval == 30 - assert delta == relativedelta(minutes=+30, seconds=-1) - - freq, interval, delta = get_freq_delta("hourly") - assert freq == HOURLY - assert interval == 1 - assert delta == relativedelta(minutes=+59, seconds=+59) - - freq, interval, delta = get_freq_delta("daily") - assert freq == DAILY - assert interval == 1 - assert delta == relativedelta(hours=+23, minutes=+59, seconds=+59) - - freq, interval, delta = get_freq_delta("weekly") - assert freq == WEEKLY - assert interval == 1 - assert delta == relativedelta(days=+6, hours=+23, minutes=+59, seconds=+59) - - freq, interval, delta = get_freq_delta("monthly") - assert freq == MONTHLY - assert interval == 1 - assert delta == relativedelta(months=+1, seconds=-1) - - freq, interval, delta = get_freq_delta("yearly") - assert freq == YEARLY - assert interval == 1 - assert delta == relativedelta(years=+1, seconds=-1) - - -def test_freq_delta_pytimeparse(): - - get_freq_delta = SequentialAnimation.get_freq_delta - - freq, interval, delta = get_freq_delta("1s") - assert freq == SECONDLY - assert interval == 1 - assert delta == relativedelta(seconds=+1) - - freq, interval, delta = get_freq_delta("30s") - assert freq == SECONDLY - assert interval == 30 - assert delta == relativedelta(seconds=+30) - - freq, interval, delta = get_freq_delta("1m") - assert freq == MINUTELY - assert interval == 1 - assert delta == relativedelta(minutes=+1, seconds=-1) - - freq, interval, delta = get_freq_delta("2m30s") - assert freq == MINUTELY - assert interval == 2 - assert delta == relativedelta(minutes=+2, seconds=29) - - freq, interval, delta = get_freq_delta("30m") - assert freq == MINUTELY - assert interval == 30 - assert delta == relativedelta(minutes=+30, seconds=-1) - - freq, interval, delta = get_freq_delta("1h") - assert freq == HOURLY - assert interval == 1 - assert delta == relativedelta(hours=+1, seconds=-1) - - freq, interval, delta = get_freq_delta("12h") - assert freq == HOURLY - assert interval == 12 - assert delta == relativedelta(hours=+12, seconds=-1) - - freq, interval, delta = get_freq_delta("1d") - assert freq == DAILY - assert interval == 1 - assert delta == relativedelta(days=+1, seconds=-1) - - freq, interval, delta = get_freq_delta("1d12h") - assert freq == DAILY - assert interval == 1 - assert delta == relativedelta(days=+1, hours=+12, seconds=-1) - - freq, interval, delta = get_freq_delta("1.5 days") - assert freq == DAILY - assert interval == 1 - assert delta == relativedelta(days=+1, hours=+12, seconds=-1) - - freq, interval, delta = get_freq_delta("1w") - assert freq == DAILY - assert interval == 7 - assert delta == relativedelta(days=+7, seconds=-1) - - """ - freq, interval, delta = get_freq_delta("1mo") - assert freq == MONTHLY - assert interval == 1 - assert delta == relativedelta(months=+1, seconds=-1) - - freq, interval, delta = get_freq_delta("1y") - assert freq == YEARLY - assert interval == 1 - assert delta == relativedelta(years=+1, seconds=-1) - """ diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..3f82aab --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,180 @@ +from datetime import datetime + +from dateutil.relativedelta import relativedelta +from dateutil.rrule import DAILY, MINUTELY, SECONDLY +from dateutil.tz import tzutc +from freezegun import freeze_time + +from grafanimate.model import AnimationSequence, SequencingMode + + +def test_sequence_datetime(): + + seq = AnimationSequence( + start=datetime(2021, 11, 14, 2, 0, 0), + stop=datetime(2021, 11, 14, 2, 16, 36), + every="5min", + ) + + assert seq.start == datetime(2021, 11, 14, 2, 0, 0) + assert seq.stop == datetime(2021, 11, 14, 2, 16, 36) + assert seq.every == "5min" + assert seq.mode == SequencingMode.WINDOW + + assert seq.recurrence.every == "5min" + assert seq.recurrence.frequency == MINUTELY + assert seq.recurrence.interval == 5 + assert seq.recurrence.duration == relativedelta(minutes=+5, seconds=-1) + + assert list(seq.get_timeranges_isoformat()) == [ + "2021-11-14T02:00:00/2021-11-14T02:04:59", + "2021-11-14T02:05:00/2021-11-14T02:09:59", + "2021-11-14T02:10:00/2021-11-14T02:14:59", + "2021-11-14T02:15:00/2021-11-14T02:19:59", + ] + + +def test_sequence_isodate(): + seq = AnimationSequence( + start="2021-11-15T02:12:05Z", + stop="2021-11-15T02:37:36Z", + every="3min", + mode=SequencingMode.CUMULATIVE, + ) + + assert seq.start == datetime(2021, 11, 15, 2, 12, 5, tzinfo=tzutc()) + assert seq.stop == datetime(2021, 11, 15, 2, 37, 36, tzinfo=tzutc()) + assert seq.every == "3min" + assert seq.mode == SequencingMode.CUMULATIVE + + assert seq.recurrence.every == "3min" + assert seq.recurrence.frequency == MINUTELY + assert seq.recurrence.interval == 3 + assert seq.recurrence.duration == relativedelta(minutes=+3, seconds=-1) + + assert list(seq.get_timeranges_isoformat()) == [ + "2021-11-15T02:12:05+00:00/2021-11-15T02:12:05+00:00", + "2021-11-15T02:12:05+00:00/2021-11-15T02:15:05+00:00", + "2021-11-15T02:12:05+00:00/2021-11-15T02:18:05+00:00", + "2021-11-15T02:12:05+00:00/2021-11-15T02:21:05+00:00", + "2021-11-15T02:12:05+00:00/2021-11-15T02:24:05+00:00", + "2021-11-15T02:12:05+00:00/2021-11-15T02:27:05+00:00", + "2021-11-15T02:12:05+00:00/2021-11-15T02:30:05+00:00", + "2021-11-15T02:12:05+00:00/2021-11-15T02:33:05+00:00", + "2021-11-15T02:12:05+00:00/2021-11-15T02:36:05+00:00", + "2021-11-15T02:12:05+00:00/2021-11-15T02:39:05+00:00", + ] + + +def test_sequence_epoch(): + seq = AnimationSequence( + start=1637091011, + stop=1637091911, + every="4m5s", + mode=SequencingMode.CUMULATIVE, + ) + + assert seq.start == datetime(2021, 11, 16, 20, 30, 11) + assert seq.stop == datetime(2021, 11, 16, 20, 45, 11) + assert seq.every == "4m5s" + assert seq.mode == SequencingMode.CUMULATIVE + + assert seq.recurrence.every == "4m5s" + assert seq.recurrence.frequency == MINUTELY + assert seq.recurrence.interval == 4 + assert seq.recurrence.duration == relativedelta(minutes=+4, seconds=+4) + + assert list(seq.get_timeranges_isoformat()) == [ + "2021-11-16T20:30:11/2021-11-16T20:30:11", + "2021-11-16T20:30:11/2021-11-16T20:34:11", + "2021-11-16T20:30:11/2021-11-16T20:38:11", + "2021-11-16T20:30:11/2021-11-16T20:42:11", + "2021-11-16T20:30:11/2021-11-16T20:46:11", + ] + + +@freeze_time("2021-11-19T20:34:17Z") +def test_sequence_relative_to_now(): + seq = AnimationSequence( + start="-30m", + stop="+30m", + every="8m", + ) + + assert seq.start == datetime(2021, 11, 19, 20, 4, 17, tzinfo=tzutc()) + assert seq.stop == datetime(2021, 11, 19, 21, 4, 17, tzinfo=tzutc()) + assert seq.every == "8m" + + assert seq.recurrence.every == "8m" + assert seq.recurrence.frequency == MINUTELY + assert seq.recurrence.interval == 8 + assert seq.recurrence.duration == relativedelta(minutes=+8, seconds=-1) + + assert list(seq.get_timeranges_isoformat()) == [ + "2021-11-19T20:04:17+00:00/2021-11-19T20:12:16+00:00", + "2021-11-19T20:12:17+00:00/2021-11-19T20:20:16+00:00", + "2021-11-19T20:20:17+00:00/2021-11-19T20:28:16+00:00", + "2021-11-19T20:28:17+00:00/2021-11-19T20:36:16+00:00", + "2021-11-19T20:36:17+00:00/2021-11-19T20:44:16+00:00", + "2021-11-19T20:44:17+00:00/2021-11-19T20:52:16+00:00", + "2021-11-19T20:52:17+00:00/2021-11-19T21:00:16+00:00", + "2021-11-19T21:00:17+00:00/2021-11-19T21:08:16+00:00", + ] + + +@freeze_time("2021-11-19T20:34:17Z") +def test_sequence_relative_to_start(): + seq = AnimationSequence( + start="-14d", + stop="start+7d", + every="1d", + ) + + assert seq.start == datetime(2021, 11, 5, 20, 34, 17, tzinfo=tzutc()) + assert seq.stop == datetime(2021, 11, 12, 20, 34, 17, tzinfo=tzutc()) + assert seq.every == "1d" + + assert seq.recurrence.every == "1d" + assert seq.recurrence.frequency == DAILY + assert seq.recurrence.interval == 1 + assert seq.recurrence.duration == relativedelta(days=+1, seconds=-1) + + assert list(seq.get_timeranges_isoformat()) == [ + "2021-11-05T20:34:17+00:00/2021-11-06T20:34:16+00:00", + "2021-11-06T20:34:17+00:00/2021-11-07T20:34:16+00:00", + "2021-11-07T20:34:17+00:00/2021-11-08T20:34:16+00:00", + "2021-11-08T20:34:17+00:00/2021-11-09T20:34:16+00:00", + "2021-11-09T20:34:17+00:00/2021-11-10T20:34:16+00:00", + "2021-11-10T20:34:17+00:00/2021-11-11T20:34:16+00:00", + "2021-11-11T20:34:17+00:00/2021-11-12T20:34:16+00:00", + "2021-11-12T20:34:17+00:00/2021-11-13T20:34:16+00:00", + ] + + +@freeze_time("2021-11-19T20:34:17Z") +def test_sequence_relative_with_now(): + seq = AnimationSequence( + start="-7d", + stop="now", + every="1d", + ) + + assert seq.start == datetime(2021, 11, 12, 20, 34, 17, tzinfo=tzutc()) + assert seq.stop == datetime(2021, 11, 19, 20, 34, 17, tzinfo=tzutc()) + assert seq.every == "1d" + + assert seq.recurrence.every == "1d" + assert seq.recurrence.frequency == DAILY + assert seq.recurrence.interval == 1 + assert seq.recurrence.duration == relativedelta(days=+1, seconds=-1) + + assert list(seq.get_timeranges_isoformat()) == [ + "2021-11-12T20:34:17+00:00/2021-11-13T20:34:16+00:00", + "2021-11-13T20:34:17+00:00/2021-11-14T20:34:16+00:00", + "2021-11-14T20:34:17+00:00/2021-11-15T20:34:16+00:00", + "2021-11-15T20:34:17+00:00/2021-11-16T20:34:16+00:00", + "2021-11-16T20:34:17+00:00/2021-11-17T20:34:16+00:00", + "2021-11-17T20:34:17+00:00/2021-11-18T20:34:16+00:00", + "2021-11-18T20:34:17+00:00/2021-11-19T20:34:16+00:00", + "2021-11-19T20:34:17+00:00/2021-11-20T20:34:16+00:00", + ] diff --git a/tests/test_timeutil.py b/tests/test_timeutil.py new file mode 100644 index 0000000..c3c2278 --- /dev/null +++ b/tests/test_timeutil.py @@ -0,0 +1,127 @@ +from dateutil.relativedelta import relativedelta +from dateutil.rrule import DAILY, HOURLY, MINUTELY, MONTHLY, SECONDLY, WEEKLY, YEARLY + +from grafanimate.timeutil import get_freq_delta + + +def test_freq_delta_legacy(): + + recurrence = get_freq_delta("secondly") + assert recurrence.frequency == SECONDLY + assert recurrence.interval == 1 + assert recurrence.duration == relativedelta(seconds=+1) + + recurrence = get_freq_delta("minutely") + assert recurrence.frequency == MINUTELY + assert recurrence.interval == 1 + assert recurrence.duration == relativedelta(seconds=+59) + + recurrence = get_freq_delta("5min") + assert recurrence.frequency == MINUTELY + assert recurrence.interval == 5 + assert recurrence.duration == relativedelta(minutes=+5, seconds=-1) + + recurrence = get_freq_delta("10min") + assert recurrence.frequency == MINUTELY + assert recurrence.interval == 10 + assert recurrence.duration == relativedelta(minutes=+10, seconds=-1) + + recurrence = get_freq_delta("30min") + assert recurrence.frequency == MINUTELY + assert recurrence.interval == 30 + assert recurrence.duration == relativedelta(minutes=+30, seconds=-1) + + recurrence = get_freq_delta("hourly") + assert recurrence.frequency == HOURLY + assert recurrence.interval == 1 + assert recurrence.duration == relativedelta(minutes=+59, seconds=+59) + + recurrence = get_freq_delta("daily") + assert recurrence.frequency == DAILY + assert recurrence.interval == 1 + assert recurrence.duration == relativedelta(hours=+23, minutes=+59, seconds=+59) + + recurrence = get_freq_delta("weekly") + assert recurrence.frequency == WEEKLY + assert recurrence.interval == 1 + assert recurrence.duration == relativedelta(days=+6, hours=+23, minutes=+59, seconds=+59) + + recurrence = get_freq_delta("monthly") + assert recurrence.frequency == MONTHLY + assert recurrence.interval == 1 + assert recurrence.duration == relativedelta(months=+1, seconds=-1) + + recurrence = get_freq_delta("yearly") + assert recurrence.frequency == YEARLY + assert recurrence.interval == 1 + assert recurrence.duration == relativedelta(years=+1, seconds=-1) + + +def test_freq_delta_pytimeparse(): + + recurrence = get_freq_delta("1s") + assert recurrence.frequency == SECONDLY + assert recurrence.interval == 1 + assert recurrence.duration == relativedelta(seconds=+1) + + recurrence = get_freq_delta("30s") + assert recurrence.frequency == SECONDLY + assert recurrence.interval == 30 + assert recurrence.duration == relativedelta(seconds=+30) + + recurrence = get_freq_delta("1m") + assert recurrence.frequency == MINUTELY + assert recurrence.interval == 1 + assert recurrence.duration == relativedelta(minutes=+1, seconds=-1) + + recurrence = get_freq_delta("2m30s") + assert recurrence.frequency == MINUTELY + assert recurrence.interval == 2 + assert recurrence.duration == relativedelta(minutes=+2, seconds=29) + + recurrence = get_freq_delta("30m") + assert recurrence.frequency == MINUTELY + assert recurrence.interval == 30 + assert recurrence.duration == relativedelta(minutes=+30, seconds=-1) + + recurrence = get_freq_delta("1h") + assert recurrence.frequency == HOURLY + assert recurrence.interval == 1 + assert recurrence.duration == relativedelta(hours=+1, seconds=-1) + + recurrence = get_freq_delta("12h") + assert recurrence.frequency == HOURLY + assert recurrence.interval == 12 + assert recurrence.duration == relativedelta(hours=+12, seconds=-1) + + recurrence = get_freq_delta("1d") + assert recurrence.frequency == DAILY + assert recurrence.interval == 1 + assert recurrence.duration == relativedelta(days=+1, seconds=-1) + + recurrence = get_freq_delta("1d12h") + assert recurrence.frequency == DAILY + assert recurrence.interval == 1 + assert recurrence.duration == relativedelta(days=+1, hours=+12, seconds=-1) + + recurrence = get_freq_delta("1.5 days") + assert recurrence.frequency == DAILY + assert recurrence.interval == 1 + assert recurrence.duration == relativedelta(days=+1, hours=+12, seconds=-1) + + recurrence = get_freq_delta("1w") + assert recurrence.frequency == DAILY + assert recurrence.interval == 7 + assert recurrence.duration == relativedelta(days=+7, seconds=-1) + + """ + recurrence = get_freq_delta("1mo") + assert recurrence.frequency == MONTHLY + assert recurrence.interval == 1 + assert recurrence.duration == relativedelta(months=+1, seconds=-1) + + recurrence = get_freq_delta("1y") + assert recurrence.frequency == YEARLY + assert recurrence.interval == 1 + assert recurrence.duration == relativedelta(years=+1, seconds=-1) + """