util.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. """This module contains several handy functions primarily meant for internal use."""
  2. __all__ = (
  3. "asbool",
  4. "asint",
  5. "astimezone",
  6. "check_callable_args",
  7. "convert_to_datetime",
  8. "datetime_ceil",
  9. "datetime_to_utc_timestamp",
  10. "get_callable_name",
  11. "localize",
  12. "maybe_ref",
  13. "normalize",
  14. "obj_to_ref",
  15. "ref_to_obj",
  16. "undefined",
  17. "utc_timestamp_to_datetime",
  18. )
  19. import re
  20. import sys
  21. from calendar import timegm
  22. from datetime import date, datetime, time, timedelta, timezone, tzinfo
  23. from functools import partial
  24. from inspect import isbuiltin, isclass, isfunction, ismethod, signature
  25. if sys.version_info < (3, 14):
  26. from asyncio import iscoroutinefunction
  27. else:
  28. from inspect import iscoroutinefunction
  29. if sys.version_info < (3, 9):
  30. from backports.zoneinfo import ZoneInfo
  31. else:
  32. from zoneinfo import ZoneInfo
  33. UTC = timezone.utc
  34. class _Undefined:
  35. def __nonzero__(self):
  36. return False
  37. def __bool__(self):
  38. return False
  39. def __repr__(self):
  40. return "<undefined>"
  41. undefined = (
  42. _Undefined()
  43. ) #: a unique object that only signifies that no value is defined
  44. def asint(text):
  45. """
  46. Safely converts a string to an integer, returning ``None`` if the string is ``None``.
  47. :type text: str
  48. :rtype: int
  49. """
  50. if text is not None:
  51. return int(text)
  52. def asbool(obj):
  53. """
  54. Interprets an object as a boolean value.
  55. :rtype: bool
  56. """
  57. if isinstance(obj, str):
  58. obj = obj.strip().lower()
  59. if obj in ("true", "yes", "on", "y", "t", "1"):
  60. return True
  61. if obj in ("false", "no", "off", "n", "f", "0"):
  62. return False
  63. raise ValueError(f'Unable to interpret value "{obj}" as boolean')
  64. return bool(obj)
  65. def astimezone(obj):
  66. """
  67. Interprets an object as a timezone.
  68. :rtype: tzinfo
  69. """
  70. if isinstance(obj, str):
  71. if obj == "UTC":
  72. return timezone.utc
  73. return ZoneInfo(obj)
  74. if isinstance(obj, tzinfo):
  75. if obj.tzname(None) == "local":
  76. raise ValueError(
  77. "Unable to determine the name of the local timezone -- you must "
  78. "explicitly specify the name of the local timezone. Please refrain "
  79. "from using timezones like EST to prevent problems with daylight "
  80. "saving time. Instead, use a locale based timezone name (such as "
  81. "Europe/Helsinki)."
  82. )
  83. elif isinstance(obj, ZoneInfo):
  84. return obj
  85. elif hasattr(obj, "zone"):
  86. # pytz timezones
  87. if obj.zone:
  88. return ZoneInfo(obj.zone)
  89. return timezone(obj._offset)
  90. return obj
  91. if obj is not None:
  92. raise TypeError(f"Expected tzinfo, got {obj.__class__.__name__} instead")
  93. def asdate(obj):
  94. if isinstance(obj, str):
  95. return date.fromisoformat(obj)
  96. return obj
  97. _DATE_REGEX = re.compile(
  98. r"(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})"
  99. r"(?:[ T](?P<hour>\d{1,2}):(?P<minute>\d{1,2}):(?P<second>\d{1,2})"
  100. r"(?:\.(?P<microsecond>\d{1,6}))?"
  101. r"(?P<timezone>Z|[+-]\d\d:\d\d)?)?$"
  102. )
  103. def convert_to_datetime(input, tz, arg_name):
  104. """
  105. Converts the given object to a timezone aware datetime object.
  106. If a timezone aware datetime object is passed, it is returned unmodified.
  107. If a native datetime object is passed, it is given the specified timezone.
  108. If the input is a string, it is parsed as a datetime with the given timezone.
  109. Date strings are accepted in three different forms: date only (Y-m-d), date with
  110. time (Y-m-d H:M:S) or with date+time with microseconds (Y-m-d H:M:S.micro).
  111. Additionally you can override the time zone by giving a specific offset in the
  112. format specified by ISO 8601: Z (UTC), +HH:MM or -HH:MM.
  113. :param str|datetime input: the datetime or string to convert to a timezone aware
  114. datetime
  115. :param datetime.tzinfo tz: timezone to interpret ``input`` in
  116. :param str arg_name: the name of the argument (used in an error message)
  117. :rtype: datetime
  118. """
  119. if input is None:
  120. return
  121. elif isinstance(input, datetime):
  122. datetime_ = input
  123. elif isinstance(input, date):
  124. datetime_ = datetime.combine(input, time())
  125. elif isinstance(input, str):
  126. m = _DATE_REGEX.match(input)
  127. if not m:
  128. raise ValueError("Invalid date string")
  129. values = m.groupdict()
  130. tzname = values.pop("timezone")
  131. if tzname == "Z":
  132. tz = timezone.utc
  133. elif tzname:
  134. hours, minutes = (int(x) for x in tzname[1:].split(":"))
  135. sign = 1 if tzname[0] == "+" else -1
  136. tz = timezone(sign * timedelta(hours=hours, minutes=minutes))
  137. values = {k: int(v or 0) for k, v in values.items()}
  138. datetime_ = datetime(**values)
  139. else:
  140. raise TypeError(f"Unsupported type for {arg_name}: {input.__class__.__name__}")
  141. if datetime_.tzinfo is not None:
  142. return datetime_
  143. if tz is None:
  144. raise ValueError(
  145. f'The "tz" argument must be specified if {arg_name} has no timezone information'
  146. )
  147. if isinstance(tz, str):
  148. tz = astimezone(tz)
  149. return localize(datetime_, tz)
  150. def datetime_to_utc_timestamp(timeval):
  151. """
  152. Converts a datetime instance to a timestamp.
  153. :type timeval: datetime
  154. :rtype: float
  155. """
  156. if timeval is not None:
  157. return timegm(timeval.utctimetuple()) + timeval.microsecond / 1000000
  158. def utc_timestamp_to_datetime(timestamp):
  159. """
  160. Converts the given timestamp to a datetime instance.
  161. :type timestamp: float
  162. :rtype: datetime
  163. """
  164. if timestamp is not None:
  165. return datetime.fromtimestamp(timestamp, timezone.utc)
  166. def timedelta_seconds(delta):
  167. """
  168. Converts the given timedelta to seconds.
  169. :type delta: timedelta
  170. :rtype: float
  171. """
  172. return delta.days * 24 * 60 * 60 + delta.seconds + delta.microseconds / 1000000.0
  173. def datetime_ceil(dateval):
  174. """
  175. Rounds the given datetime object upwards.
  176. :type dateval: datetime
  177. """
  178. if dateval.microsecond > 0:
  179. return datetime_utc_add(
  180. dateval, timedelta(seconds=1, microseconds=-dateval.microsecond)
  181. )
  182. return dateval
  183. def datetime_utc_add(dateval: datetime, tdelta: timedelta) -> datetime:
  184. """
  185. Adds an timedelta to a datetime in UTC for correct datetime arithmetic across
  186. Daylight Saving Time changes
  187. :param dateval: The date to add to
  188. :type dateval: datetime
  189. :param operand: The timedelta to add to the datetime
  190. :type operand: timedelta
  191. :return: The sum of the datetime and the timedelta
  192. :rtype: datetime
  193. """
  194. original_tz = dateval.tzinfo
  195. if original_tz is None:
  196. return dateval + tdelta
  197. return (dateval.astimezone(UTC) + tdelta).astimezone(original_tz)
  198. def datetime_repr(dateval):
  199. return dateval.strftime("%Y-%m-%d %H:%M:%S %Z") if dateval else "None"
  200. def timezone_repr(timezone: tzinfo) -> str:
  201. if isinstance(timezone, ZoneInfo):
  202. return timezone.key
  203. return repr(timezone)
  204. def get_callable_name(func):
  205. """
  206. Returns the best available display name for the given function/callable.
  207. :rtype: str
  208. """
  209. if ismethod(func):
  210. self = func.__self__
  211. cls = self if isclass(self) else type(self)
  212. return f"{cls.__qualname__}.{func.__name__}"
  213. elif isclass(func) or isfunction(func) or isbuiltin(func):
  214. return func.__qualname__
  215. elif hasattr(func, "__call__") and callable(func.__call__):
  216. # instance of a class with a __call__ method
  217. return type(func).__qualname__
  218. raise TypeError(
  219. f"Unable to determine a name for {func!r} -- maybe it is not a callable?"
  220. )
  221. def obj_to_ref(obj):
  222. """
  223. Returns the path to the given callable.
  224. :rtype: str
  225. :raises TypeError: if the given object is not callable
  226. :raises ValueError: if the given object is a :class:`~functools.partial`, lambda or a nested
  227. function
  228. """
  229. if isinstance(obj, partial):
  230. raise ValueError("Cannot create a reference to a partial()")
  231. name = get_callable_name(obj)
  232. if "<lambda>" in name:
  233. raise ValueError("Cannot create a reference to a lambda")
  234. if "<locals>" in name:
  235. raise ValueError("Cannot create a reference to a nested function")
  236. if ismethod(obj):
  237. module = obj.__self__.__module__
  238. else:
  239. module = obj.__module__
  240. return f"{module}:{name}"
  241. def ref_to_obj(ref):
  242. """
  243. Returns the object pointed to by ``ref``.
  244. :type ref: str
  245. """
  246. if not isinstance(ref, str):
  247. raise TypeError("References must be strings")
  248. if ":" not in ref:
  249. raise ValueError("Invalid reference")
  250. modulename, rest = ref.split(":", 1)
  251. try:
  252. obj = __import__(modulename, fromlist=[rest])
  253. except ImportError as exc:
  254. raise LookupError(
  255. f"Error resolving reference {ref}: could not import module"
  256. ) from exc
  257. try:
  258. for name in rest.split("."):
  259. obj = getattr(obj, name)
  260. return obj
  261. except Exception:
  262. raise LookupError(f"Error resolving reference {ref}: error looking up object")
  263. def maybe_ref(ref):
  264. """
  265. Returns the object that the given reference points to, if it is indeed a reference.
  266. If it is not a reference, the object is returned as-is.
  267. """
  268. if not isinstance(ref, str):
  269. return ref
  270. return ref_to_obj(ref)
  271. def check_callable_args(func, args, kwargs):
  272. """
  273. Ensures that the given callable can be called with the given arguments.
  274. :type args: tuple
  275. :type kwargs: dict
  276. """
  277. pos_kwargs_conflicts = [] # parameters that have a match in both args and kwargs
  278. positional_only_kwargs = [] # positional-only parameters that have a match in kwargs
  279. unsatisfied_args = [] # parameters in signature that don't have a match in args or kwargs
  280. unsatisfied_kwargs = [] # keyword-only arguments that don't have a match in kwargs
  281. unmatched_args = list(
  282. args
  283. ) # args that didn't match any of the parameters in the signature
  284. # kwargs that didn't match any of the parameters in the signature
  285. unmatched_kwargs = list(kwargs)
  286. # indicates if the signature defines *args and **kwargs respectively
  287. has_varargs = has_var_kwargs = False
  288. try:
  289. sig = signature(func, follow_wrapped=False)
  290. except ValueError:
  291. # signature() doesn't work against every kind of callable
  292. return
  293. for param in sig.parameters.values():
  294. if param.kind == param.POSITIONAL_OR_KEYWORD:
  295. if param.name in unmatched_kwargs and unmatched_args:
  296. pos_kwargs_conflicts.append(param.name)
  297. elif unmatched_args:
  298. del unmatched_args[0]
  299. elif param.name in unmatched_kwargs:
  300. unmatched_kwargs.remove(param.name)
  301. elif param.default is param.empty:
  302. unsatisfied_args.append(param.name)
  303. elif param.kind == param.POSITIONAL_ONLY:
  304. if unmatched_args:
  305. del unmatched_args[0]
  306. elif param.name in unmatched_kwargs:
  307. unmatched_kwargs.remove(param.name)
  308. positional_only_kwargs.append(param.name)
  309. elif param.default is param.empty:
  310. unsatisfied_args.append(param.name)
  311. elif param.kind == param.KEYWORD_ONLY:
  312. if param.name in unmatched_kwargs:
  313. unmatched_kwargs.remove(param.name)
  314. elif param.default is param.empty:
  315. unsatisfied_kwargs.append(param.name)
  316. elif param.kind == param.VAR_POSITIONAL:
  317. has_varargs = True
  318. elif param.kind == param.VAR_KEYWORD:
  319. has_var_kwargs = True
  320. # Make sure there are no conflicts between args and kwargs
  321. if pos_kwargs_conflicts:
  322. raise ValueError(
  323. "The following arguments are supplied in both args and kwargs: {}".format(
  324. ", ".join(pos_kwargs_conflicts)
  325. )
  326. )
  327. # Check if keyword arguments are being fed to positional-only parameters
  328. if positional_only_kwargs:
  329. raise ValueError(
  330. "The following arguments cannot be given as keyword arguments: {}".format(
  331. ", ".join(positional_only_kwargs)
  332. )
  333. )
  334. # Check that the number of positional arguments minus the number of matched kwargs
  335. # matches the argspec
  336. if unsatisfied_args:
  337. raise ValueError(
  338. "The following arguments have not been supplied: {}".format(
  339. ", ".join(unsatisfied_args)
  340. )
  341. )
  342. # Check that all keyword-only arguments have been supplied
  343. if unsatisfied_kwargs:
  344. raise ValueError(
  345. "The following keyword-only arguments have not been supplied in kwargs: "
  346. "{}".format(", ".join(unsatisfied_kwargs))
  347. )
  348. # Check that the callable can accept the given number of positional arguments
  349. if not has_varargs and unmatched_args:
  350. raise ValueError(
  351. f"The list of positional arguments is longer than the target callable can "
  352. f"handle (allowed: {len(args) - len(unmatched_args)}, given in args: "
  353. f"{len(args)})"
  354. )
  355. # Check that the callable can accept the given keyword arguments
  356. if not has_var_kwargs and unmatched_kwargs:
  357. raise ValueError(
  358. "The target callable does not accept the following keyword arguments: "
  359. "{}".format(", ".join(unmatched_kwargs))
  360. )
  361. def iscoroutinefunction_partial(f):
  362. while isinstance(f, partial):
  363. f = f.func
  364. # The asyncio version of iscoroutinefunction includes testing for @coroutine
  365. # decorations vs. the inspect version which does not.
  366. return iscoroutinefunction(f)
  367. def normalize(dt):
  368. return datetime.fromtimestamp(dt.timestamp(), dt.tzinfo)
  369. def localize(dt, tzinfo):
  370. if hasattr(tzinfo, "localize"):
  371. return tzinfo.localize(dt)
  372. return normalize(dt.replace(tzinfo=tzinfo))