""" Primitives for the Telisaran reckoning of dates and time. """ from abc import ABC, abstractmethod import inspect import sys import re class ReckoningError(Exception): pass class ParseError(ReckoningError): pass class InvalidEraError(ReckoningError): pass class InvalidYearError(ReckoningError): pass class InvalidSeasonError(ReckoningError): pass class InvalidSpanError(ReckoningError): pass class InvalidDayError(ReckoningError): pass class InvalidHourError(ReckoningError): pass class InvalidMinuteError(ReckoningError): pass class InvalidSecondError(ReckoningError): pass class InvalidDateError(ReckoningError): pass class MissingSeasonError(ReckoningError): pass def _suffix(day): n = int(str(day)[-1]) if n == 1: return 'st' elif n == 2: return 'nd' elif n == 3: return 'rd' else: return 'th' class DateObject(ABC): # pragma: no cover """ Base class for all date components. This ABC implements basic arithmetic operator support for all DateObjects as integer seconds since the beginning of time. If the current instance has a from_seconds() method, it will be called with the result of the calculation, otherwise the integer seconds will be returned. Subclasses must define the number, as_seconds and length_in_seconds attributes. Attribtues: number (int): The numeric index of the object in its parent group as_seconds (int): The component object expressed as seconds since the beginning of time. length_in_seconds (int): The number of seconds in a single object. """ @property @abstractmethod def number(self): pass @property def as_seconds(self): return (self.number - 1) * self.length_in_seconds def length_in_seconds(self): raise NotImplementedError("Please define the length_in_seconds class attribute.") def __str__(self): return str(self.number) def __repr__(self): return str(self) def __int__(self): return self.as_seconds def __eq__(self, other): try: return int(self) == int(other) except TypeError: return False def __gt__(self, other): return not self.__le__(other) def __lt__(self, other): return not self.__ge__(other) def __ge__(self, other): return int(self) >= int(other) def __le__(self, other): return int(self) <= int(other) def __ne__(self, other): return not self.__eq__(other) def __add__(self, other): val = int(self) + int(other) if hasattr(self.__class__, 'from_seconds'): return self.__class__.from_seconds(val) else: return val def __sub__(self, other): val = int(self) - int(other) if hasattr(self.__class__, 'from_seconds'): return self.__class__.from_seconds(val) else: return val class datetime(DateObject): """ A date and time on the Telisaran calendar. Attributes: era (Era): The era component of the date year (Year): The year component of the date season (Season): The season component of the date day (Day): The day component of the date hour (Hour): The hour component of the time minute (Minute): The minute component of the time second (int): The seconds component of the time long (str): The long representation of the date and time short (str): The short representation of the date and time numeric (str): The dotted numeric representation of the date and time numeric_date (str): The dotted numeric representation of the date (no time) date (str): The shorthand representation of the day and date time_long (str): The long form of the time, including names of hours time_short (str): The short form of the time time (str): Alias of time_short as_seconds (int): The date and time expressed in seconds since the beginning of time number (int): alias for as_seconds """ def __init__(self, era=1, year=1, season=1, day=1, hour=0, minute=0, second=0): """ Args: year (int): The year season (int): The season, 1-8 day (int): The day, 1-45 era (int): The era, 1-3 """ self.era = Era(era) self.year = Year(year, era=self.era) if season == Year.length_in_seasons + 1: self.season = FestivalOfTheHunt(year) else: self.season = Season(season_of_year=season, year=self.year.year) self.day = Day(day, season=self.season) self.hour = Hour(hour) self.minute = Minute(minute) if second < 0 or second > 59: raise InvalidSecondError("second {} must be between 0 and 59".format(second)) self.second = second @property def long(self): if self.season.number == 9: template = ("{time} on {day_name}, the {day}{day_suffix} day of the {season}, " "in the year {year} of the {era}") else: template = ("{time} on {day_name}, the {day}{day_suffix} day of the {season} " "(the {span_day}{span_day_suffix} day of the {span}{span_suffix} span) " "in the year {year} of the {era}") return template.format( time=self.time_long, day_name=self.day.name, day=self.day.day_of_season, day_suffix=_suffix(self.day.day_of_season), season=self.season, span_day=self.day.day_of_span, span_day_suffix=_suffix(self.day.day_of_span), span=self.day.span, span_suffix=_suffix(self.day.span), year=self.year, era=self.era.long ) @property def numeric(self): return ( "{0.era.number}.{0.year.number}.{0.season.number}." "{0.day.day_of_season:02d}." "{0.hour.number:02d}.{0.minute.number:02d}.{0.second:02d}" ).format(self) @property def numeric_date(self): return ( "{0.era.number}.{0.year.number}.{0.season.number}." "{0.day.day_of_season:02d}" ).format(self) @property def date_short(self): if self.season.number == 9: season = 'H' else: season = self.season.name[0].upper() return "{name}{date:02d}{season}".format( name=self.day.name[0].upper(), date=self.day.day_of_season, season=season, ) @property def time_long(self): return "{minute}{hour}".format( minute="{} past ".format(self.minute) if self.minute != 0 else '', hour=self.hour.name ) @property def time(self): return "{0.hour.number:02d}:{0.minute.number:02d}:{0.second:02d}".format(self) @property def time_short(self): return "{short} {time}".format( short=self.short, time=self.time ) @property def date(self): return self.short + ' ' + self.time_short @property def short(self): return "{name}, {day}{suffix} of the {season}, {year} {era}".format( name=self.day.name, day=self.day.day_of_season, suffix=_suffix(self.day.day_of_season), season=self.season.name, year=self.year, era=self.era.short, ) @property def as_seconds(self): return sum(map(int, [self.era, self.year, self.season, self.day, self.hour, self.minute, self.second])) @property def number(self): return self.as_seconds def __repr__(self): return ( ": {0.short}".format(self) ) @classmethod def from_expression(cls, expression, now=None, timeline=None): return parser(now=now, timeline=timeline).parse(expression) @classmethod def from_seconds(cls, seconds): """ Return a datetime object corresponding to the given number of seconds since the beginning. """ for (era, years) in enumerate(Era.years): if years is None: break era_length = years * Year.length_in_seconds if seconds > era_length: seconds -= era_length else: break year = int((seconds) / Year.length_in_seconds) y_sec = year * Year.length_in_seconds season = int((seconds - y_sec) / Season.length_in_seconds) s_sec = season * Season.length_in_seconds day = int((seconds - y_sec - s_sec) / Day.length_in_seconds) d_sec = day * Day.length_in_seconds hour = int((seconds - y_sec - s_sec - d_sec) / Hour.length_in_seconds) h_sec = hour * Hour.length_in_seconds minute = int((seconds - y_sec - s_sec - d_sec - h_sec) / Minute.length_in_seconds) m_sec = minute * Minute.length_in_seconds seconds = seconds - y_sec - s_sec - d_sec - h_sec - m_sec return cls( era=era + 1, year=year + 1, season=season + 1, day=day + 1, hour=hour, minute=minute, second=seconds ) class Minute(DateObject): """ A representation of one minute on the Telisaran clock. Class Attributes: length_in_seconds (int): The length of an hour in seconds Attributes: minute (int): The minute of the hour (0-59) number (int): alias for minute """ length_in_seconds = 60 def __init__(self, minute): if minute < 0 or minute > 59: raise InvalidMinuteError("minute {} must be between 0 and 59") self.minute = minute @property def as_seconds(self): return self.number * Minute.length_in_seconds @property def number(self): return self.minute class Hour(DateObject): """ A representation of one hour on the Telisaran clock. Class Attributes: length_in_seconds (int): The length of an hour in seconds Instance Attributes: hour (int): The hour of the day (0-23) number (int): alias for hour """ length_in_seconds = 60 * Minute.length_in_seconds names = { '0': "Black Hour", '6': "Soul's Hour", '12': "Sun's Hour", '18': "Grey Hour", } def __init__(self, hour): if hour < 0 or hour > 23: raise InvalidHourError("hour {} must be between 0 and 23") self.hour = hour @property def as_seconds(self): return self.number * Hour.length_in_seconds @property def number(self): return self.hour @property def name(self): if str(self) in Hour.names: return Hour.names[str(self)] else: return "{}{} hour".format(str(self), _suffix(int(self))) class Day(DateObject): """ A representation of one day on the Telisaran calendar. Class Attributes: length_in_seconds (int): The length of a day in seconds names (list): The names of the days Instance Attributes: day_of_season (int): The day of the season (1 - 45) day_of_span (int): The day of the span ( name (str): The name of the day season (Season): The Season in which this day occurs span (int): The span of the season in which this day falls (1 - 9) """ length_in_seconds = 24 * Hour.length_in_seconds names = ['Syfdag', 'Mimdag', 'Wodag', 'Thordag', 'Freydag'] def __init__(self, day_of_season, season=None): """ Create a Day instance. Args: day_of_season (int): The day of the season between 1 and 45. season (Season): optional, specify a Season instance for this day. """ if day_of_season < 1 or day_of_season > Season.length_in_days: raise InvalidDayError("{}: day_of_season must be between 1 and {}".format( day_of_season, Season.length_in_days)) self.day_of_season = day_of_season self.season = season @property def number(self): return self.day_of_season @property def span(self): return int((self.day_of_season - 1) / Span.length_in_days) + 1 @property def day_of_span(self): return (self.day_of_season - 1) % Span.length_in_days + 1 @property def name(self): if self.season.number == 9: return self.season.day_names[self.day_of_span - 1] else: return Day.names[self.day_of_span - 1] def __repr__(self): return self.name class Span(DateObject): """ A span (week) of days in a Season. Class Attributes: length_in_days (int): The number of days in a span. """ length_in_days = len(Day.names) length_in_seconds = length_in_days * Day.length_in_seconds @property def number(self): return 1 class Season(DateObject): """ A season (month) of days in a Telisaran year. Class Attributes: names (list): The names of the seasons length_in_spans (int): The number of spans in a season length_in_days (int): The number of days in a season Instance Attributes: name (str): The name of the season season_of_year (int): The season of the year, between 1 and 8. days (list): A list of Day objects for every day in the season year (int): The year in which this season falls. """ names = ['Fox', 'Owl', 'Wolf', 'Eagle', 'Shark', 'Lion', 'Raven', 'Bear'] day_names = Day.names length_in_spans = 9 length_in_days = length_in_spans * Span.length_in_days length_in_seconds = length_in_days * Day.length_in_seconds def __init__(self, season_of_year, year): if season_of_year < 1 or season_of_year > len(Season.names): raise InvalidSeasonError("season_of_year {} must be between 1 and {}".format( season_of_year, len(Season.names))) self.season_of_year = season_of_year self.year = year self._days = [] @property def number(self): return self.season_of_year @property def days(self): if not self._days: for i in range(1, Season.length_in_days + 1): self._days.append(Day(i, season=self)) return self._days @property def name(self): return Season.names[self.season_of_year - 1] def __int__(self): return (self.season_of_year - 1) * self.length_in_seconds def __str__(self): return "Season of the {}".format(self.name) class HuntDay(Day): names = [ "Syf's Hunt", "Mimir's Hunt", "Woden's Hunt", "Thorus's Hunt", "Freya's Hunt" ] class FestivalOfTheHunt(Season): """ The 9th season, which only has 5 days, occurring at the end of each year. Class Attributes: day_names (list): The names of the days in this season length_in_spans (int): the length of the festival in spans length_in_days (int): The length of the festival in days length_in_seconds (int): The length of the festival in seconds Instance Attributes: season_of_year (int): The season of the year (9) days (list): A list of Day objects for every day in the season name (str): The name of this special season year (Year): The year in which this festival falls """ day_names = HuntDay.names length_in_spans = 1 length_in_days = 5 length_in_seconds = length_in_days * Day.length_in_seconds def __init__(self, year): self.season_of_year = 9 self.year = year self._days = [] for i in range(1, self.length_in_days + 1): self._days.append(HuntDay(i, season=self)) @property def name(self): return "Festival Of The Hunt" def __int__(self): return (self.season_of_year - 1) * Season.length_in_seconds def __str__(self): return "the {}".format(self.name) class Year(DateObject): """ A year on the Telisaran calendar. Class Attributes: length_in_seasons (int): The length of a year in seasons length_in_spans (int): The length of a year in spans length_in_days (int): The length of a year in days length_in_seconds (int): The length of a year in seconds Instance Attributes: era (Era): The era year (Year): The year seasons (list): The seasons in the year number (int): Alias of year """ # precompute some length properties. Note that every year has one extra span, the Festival Of # The Hunt, which falls between the last span of the Bear and the first span of the Fox. length_in_seasons = len(Season.names) length_in_spans = (length_in_seasons * Season.length_in_spans) + 1 length_in_days = length_in_spans * Span.length_in_days length_in_seconds = length_in_days * Day.length_in_seconds def __init__(self, year, era): """ Instantiate a Year object Args: year (int): The year of the era. era (int): The era """ self.era = era if year < 1: raise InvalidYearError("Years must be greater than 1.") if self.era.end and year > self.era.end: raise InvalidYearError("The {} ended in {}".format(self.era.long, self.era.end)) self.year = year self.seasons = [Season(i, self) for i in range(1, Year.length_in_seasons + 1)] self.seasons.append(FestivalOfTheHunt(self)) @property def number(self): return self.year class Era(DateObject): """ An age of years, by Telisaran reckoning. Class Attributes: long_names (list): The long names of the eras shot_names (list): The abbreviated names of the eras lenth_in_seconds (int): The length of an era, in seconds Instance Attributes: era (int): The number of the era (1-3) end (int): The last year of the era short (str): The short name of this era long (str): The long name of this era number (int): Alias for era """ long_names = ['Ancient Era', 'Old Era', 'Modern Era'] short_names = ['AE', 'OE', 'ME'] years = [20000, 10000, None] def __init__(self, era, end=None): """ Args: era (int): The number of the era; must be between 1 and 3. end (year): The last year of the era """ if era < 1 or era > len(Era.long_names): raise InvalidEraError("{}: Eras must be between 0 and {}".format(era, len(Era.long_names))) self.era = era self.end = Era.years[self.era - 1] self.length_in_seconds = sum(Era.years[:self.era - 1]) * Year.length_in_seconds @property def short(self): return Era.short_names[self.era - 1] @property def long(self): return Era.long_names[self.era - 1] @property def number(self): return self.era def __int__(self): return self.length_in_seconds def __repr__(self): return self.long class parser: """ A lexical date expression parser that can understand various relative dates. Some examples: 2 days before 3.3206.3.36 11 spans later than now yesterday tomorrow 2 days after tomorrow 11 spans later than now yesterday 1000 years ago on 1.193.1.1 at 2.4839.7.22 If initialized with a timeline, the parser will also support references to events: 36 hours before campaign start 11 spans after the party returns from the feywild Class Attributes: future_modifiers (list): list of phrases that indicate a positive (future) date past_modifiers (list): list of phrases that indicate a negative (past) date patterns (list): A list of regular expression objects that will be used, in order, to parse the date expressions Instance Attributes: now (int): the date relative to which dates will be calculated, in seconds. timeline (dict): A dictionary of event datetimes """ future_modifiers = [ 'from', 'after', 'later than', ] past_modifiers = [ 'before', 'ago', 'earlier than', 'prior to', ] patterns = [ # re.compile( r'(?P\d+)\s+' + r'(?P\S+)\s+' + r'(?P{})'.format('|'.join(future_modifiers + past_modifiers)) + r'(?P.*)', ), # at re.compile(r'(?Pon|at)\s+(?P.*)'), ] def __init__(self, now=None, timeline={}): """ Constructor Args: now (datetime): the date against which calculate the relative date timeline (dict): a dictionary of event datetimes keyed by description """ self.timeline = timeline if not now: self.now = today.as_seconds else: try: self.now = self.timeline[str(now)].as_seconds except KeyError: self.now = int(now) def parse(self, expression): """ Parse an expression and return a datetime object computed relative to 'now'. Args: expression (str): The expression to parse. Returns: datetime: A datetime object """ for pattern in parser.patterns: m = pattern.match(expression) if m: return datetime.from_seconds(self.calculate_date(**m.groupdict())) raise ParseError("Could not parse expression '{}' using any pattern".format(expression)) def _parse_value(self, value, unit): """ Convert a value into integer seconds. Args: value (int): the value to convert (eg. "2", "37") unit (str): The units to convert to seconds (eg. "Days", "Year") Returns: int: The integer seconds of value * units, or 0 if either of value or unit is None """ if None not in (value, unit): return int(value) * self.get_unit_class(unit).length_in_seconds return 0 def _parse_start(self, expression): """ Convert a starting time expression to a datetime object. The expression can be one of: - a date in numeric format (eg. "3.3206.12.17"); - a datetime instance defined in this module (eg. "yesterday", "tomorrow") - a member of the timeline dictionary If start is False, the parser's 'now' will be used. Args: expression (str): The expression to convert to a datetime object Returns: datetime: The datetime object Raises: ParseError: If the sub-expression cannot be parsed """ # if there is no start, use 'now', ie, whatever the parser was seeded with for now if not expression: return self.now # if start is a member of the timeline, use the date associated with that event if expression in self.timeline: return self.timeline[expression] # the start might be a datetime instance defined by this module ('yesterday', 'today', etc) try: return [ i for i in inspect.getmembers(sys.modules[__name__]) if isinstance(i[1], datetime) and str(i[0]).lower() == expression.lower() ][0][1] except IndexError: pass # the start might be a numeric date string try: return datetime(*(map(int, expression.split('.')))) except ValueError: raise ParseError("Unable to parse date exprssion {}".format(expression)) def calculate_date(self, modifier, start='', value=None, unit=None): """ Calculate a date by parsing a modifier sub-expression, possibly with a value and unit, and applying it to the starting date. Args: modifier (str): A string specifying what kind of calculation to make; must be a member of past_modifiers, future_modifiers, 'at', or 'on'. start (str): The expression defining the date to apply the calculation to value (str): A string of digits unit (str): A string referencing time units defined by this module (eg. day, years, etc) Returns: datetime: The datetime object Raises: ParseError: If a date cannot be calculated from input """ if modifier == 'ago': start = 'now' start = start.strip() if not start: start = 'now' offset = self._parse_value(value, unit) start = self._parse_start(start) if modifier.lower() in ('at', 'on'): return start.as_seconds elif modifier.lower() in self.past_modifiers: return int(start) - offset elif modifier.lower() in self.future_modifiers: return int(start) + offset else: raise ParseError("Could not parse range modifier '{}'".format(modifier)) def get_unit_class(self, unit): """ Returns the class referenced by the unit string (Era, Year, Season, Span, Day, Hour, etc). Plurals will be stripped and capialization will be forced, so 'minutes' is equivalent to 'Minute'. Args: unit (str): The name of the unit of time to look up in this module Returns: DateObject: The subclass of DateObject """ names = [unit.title(), unit.title().rstrip('s')] for (name, obj) in inspect.getmembers(sys.modules[__name__], inspect.isclass): if name in names: return obj raise ParseError("Could not find a datetime object for {}".format(unit)) # helpful shortcuts for importing and hints for the parser now = datetime(year=1, season=1, day=1, era=1) today = now yesterday = None