"""Wrappers for data that can be garbled during read/write"""
from __future__ import annotations
import logging
import re
from calendar import monthrange
from collections import namedtuple
from datetime import MINYEAR, MAXYEAR, date, datetime, time
from math import log10, modf
from typing import Any
logger = logging.getLogger(__name__)
ExtendedDate = namedtuple("ExtendedDate", ["year", "month", "day"])
[docs]
class EMuType:
"""Container for data types that may be garbled during read/write
For example, transforming a year to a date using datetime.strptime()
imposes a month and date, which could be bad news if that data is ever
loaded back into the database. This class tracks the original string
and format while coercing the string to a Python data type and
providing support for basic operations.
Parameters
----------
val : Any
value to wrap
fmt : str
formatting string used to translate value back to a string
Attributes
----------
value : Any
value coerced to the correct type from a string
format : str
a formatting string
verbatim : Any
the original, unparsed value
"""
def __init__(self, val: Any, fmt: str = "{}"):
self.verbatim = val
self.value = val
self.format = fmt
self.always_compare_range = False
def __str__(self) -> str:
return self.format.format(self.value)
def __repr__(self) -> str:
return f"{self.__class__.__name__}('{str(self)}')"
def __eq__(self, other: Any) -> bool:
if self.value == other:
return True
try:
other = self.coerce(other)
except (TypeError, ValueError):
return False
if self.is_range() or self.always_compare_range:
return (
self.comp == other.comp
and self.min_comp == other.min_comp
and self.max_comp == other.max_comp
)
return self.value == other.value
def __ne__(self, other: Any) -> bool:
return not self.__eq__(other)
def __lt__(self, other: Any) -> bool:
try:
other = self.coerce(other)
except:
raise TypeError(
f"'<' not supported between instances of '{self.__class__.__name__}'"
f" and '{type(other)}'"
)
if self.is_range() or self.always_compare_range:
return self.max_comp < other.min_comp
return self.value < other.value
def __le__(self, other: Any) -> bool:
try:
other = self.coerce(other)
except:
raise TypeError(
f"'<=' not supported between instances of '{self.__class__.__name__}'"
f" and '{type(other)}'"
)
if self.is_range() or self.always_compare_range:
return self.min_comp <= other.max_comp
return self.value <= other.value
def __gt__(self, other: Any) -> bool:
try:
other = self.coerce(other)
except:
raise TypeError(
f"'>' not supported between instances of '{self.__class__.__name__}'"
f" and '{type(other)}'"
)
if self.is_range() or self.always_compare_range:
other = self.coerce(other)
return self.min_comp > other.max_comp
return self.value > other.value
def __ge__(self, other: Any) -> bool:
try:
other = self.coerce(other)
except:
raise TypeError(
f"'>=' not supported between instances of '{self.__class__.__name__}'"
f" and '{type(other)}'"
)
if self.is_range() or self.always_compare_range:
return self.max_comp >= other.min_comp
return self.value >= other.value
def __contains__(self, other: Any) -> bool:
if self.is_range():
other = self.coerce(other)
return self.min_comp <= other.min_comp and self.max_comp >= other.max_comp
raise ValueError(f"{self.__class__.__name__} is not a range")
def __add__(self, other) -> EMuType | int | float:
return self._math_op(other, "__add__")
def __sub__(self, other) -> EMuType | int | float:
return self._math_op(other, "__sub__")
def __mul__(self, other) -> EMuType | int | float:
return self._math_op(other, "__mul__")
def __floordiv__(self, other) -> EMuType | int | float:
return self._math_op(other, "__floordiv__")
def __truediv__(self, other) -> EMuType | int | float:
return self._math_op(other, "__truediv__")
def __mod__(self, other) -> EMuType | int | float:
return self._math_op(other, "__mod__")
def __divmod__(self, other) -> EMuType | int | float:
return self._math_op(other, "__divmod__")
def __pow__(self, other) -> EMuType | int | float:
return self._math_op(other, "__pow__")
def __iadd__(self, other: Any) -> EMuType:
result = self + other
return self.__class__(result.value, result.format)
def __isub__(self, other: Any) -> EMuType:
result = self - other
return self.__class__(result.value, result.format)
def __imul__(self, other: Any) -> EMuType:
result = self * other
return self.__class__(result.value, result.format)
def __ifloordiv__(self, other: Any) -> EMuType:
result = self // other
return self.__class__(result.value, result.format)
def __itruediv__(self, other: Any) -> EMuType:
result = self / other
return self.__class__(result.value, result.format)
def __imod__(self, other: Any) -> EMuType:
result = self % other
return self.__class__(result.value, result.format)
def __ipow__(self, other: Any) -> EMuType:
result = self**other
return self.__class__(result.value, result.format)
def __setattr__(self, attr: str, val: Any) -> None:
try:
existing = getattr(self, attr)
except AttributeError:
super().__setattr__(attr, val)
else:
if val != existing:
raise AttributeError(
f"Cannot modify existing attribute ({attr}={repr(existing)},"
f" tried to assign {repr(val)})"
)
def __delattr__(self, attr: str) -> None:
raise AttributeError("Cannot delete attribute")
@property
def min_value(self) -> Any:
"""Minimum value needed to express the original string"""
return self.value
@property
def max_value(self) -> Any:
"""Maximum value needed to express the original string"""
return self.value
@property
def comp(self) -> Any:
"""Value to use for comparisons"""
return self.value
@property
def min_comp(self) -> Any:
"""Minimum value to use for comparisons"""
return self.value
@property
def max_comp(self) -> Any:
"""Maximum value to use for comparisons"""
return self.value
[docs]
def emu_str(self) -> str:
"""Returns a string representation suitable for EMu"""
return str(self)
[docs]
def coerce(self, other: Any) -> EMuType:
"""Coerces another object to the current class
Parameters
----------
other : Any
an object to convert to this class
Returns
-------
EMuType
other as EMuType
"""
if not isinstance(other, self.__class__):
other = self.__class__(other)
return other
[docs]
def is_range(self) -> bool:
"""Checks if class represents a range"""
return self.min_comp != self.max_comp
def _math_op(self, other, operation) -> EMuType | int | float:
"""Performs the specified arithmetic operation
Wraps result in instance class if possible. In the case of values that
can't be expressed using the original class (for example, the difference
between two dates), it returns the result itself.
"""
if self.is_range():
min_val = self.__class__(self.min_value)._math_op(other, operation)
max_val = self.__class__(self.max_value)._math_op(other, operation)
return (min_val, max_val)
if isinstance(other, self.__class__):
val = getattr(self.value, operation)(other.value)
# Raise an error if values are not floats and formats differ
if isinstance(self.value, float):
# Use the more precise format for add/substract
i = -1 if operation in {"__add__", "__sub__"} else 0
try:
fmt = sorted([o.format for o in [self, other] if o.dec_places])[i]
except IndexError:
fmt = self.format
elif self.format != other.format:
raise ValueError(
f"{self.__class__.__name__} have different formats: {[self.format, other.format]}"
)
else:
fmt = self.format
else:
try:
val = getattr(self.value, operation)(other)
except AttributeError:
raise ValueError(f"Operation not available: {operation}")
fmt = self.format
if isinstance(val, tuple):
return tuple([self.__class__(str(val), fmt=fmt) for val in val])
try:
return self.__class__(str(val), fmt=fmt)
except ValueError:
# Some operations return values that cannot be coerced to the original
# class, for example, subtracting one date from another
return val
def _set_default_attr(self, attr: str, val: Any = None) -> None:
try:
getattr(self, attr)
except AttributeError:
setattr(self, attr, val)
[docs]
class EMuFloat(EMuType):
"""Wraps floats read from strings to preserve precision
Parameters
----------
val : str | float
float as a string or float
fmt : str
formatting string used to convert the float back to a string. Computed
for strings but must be included if val is a float.
Attributes
----------
value : float
float parsed from string
format : str
formatting string used to convert the float back to a string
verbatim : Any
the original, unparsed value
"""
def __init__(self, val: str | float, fmt: str = None):
"""Initialize an EMuFloat object
Parameters
----------
val : str or float
the number to wrap
fmt : str
a Python formatting string. Recommended if val is a float,
otherwise it will be determined from val.
"""
self.verbatim = val
self.always_compare_range = False
fmt_provided = fmt is not None
if isinstance(val, str):
val = val.replace(",", "")
if isinstance(val, float) and not fmt_provided:
val = str(val)
if isinstance(val, self.__class__):
self.value = val.value
self.format = val.format
val = str(val) # convert to string so the verification step works
elif fmt_provided:
self.value = float(val)
self.format = fmt
else:
self.value = float(val)
val = str(val)
dec_places = len(val.split(".")[1]) if "." in val else 0
self.format = f"{{:.{dec_places}f}}"
# Verify that the parsed value is the same as the original string if
# the format string was calculated
if not fmt_provided and val.lstrip("0").rstrip(".") != str(self).lstrip("0"):
raise ValueError(f"Parsing changed value ({repr(val)} became {repr(self)})")
def __format__(self, format_spec: str) -> str:
try:
return format(str(self), format_spec)
except ValueError:
return format(float(self), format_spec)
def __int__(self) -> int:
return int(self.value)
def __float__(self) -> float:
return self.value
@property
def dec_places(self) -> int:
"""Number of decimal places from the formatting string"""
return int(self.format.strip("{:.f}"))
[docs]
def round(self, dec_places: int) -> "EMuFloat":
"""Rounds the float to the given number of decimal places
Parameters
----------
dec_places : int
the number of decimal places
Returns
-------
EMuFloat
the rounded value
"""
return self.__class__(("{:." + str(dec_places) + "f}").format(self))
[docs]
class EMuCoord(EMuFloat):
"""Wraps coordinates read from strings
Attributes
----------
value : str | float
coordinate as a string or float
format : str
formatting string used to convert the float back to a string
degrees : EMuFloat
degrees parsed from original
minutes : EMuFloat
minutes parsed from original, if any
seconds : EMuFloat
seconds parsed from original, if any
verbatim : Any
the original, unparsed value
"""
#: str : pattern for hemisphere for positive coordinates
pos = ""
#: str : pattern for hemisphere for negative coordinates
neg = ""
#: tuple of int : range of allowable values
bounds = (0, 0)
#: float : width of one degree lat (anywhere) or lon (at the equator)
deg_dist_m = 110567
# dict : uncertainty in meters for deg/min/sec at the equator
dms_unc_m = {
"degrees": deg_dist_m,
"minutes": deg_dist_m / 60,
"seconds": deg_dist_m / 3600,
}
# dict : uncertainty in meters for decimal degrees at the equator
dec_unc_m = {
0: deg_dist_m,
1: deg_dist_m / 10,
2: deg_dist_m / 100,
3: deg_dist_m / 1000,
4: deg_dist_m / 10000,
5: deg_dist_m / 100000,
}
def __init__(self, val: str | float, fmt: str = None):
"""Initializes an EMuCoord object
Parameters
----------
val : str | float
coordinate
fmt : str
formatting string used to convert a float back to a string
"""
self.always_compare_range = False
if isinstance(val, str):
self.verbatim = val.strip()
parts = re.findall(r"(\d+(?:\.\d+)?)", self.verbatim)
if len(parts) > 3:
raise ValueError(f"Invalid coordinate: {self.verbatim}")
self.degrees = EMuFloat(parts[0])
if len(parts) == 1:
self.format = self.degrees.format
elif len(parts) > 1:
self.minutes = EMuFloat(parts[1])
self.format = "{}"
if len(parts) > 2:
self.seconds = EMuFloat(parts[2])
elif isinstance(val, EMuCoord):
self.verbatim = val.verbatim
for attr in ("degrees", "minutes", "seconds", "format"):
if getattr(val, attr) is not None:
setattr(self, attr, getattr(val, attr))
else:
self.verbatim = val
self.degrees = EMuFloat(abs(val), fmt=fmt)
self.format = self.degrees.format
self._set_default_attr("minutes", None)
self._set_default_attr("seconds", None)
self._sign = EMuFloat(self._get_sign(), fmt="{:.0f}")
self.value = float(self)
if self.value < min(self.bounds) or self.value > max(self.bounds):
raise ValueError(f"Coordinate out of bounds ({val} not in {self.bounds})")
if self.minutes and self.minutes > 60:
raise ValueError(f"Invalid minutes: {val}")
if self.seconds and self.seconds > 60:
raise ValueError(f"Invalid seconds: {val}")
def __format__(self, format_spec: str) -> str:
try:
return format(str(self), format_spec)
except ValueError:
return format(float(self), format_spec)
def __str__(self) -> str:
if self.kind == "dms":
parts = (self.degrees, self.minutes, self.seconds)
return f"{' '.join([str(p) for p in parts if p is not None])} {self.hemisphere}"
return str(self._sign * self.degrees)
def __int__(self) -> int:
return int(float(self))
def __float__(self) -> float:
val = EMuFloat(self.degrees)
if self.minutes:
val += self.minutes / 60
if self.seconds:
val += self.seconds / 3600
return float(self._sign * val)
@property
def hemisphere(self) -> str:
"""Gets the hemisphere in which a coordinate is located"""
return self.pos[0] if self._sign > 0 else self.neg[0]
@property
def kind(self) -> str:
"""Gets kind of verbatim coordinate string"""
try:
float(self.verbatim)
except ValueError:
return "dms"
return "decimal"
[docs]
def to_dms(self, unc_m: int = None) -> str:
"""Expresses coordinate as degrees-minutes-seconds
Parameters
----------
unc_m : int
uncerainty in meters
Returns
-------
str
coordinate as degrees-minutes-seconds
"""
orig_unc_m = self.coord_uncertainty_m()
if unc_m is None:
if self.kind == "dms":
parts = [
p if p else 0 for p in (self.degrees, self.minutes, self.seconds)
]
for i, part in enumerate(parts):
if i < 2:
frac, num = modf(part)
parts[i] = num
parts[i + 1] += 60 * frac
parts[i] = int(parts[i])
return f"{' '.join([str(p) for p in parts if p is not None])} {self.hemisphere}"
unc_m = orig_unc_m
# Round to approximate the given uncertainty
unc_m = self._round_to_exp_10(unc_m)
if unc_m < orig_unc_m:
raise ValueError(
f"unc_m cannot be smaller than the uncertainty implied by verbatim ({orig_unc_m} m)"
)
last_unc_m = 1e7
for key, ref_unc_m in self.dms_unc_m.items():
ref_unc_m = self._round_to_exp_10(ref_unc_m)
if ref_unc_m <= unc_m <= last_unc_m:
tenths = False
break
last_unc_m = ref_unc_m
# Gaps between deg/min/sec ranks are huge, so try tenths as well
if ref_unc_m / 10 <= unc_m <= last_unc_m:
tenths = True
break
last_unc_m = ref_unc_m / 10
val = self.value
# Reverse sign for negative coords. Hemisphere is given using a letter.
if val < 0:
val *= -1
parts = []
for attr in ["degrees", "minutes", "seconds"]:
fractional, integer = modf(val)
if key == attr and tenths:
integer += round(fractional, 1)
parts.append(f"{integer:.1f}")
else:
parts.append(str(int(integer)))
if key == attr:
break
val = fractional * 60
return f"{' '.join([str(p) for p in parts])} {self.hemisphere}"
[docs]
def to_dec(self, unc_m: int = None) -> str:
"""Expresses coordinate as a decimal
Parameters
----------
unc_m : int
uncerainty in meters
Returns
-------
str
coordinate as decimal
"""
orig_unc_m = self.coord_uncertainty_m()
if unc_m is None:
if self.kind == "decimal":
return str(self._sign * self.degrees)
unc_m = orig_unc_m
unc_m = self._round_to_exp_10(unc_m)
if unc_m < orig_unc_m:
raise ValueError(
f"unc_m cannot be smaller than the uncertainty implied by verbatim ({orig_unc_m} m)"
)
last_unc_m = 1e7
for key, ref_unc_m in self.dec_unc_m.items():
ref_unc_m = self._round_to_exp_10(ref_unc_m)
if ref_unc_m <= unc_m <= last_unc_m:
break
last_unc_m = ref_unc_m
return f"{{:.{key}f}}".format(self)
[docs]
def coord_uncertainty_m(self) -> int:
"""Estimates coordinate uncertainty in meters based on distance at equator
Returns
-------
int
uncertainty in meters, rounded to an exponent of 10
"""
if self.seconds:
unc_m = self.deg_dist_m / (3600 * 10**self.seconds.dec_places)
elif self.minutes:
unc_m = self.deg_dist_m / (60 * 10**self.minutes.dec_places)
else:
unc_m = self.deg_dist_m / 10**self.degrees.dec_places
return self._round_to_exp_10(unc_m)
def _get_sign(self) -> int:
"""Gets the sign of the decimal coordinate represented as +1 or -1"""
if isinstance(self.verbatim, str):
val = self.verbatim.strip()
try:
return 1 if float(self.verbatim) >= 0 else -1
except ValueError:
pass
for pat, mod in {
r"(^\+|^{0}|{0}\.?$)".format(self.pos): 1,
r"(^-|^{0}|{0}\.?$)".format(self.neg): -1,
}.items():
if re.search(pat, val, flags=re.I):
return mod
raise ValueError(
f"Could not parse as {self.__class__.__name__}: {self.verbatim}"
)
return 1 if self.verbatim >= 0 else -1
@staticmethod
def _round_to_exp_10(val: int | float) -> int:
"""Rounds value to an exponent of 10"""
frac, exp = modf(log10(val))
if frac > log10(4.99999999):
exp += 1
return int(10**exp)
[docs]
class EMuLatitude(EMuCoord):
"""Wraps latitudes read from strings"""
#: str : pattern for hemisphere for positive coordinates
pos = "N(orth)?"
#: str : pattern for hemisphere for negative coordinates
neg = "S(outh)?"
#: tuple of int : range of allowable values
bounds = (-90, 90)
def __init__(self, val: str | float, fmt: str = None):
"""Initialize an EMuLatitude object
Parameters
----------
val : str or float
latitude
fmt : str
formatting string used to convert a float back to a string
"""
try:
super().__init__(val, fmt)
except Exception as exc:
raise ValueError(f"Could not create EMuLatitude from {repr(val)}") from exc
[docs]
class EMuLongitude(EMuCoord):
"""Wraps longitudes read from strings"""
#: str : pattern for hemisphere for positive coordinates
pos = "E(ast)?"
#: str : pattern for hemisphere for negative coordinates
neg = "W(est)?"
#: tuple of int : range of allowable values
bounds = (-180, 180)
def __init__(self, val: str | float, fmt: str = None):
"""Initialize an EMuLongitude object
Parameters
----------
val : str or float
longitude
fmt : str
formatting string used to convert a float back to a string
"""
try:
super().__init__(val, fmt)
except Exception as exc:
raise ValueError(f"Could not create EMuLongitude from {repr(val)}") from exc
[docs]
class EMuDate(EMuType):
"""Wraps dates read from strings to preserve meaning
For dates in the range supported by the native EMu datetime module, this
class supports both comparisons and addition/subtraction using timedelta objects
but not augmented assignment using += or -=. For dates outside this range,
comparisons and operations are currently not possible.
Parameters
----------
val : str or datetime.date
date as a string or date object
fmt : str
formatting string used to convert the value back to a string. If
omitted, the class will try to determine the correct format.
Attributes
----------
value : datetime.date or ExtendedDate
date parsed from string
format : str
date format string used to convert the date back to a string
verbatim : Any
the original, unparsed value
"""
directives = {
"day": ("%d", "%-d"),
"month": ("%B", "%b", "%m", "%-m"),
"year": ("%Y", "%y"),
}
formats = {"day": "%Y-%m-%d", "month": "%b %Y", "year": "%Y"}
def __init__(self, *val, fmt: str = None):
"""Initialize an EMuDate object
Parameters
----------
val : str, int, datetime.date, Iterable[int, int, int]
the date. If an int, must be a year only. If multiple values are given,
they must be a year, month, and day as ints. If the string "today" is
given, returns today's date.
fmt : str
a date format string
"""
if len(val) == 1:
val = val[0]
if val == "today":
val = datetime.now().strftime("%Y-%m-%d")
self.verbatim = val
self.always_compare_range = True
fmt_provided = fmt is not None
# Convert integers to strings
if isinstance(val, int):
val = str(val)
# Convert tuples to ExtendedDate
if isinstance(val, tuple) and not isinstance(val, ExtendedDate):
val = ExtendedDate(*val)
# Remove periods and trailing hyphens before parsing
if isinstance(val, str):
val = val.replace(".", "").rstrip("-")
# Zero-pad two-to-three-digit years if no format is provided. EMu does
# not zero-pad years less than 1000 during export, which trips up the
# date parsing below. Assumes year-month-day format.
if (
not fmt_provided
and isinstance(val, str)
and re.match(r"^\d{1,3}-\d{1,2}-(\d{1,2})?$", val)
):
val = re.sub(
r"^(-?\d{1,3})\b",
lambda s: s.group(1).zfill(5 if val[0] == "-" else 4),
val,
)
# Common data formats
fmts = [
# EMu date formats
("day", "%Y-%m-%d"),
("day", "%d %b %Y"),
("month", "%b %Y"),
("month", "%Y-%m-"),
("year", "%Y"),
# Other common date formats
("day", "%d-%b-%Y"),
("day", "%b %d %Y"),
("day", "%b %d, %Y"),
("day", "%B %d %Y"),
("day", "%B %d, %Y"),
("month", "%B %Y"),
("month", "%Y-%m"),
]
if isinstance(val, EMuDate):
self.value = val.value
self.kind = val.kind
self.format = val.format
val = val.strftime(self.format)
fmt = self.format
fmts.clear()
elif isinstance(val, (date, ExtendedDate)):
if val.day:
self.kind = "day"
self.format = "%Y-%m-%d"
elif val.month:
self.kind = "month"
self.format = "%b %Y"
else:
self.kind = "year"
self.format = "%Y"
# Convert ExtendedDate that can be handled by the datetime module
if isinstance(val, ExtendedDate) and MINYEAR <= val.year <= MAXYEAR:
self.value = date(*(n if n else 1 for n in val))
else:
self.value = val
val = self._strftime(val, self.format)
fmt = self.format
fmts.clear()
self._validate_extended_date(self)
elif fmt:
# Assess speciicity of date if custom formatting string provided
for kind, directives in self.directives.items():
if any((d in fmt for d in directives)):
self.value = self.strptime(str(val), fmt)
self.kind = kind
self.format = self.formats[kind]
fmts.clear()
break
for kind, fmt in fmts:
try:
self.value = self.strptime(str(val), fmt)
self.kind = kind
self.format = self.formats[kind]
break
except (TypeError, ValueError):
pass
else:
if fmts:
raise ValueError(f"Could not parse date: {repr(val)}")
# Verify that the parsed value is the same as the original string if
# the format string was calculated
# if not fmt_provided and str(val) != self.strftime(fmt):
# raise ValueError(f"Parsing changed value ('{val}' became '{self}')")
def __str__(self) -> str:
return self.strftime(self.format)
[docs]
def strftime(self, fmt: str = None) -> str:
"""Formats date as a string
Parameters
----------
fmt : str
date format string
Returns
-------
str
date as string
"""
return self._strftime(self, fmt if fmt is not None else self.format)
[docs]
def to_datetime(self, tm: time) -> datetime:
"""Combines date and time into a single datetime
Parameters
----------
tm : datetime.time
time to use with date
Returns
-------
datetime.datetime
combined datetime
"""
if self.min_value != self.max_value:
raise ValueError("Cannot convert range to datetime")
return datetime(
self.year,
self.month,
self.day,
tm.hour,
tm.minute,
tm.second,
tm.microsecond,
tm.tzinfo,
)
[docs]
def emu_str(self) -> str:
"""Returns a string representation of the date suitable for EMu"""
if self.year < 0:
year = str(self.year).zfill(5)
return f"{self} BC".replace(year, year.lstrip("-"))
if 0 <= self.year < 100:
return f"{self} AD"
return str(self)
@property
def min_value(self) -> date | ExtendedDate:
"""Minimum date needed to express the original string
For example, the first day of the month for a date that specifies
only a month and year or the first day of the year for a year.
"""
if self.kind == "day":
return self.value
if self.kind == "month":
return self.value.__class__(self.value.year, self.value.month, 1)
if self.kind == "year":
return self.value.__class__(self.value.year, 1, 1)
raise ValueError(f"Invalid kind: {self.kind}")
@property
def max_value(self) -> date | ExtendedDate:
"""Maximum date needed to express the original string
For example, the last day of the month for a date that specifies
only a month and year or the last day of the year for a year.
"""
if self.kind == "day":
return self.value
if self.kind == "month":
_, last_day = monthrange(self.value.year, self.value.month)
return self.value.__class__(self.value.year, self.value.month, last_day)
if self.kind == "year":
return self.value.__class__(self.value.year, 12, 31)
raise ValueError(f"Invalid kind: {self.kind}")
@property
def comp(self) -> tuple[int, int, int]:
"""Value to use for comparisons"""
val = self.min_value
return (val.year, val.month if val.month else 1, val.day if val.day else 1)
@property
def min_comp(self) -> tuple[int, int, int]:
"""Minimum value to use for comparisons"""
val = self.min_value
return (val.year, val.month, val.day)
@property
def max_comp(self) -> tuple[int, int, int]:
"""Maximum value to use for comparisons"""
val = self.max_value
return (val.year, val.month, val.day)
@property
def year(self) -> int:
"""Year of the parsed date"""
return self.value.year
@property
def month(self) -> int:
"""Month of the parsed date"""
return self.value.month if self.kind != "year" else None
@property
def day(self) -> int:
"""Day of the parsed date"""
return self.value.day if self.kind == "day" else None
[docs]
def date(self) -> date:
"""Returns the datetime.date corresponding to this object
Included to allow instances of this class to play well with functions that
accept dates using both the datetime.date and datetime.datetime classes;
instances of datetime.datetime include a date method that allows them to
be easily converted to dates, for example, for comparisons.
Returns
-------
datetime.date
the date corresponding to this object
"""
return self.value
[docs]
@staticmethod
def strptime(val: str, fmt: str) -> date | ExtendedDate:
"""Formats a string as a date
Parameters
----------
val : str
date string
fmt : str
date format string
Returns
-------
datetime.date or ExtendedDate
date as an object. If year is out-of-range for the native date class,
returns an ExtendedDate tuple instead.
"""
try:
parsed = datetime.strptime(val, fmt)
return date(parsed.year, parsed.month, parsed.day)
except ValueError:
# Set up a regex pattern based on the date format string
pattern = (
fmt.replace("%Y", r"(?P<year>-?\d+)")
.replace("%m", r"(?P<month>\d+)")
.replace("%d", r"(?P<day>\d+)")
.replace("%b", r"(?P<month>[A-Z]{3})")
)
match = re.search(
"^" + pattern + r"( (A[\. ]*D\.?|B[\. ]*C[\. ]*(E\.?)?))?$",
val,
flags=re.I,
)
ymd = []
for key in ("year", "month", "day"):
try:
ymd.append(int(match.group(key)))
except AttributeError as exc:
raise ValueError(
f"Could not parse string as ExtendedDate: {val}"
) from exc
except IndexError:
ymd.append(None)
except ValueError:
ymd.append(int(datetime.strptime(match.group(key), "%b").month))
# Handle AD and BC
if ymd[0] is not None and ymd[0] > 0:
pattern = r"\b(A[\. ]*D\.?|B[\. ]*C[\. ]*(E\.?)?)\b"
ad_bc = re.search(pattern, val, flags=re.I)
if ad_bc is not None:
ad_bc = re.sub(r"[^A-Z]", "", ad_bc.group().upper(), flags=re.I)
if ad_bc.startswith("BC"):
ymd[0] *= -1
ext_date = ExtendedDate(*ymd)
EMuDate._validate_extended_date(ext_date)
return ext_date
def _strftime(self, val: str, fmt: str = None) -> str:
"""Formats date as a string
Parameters
----------
val: datetime.date, EMuDate, or ExtendedDate
date
fmt : str
date format string
Returns
-------
str
date as string
"""
# Forbid formats that are more specific than the original string. Users
# can force the issue by formatting the value attribute directly.
if not val.day:
allowed = []
if val.year is not None:
allowed.extend(self.directives["year"])
if val.month:
allowed.extend(self.directives["month"])
directives = re.findall(r"%[a-z]", fmt, flags=re.I)
disallowed = set(directives) - set(allowed)
if disallowed:
raise ValueError(
f"Invalid directives for ({val.year}, {val.month}, {val.day}): {disallowed}"
)
# Use the value attribute if passing an EMuDate
if isinstance(val, EMuDate):
val = val.value
try:
return val.strftime(fmt)
except AttributeError:
date_str = (
fmt.replace("%Y", str(val.year).zfill(5 if val.year < 0 else 4))
.replace("%m", str(val.month).zfill(2))
.replace("%d", str(val.day).zfill(2))
)
if "%b" in fmt:
month_abbr = datetime.strptime(str(val.month), "%m").strftime("%b")
date_str = date_str.replace("%b", month_abbr)
return date_str
@staticmethod
def _validate_extended_date(val: date | datetime | ExtendedDate | EMuDate) -> None:
if val.month and (val.month < 1 or val.month > 12):
raise ValueError(f"Month out of range: {val}")
if val.day:
if val.day > monthrange(val.year, val.month)[1]:
raise ValueError(f"Day out of range: {val}")
[docs]
class EMuTime(EMuType):
def __init__(self, val: str | datetime.time, fmt: str = None):
"""Initialize an EMuTime object
Parameters
----------
val : str or datetime.time
the time
fmt : str
a time format string
"""
self.verbatim = val
self.always_compare_range = False
fmt_provided = fmt is not None
# Include both naive and timezoned formats
fmts = [
"%H:%M",
"%H%M",
"%I%M %p",
"%I:%M %p",
"%H:%M:%S",
"%I:%M:%S %p",
]
num_formats = len(fmts)
fmts.extend([f"{f} %z" for f in fmts[:num_formats]])
fmts.extend([f"{f} UTC%z" for f in fmts[:num_formats]])
fmts.insert(0, "%H:%M:")
if isinstance(val, EMuTime):
self.value = val.value
fmt = val.format
val = val.strftime(fmt)
fmts.clear()
elif isinstance(val, time):
self.value = val
fmt = fmts[0]
val = val.strftime(fmt)
fmts.clear()
for fmt in fmts:
try:
parsed = datetime.strptime(val, fmt)
self.value = time(
parsed.hour,
parsed.minute,
parsed.second,
parsed.microsecond,
parsed.tzinfo,
)
break
except (TypeError, ValueError):
pass
else:
if fmts:
raise ValueError(f"Could not parse time: {repr(val)}")
# Verify that the parsed value is the same as the original string if
# the format string was calculated
if not fmt_provided and val.lstrip("0") != self.strftime(fmt).lstrip("0"):
raise ValueError(f"Parsing changed value ('{val}' became '{self}')")
# Enforce a consistent output format
self.format = "%H:%M:%S" if "%S" in fmt else "%H:%M"
def __str__(self) -> str:
return self.value.strftime(self.format)
[docs]
def strftime(self, fmt: str = None) -> str:
"""Formats time as a string
Parameters
----------
fmt : str
time format string
Returns
-------
str
time as string
"""
return self.value.strftime(fmt if fmt else self.format)
[docs]
def to_datetime(self, dt: date) -> datetime:
"""Combines date and time into a single datetime
Parameters
----------
dt : datetime.date
date to use with time
Returns
-------
datetime.datetime
combined datetime
"""
return EMuDate(dt).to_datetime(self)
@property
def hour(self) -> int:
"""Hour of the parsed time"""
return self.value.hour
@property
def minute(self) -> int:
"""Minute of the parsed time"""
return self.value.minute
@property
def second(self) -> int:
"""Second of the parsed time"""
return self.value.second
@property
def microsecond(self) -> int:
"""Microsecond of the parsed time"""
return self.value.microsecond
@property
def tzinfo(self) -> str:
"""Time zone info for the parsed time"""
return self.value.tzinfo