Source code for bikram.bikram

"""
This module contains the :code:`samwat`, a container class for Bikram Samwat dates.

To run the examples in this page, import :code:`samwat` like this:

>>> from bikram import samwat

Some examples require the :code:`datetime.date`, and :code:`datetime.timedelta`
objects. Please import them as follows:

>>> from datetime import date, timedelta

"""

import re
from typing import List
from functools import total_ordering
from datetime import date, timedelta
from .constants import (
    BS_YEAR_TO_MONTHS,
    month_name_re_fragment,
    month_name_to_numbers,
    month_number_month_name_map,
    month_number_dev_name_map,
    month_number_shahmukhi_name_map,
    dev_digits_re_fragment,
    DEV_TO_ENG_DIGITS_TRANSTABLE as DEV_ENG_TRANS,
    ENG_TO_DEV_DIGITS_TRANSTABLE as ENG_DEV_TRANS,
)


__all__ = ['samwat', 'convert_ad_to_bs', 'convert_bs_to_ad']


_PATTERNS_CACHE = {}


[docs]@total_ordering class samwat: ''' This class represents a Bikram Samwat date. It can be used as an independent container, without using the date conversion part. >>> samwat(2074, 11, 30) samwat(2074, 11, 30) If you have the equivalent :code:`datetime.date` instance, then you can pass it as :code:`_ad` argument to the constructor like this: >>> samwat(2074, 11, 30, date(2018, 3, 14)) Doing so will cache the AD equivalent of the :code:`samwat` instance and provide a faster access through the :code:`ad` property for future access. :code:`samwat` also supports date operations, comparison etc. with other :code:`samwat` objects and :code:`datetime.date` objects. It also supports arithmetic operations with :code:`datetime.timedelta` objects. Compare two :code:`samwat` date: >>> samwat(2074, 10, 30) < samwat(2074, 11, 30) True Comparison with :code:`datetime.date` object: >>> samwat(2074, 10, 30) == date(2018, 3, 14) True Subtract 10 days from a :code:`samwat` using :code:`datetime.timedelta` object. >>> samwat(2074, 10, 30) - timedelta(days=10) samwat(2074, 10, 20) Subtract two :code:`samwat` dates and get :code:`datetime.timedelta` representation. >>> samwat(2074, 10, 11) - samwat(2070, 10, 11) datetime.timedelta(1461) Please note that the above operations require that the date be in the range of years specified in the :code:`constants.py` file. As warned in the usage guide, you will need to handle :code:`ValueError` exception if the date falls outside the range. ''' __slots__ = ('year', 'month', 'day', '_ad') _to_str_converters = { "%d": lambda obj: str(obj.day).zfill(2), "%-d": lambda obj: str(obj.day).rjust(2), "%dne": lambda obj: str(obj.day).zfill(2).translate(ENG_DEV_TRANS), "%-dne": lambda obj: str(obj.day).rjust(2).translate(ENG_DEV_TRANS), "%m": lambda obj: str(obj.month).zfill(2), "%-m": lambda obj: str(obj.month).rjust(2), "%mne": lambda obj: str(obj.month).zfill(2).translate(ENG_DEV_TRANS), "%-mne": lambda obj: str(obj.month).rjust(2).translate(ENG_DEV_TRANS), "%y": lambda obj: str(obj.year)[2:], "%Y": lambda obj: str(obj.year), "%yne": lambda obj: str(obj.year)[2:].translate(ENG_DEV_TRANS), "%Yne": lambda obj: str(obj.year).translate(ENG_DEV_TRANS), "%B": lambda obj: month_number_month_name_map[obj.month], "%Bne": lambda obj: month_number_dev_name_map[obj.month], "%S": lambda obj: month_number_shahmukhi_name_map[obj.month], } def __init__(self, year, month, day, ad=None): self.year = year self.month = month self.day = day self._ad = ad @property def ad(self): """ Return a :code:`datetime.date` instance, that is, this date converted to AD. Accessing the :code:`ad` property automatically tries to calculate the AD date. It caches the :code:`datetiem.date` object as :code:`_ad` to avoid expensive calculation for the next time. >>> samwat(2074, 11, 30).ad datetime.date(2018, 3, 14) """ if self._ad is None: self._ad = convert_bs_to_ad(self) return self._ad
[docs] def as_tuple(self): """ Return a :code:`samwat` instance as a tuple of year, month, and day. >>> samwat(2074, 11, 30).as_tuple() (2074, 11, 30) """ return self.year, self.month, self.day
[docs] def replace(self, year=None, month=None, day=None): ''' Return a new copy of :code:`samwat` by replacing one or more provided attributes of this date. For example, to replace the year: >>> samwat(2074, 11, 30).replace(year=2073) samwat(2073, 11, 30) To replace the month: >>> samwat(2074, 11, 30).replace(month=12) samwat(2074, 12, 30) ''' args = [year or self.year, month or self.month, day or self.day] return samwat(*args)
[docs] def strftime(self, formatstr: str): """ Format a samwat object to specified date string. The format strings are similar to those accepted by :func:`~bikram.samwat.parse` with the following additions/modifications: - "%B": Formats to Nepali month name(Example: Baisakh, Jestha, etc.) - "%S": Formats to Punjabi Shahmukhi month name(Example: بیساکھ, جیٹھ, etc.) - "%Bne": Formats to Nepali Devnagari month name(Example:'वैशाख', 'जेष्ठ', etc.) """ formatted = formatstr matches = [match.group() for match in self._code_re.finditer(formatstr)] for match in matches: try: converter = self._to_str_converters[match] except KeyError: raise ValueError(f"Invalid date pattern {match}") try: formatted = formatted.replace(match, converter(self)) except KeyError: raise ValueError(f"Invalid value for month") return formatted
def __repr__(self): return 'samwat({self.year}, {self.month}, {self.day})'.format(self=self) def __str__(self): return '{self.year}-{self.month}-{self.day}'.format(self=self) def __add__(self, other): if isinstance(other, timedelta): return convert_ad_to_bs(self.ad + other) raise TypeError( 'Addition only supported for datetime.timedelta type, not {}' .format(type(other))) def __radd__(self, other): return self.__add__(other) def __iadd__(self, other): return self.__add__(other) def __sub__(self, other): if isinstance(other, timedelta): return convert_ad_to_bs(self.ad - other) elif isinstance(other, samwat): return self.ad - other.ad elif isinstance(other, date): return self.ad - other raise TypeError( 'Subtraction only supported for datetime.timedelta, datetime.date ' 'and bikram.samwat types, not {}'.format(type(other).__name__)) def __rsub__(self, other): if isinstance(other, samwat): return other - self elif isinstance(other, date): return other - self.ad raise TypeError('Unsupported operand types {} - bikram.samwat'.format(type(other))) def __isub__(self, other): return self.__sub__(other) def __hash__(self): return hash((self.year, self.month, self.day)) def __eq__(self, other): if isinstance(other, date): return self.ad == other elif isinstance(other, samwat): return self.as_tuple() == other.as_tuple() raise TypeError('Cannot compare bikram.samwat with {}' .format(type(other).__name__)) def __lt__(self, other): if isinstance(other, date): return self.ad <= other elif isinstance(other, samwat): return self.as_tuple() < other.as_tuple() raise TypeError('Cannot compare bikram.samwat with {}' .format(type(other).__name__))
[docs] @staticmethod def today(): ''' Returns a :code:`samwat` instance for today. ''' return convert_ad_to_bs(date.today())
[docs] @staticmethod def from_ad(ad_date): ''' Expects a `datetime.date` then returns an equivalent `bikram.samwat` instance ''' return convert_ad_to_bs(ad_date)
_code_patterns = { "%d": r"(?P<day>\d{2})", "%-d": r"(?P<day>\d{1,2})", "%dne": rf"(?P<ned>{dev_digits_re_fragment}{{2}})", "%-dne": rf"(?P<ned>{dev_digits_re_fragment}{{1,2}})", "%m": r"(?P<m>\d{2})", "%-m": r"(?P<m>\d{1,2})", "%mne": rf"(?P<nem>{dev_digits_re_fragment}{{2}})", "%-mne": rf"(?P<nem>{dev_digits_re_fragment}{{1,2}})", "%y": r"(?P<sy>\d{2})", "%Y": r"(?P<y>\d{4})", "%yne": rf"(?P<ney>{dev_digits_re_fragment}{{2}})", "%Yne": rf"(?P<ney>{dev_digits_re_fragment}{{4}})", "%B": rf"(?P<ml>{month_name_re_fragment})", } _code_re = re.compile(r"(?P<code>%-?\w{1,3})") @classmethod def _get_pattern_from_codes(cls, codes: List[str]): patterns = [] for code in codes: try: pattern_str = cls._code_patterns[code] except KeyError: raise ValueError(f"Invalid code: {code}") patterns.append(pattern_str) pattern = re.compile(r".".join(patterns)) return pattern @staticmethod def _translate_number_from_devanagari(numberstr: str) -> int: if not numberstr: raise ValueError("Trying to translate invalid numberstr from devanagari") return int(numberstr.translate(DEV_ENG_TRANS))
[docs] @classmethod def parse(cls, datestr: str, parsestr: str): """ parse bikram samwat date string and return a `bikram.samwat` instance. - "%d": zero padded day of month, 07 - "%-d": padded day of month, 7 - "%dne": zero-padded day of month in devanagari digits, ०७ - "%-dne": day of month in devanagari digits, ७ - "%m": zero-padded month number, 01 - "%-m": month number, 1 - "%mne": zero-added month number in devanagari digits, ०१ - "%-mne": month number in devanagari digits, १ - "%y": two digit year, 73 implies 2073 - "%Y": four digit year, 2073 - "%yne": two digit year in devanagari digits, ७३ implies २०७३ - "%Yne": four digit year in devanagari digits, २०७३ - "%B": name of bikram samwat months in English spelling, English spelling short (abbr. by first three letters), Devanagari spelling. Any one of the list below: ``` [ 'वैशाख', 'जेष्ठ', 'आषाढ़', 'श्रावण', 'भाद्र', 'आश्विन', 'कार्तिक', 'मंसिर', 'पौष', 'माघ', 'फाल्गुन', 'चैत्र', 'Baisakh', 'Jestha', 'Ashadh', 'Shrawan', 'Bhadra', 'Ashwin', 'Kartik', 'Mangsir', 'Poush', 'Magh', 'Falgun', 'Chaitra', 'Bai', 'Jes', 'Ash', 'Shr', 'Bha', 'Ash', 'Kar', 'Man', 'Pou', 'Mag', 'Fal', 'Cha', ] ``` """ codes = cls._code_re.findall(parsestr) # Check if three different patterns, each for year, month and days are present. # Just check if there are y, (m or b) and d or not unique_codes = [ c.replace("%", "").replace("-", "").replace("ne", "").lower() for c in codes ] if len(set(unique_codes)) != 3: raise ValueError("Invalid number of date codes in the parse pattern") # patterns are usually static across a codebase -- this is a # micro optimization to avoid calling re.compile for same # parsestr all the time # and using try..except is faster if `parse` is being called many times try: pattern = _PATTERNS_CACHE[parsestr] except KeyError: pattern = cls._get_pattern_from_codes(codes) _PATTERNS_CACHE[parsestr] = pattern match = pattern.match(datestr) if not match: raise ValueError(f"Could not match {parsestr} with {datestr}") date_dict = match.groupdict() if not len(date_dict): raise ValueError("Something is wrong with the pattern") if 'ml' in date_dict: ml = date_dict['ml'] date_dict['m'] = month_name_to_numbers[ml] if 'sy' in date_dict: date_dict['y'] = int(f"20{date_dict['sy']}") if 'ney' in date_dict: date_dict['y'] = cls._translate_number_from_devanagari(date_dict['ney']) if 'nem' in date_dict: date_dict['m'] = cls._translate_number_from_devanagari(date_dict['nem']) if 'ned' in date_dict: date_dict['day'] = cls._translate_number_from_devanagari(date_dict['ned']) datetuple = list(map(int, [date_dict['y'], date_dict['m'], date_dict['day']])) return cls(*datetuple)
[docs] @classmethod def from_iso(cls, datestr: str): ''' Naive way to parse date from a ISO8601 (YYYY-MM-DD) BS date string and return `bikram.samwat` instance. ''' try: return cls.parse(datestr, "%Y-%m-%d") except ValueError as err: raise ValueError(f"Invalid datestr provided. Original error: {err}")
# pointers to an equivalent date in both AD and BS AD_SCALE = date(1944, 1, 1) BS_SCALE = samwat(2000, 9, 17)
[docs]def convert_ad_to_bs(date_in_ad): ''' A function to convert AD dates to BS. Expects a `datetime.date` instance and returns an equivalent `bikram.samwat` instance. >>> convert_ad_to_bs(date(2018, 3, 14)) samwat(2074, 11, 30) ''' if 1944 > date_in_ad.year or date_in_ad.year > 2033: raise ValueError('A.D. year is out of range...') diff_days = (date_in_ad - AD_SCALE).days year = BS_SCALE.year day = BS_SCALE.day + diff_days month = BS_SCALE.month while day > BS_YEAR_TO_MONTHS[year][month]: day -= BS_YEAR_TO_MONTHS[year][month] if month == 12: month = 1 year += 1 else: month += 1 return samwat(year, month, day, date_in_ad)
[docs]def convert_bs_to_ad(date_in_bs): ''' A function to convert BS dates to AD. Expects a `bikram.samwat` instance and returns an equivalent `datetime.date` instance >>> convert_bs_to_ad(samwat(2074, 11, 30)) datetime.date(2018, 3, 14) ''' if 2000 > date_in_bs.year or date_in_bs.year > 2089: raise ValueError('B.S. year is out of range...') days = date_in_bs.day + sum(BS_YEAR_TO_MONTHS[date_in_bs.year][1:date_in_bs.month]) year = date_in_bs.year - 1 while year >= BS_SCALE.year: months_for_year = BS_YEAR_TO_MONTHS[year] if year == BS_SCALE.year: days += sum(months_for_year[BS_SCALE.month + 1:]) days += months_for_year[BS_SCALE.month] - BS_SCALE.day else: days += sum(months_for_year[1:]) year -= 1 return AD_SCALE + timedelta(days=days)