Coverage for gws-app/gws/core/util.py: 54%
633 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
1"""Core utilities
3Most common function which are needed everywhere.
5This module is available as ``gws.u`` everywhere.
6"""
8import hashlib
9import json
10import os
11import pickle
12import random
13import re
14import shutil
15import sys
16import threading
17import time
18import urllib.parse
19from typing import Optional, TypeVar, Union, cast
21from . import const, log
24def is_data_object(x) -> bool:
25 return False
28def to_data_object(x):
29 pass
32def exit(code: int = 255):
33 """Exit the application.
35 Args:
36 code: Exit code.
37 """
39 sys.exit(code)
42T = TypeVar('T')
45def require(value: Optional[T], message: str = '') -> T:
46 """Return the value if not None, otherwise raise an Exception."""
47 if value is None:
48 raise ValueError(message or 'unexpected None value')
49 return value
52##
54# @TODO use ABC
57def is_list(x):
58 return isinstance(x, (list, tuple))
61def is_dict(x):
62 return isinstance(x, dict)
65def is_bytes(x):
66 return isinstance(x, (bytes, bytearray))
67 # @TODO how to handle bytes-alikes?
68 # return hasattr(x, 'decode')
71def is_atom(x):
72 return x is None or isinstance(x, (int, float, bool, str, bytes))
75def is_empty(x) -> bool:
76 """Check if the value is empty (None, empty list/dict/object)."""
78 if x is None:
79 return True
80 try:
81 return len(x) == 0
82 except TypeError:
83 pass
84 try:
85 return not vars(x)
86 except TypeError:
87 pass
88 return False
91##
94def get(x, key, default=None):
95 """Get a nested value/attribute from a structure.
97 Args:
98 x: A dict, list or Data.
99 key: A list or a dot separated string of nested keys.
100 default: The default value.
102 Returns:
103 The value if it exists and the default otherwise.
104 """
106 if not x:
107 return default
108 if isinstance(key, str):
109 key = key.split('.')
110 try:
111 return _get(x, key)
112 except (KeyError, IndexError, AttributeError, ValueError):
113 return default
116def has(x, key) -> bool:
117 """True if a nested value/attribute exists in a structure.
119 Args:
120 x: A dict, list or Data.
121 key: A list or a dot separated string of nested keys.
123 Returns:
124 True if a key exists
125 """
127 if not x:
128 return False
129 if isinstance(key, str):
130 key = key.split('.')
131 try:
132 _get(x, key)
133 return True
134 except (KeyError, IndexError, AttributeError, ValueError):
135 return False
138def _get(x, keys):
139 for k in keys:
140 if is_dict(x):
141 x = x[k]
142 elif is_list(x):
143 x = x[int(k)]
144 elif is_data_object(x):
145 # special case: raise a KeyError if the attribute is truly missing in a Data
146 # (and not just equals to None)
147 x = vars(x)[k]
148 else:
149 x = getattr(x, k)
150 return x
153def pop(x, key, default=None):
154 if is_dict(x):
155 return x.pop(key, default)
156 if is_data_object(x):
157 return vars(x).pop(key, default)
158 return default
161def pick(x, *keys):
162 def _pick(d):
163 r = {}
164 for k in keys:
165 if k in d:
166 r[k] = d[k]
167 return r
169 if is_dict(x):
170 return _pick(x)
171 if is_data_object(x):
172 return type(x)(_pick(vars(x)))
173 return {}
176def omit(x, *keys):
177 def _omit(d):
178 r = {}
179 for k, v in d.items():
180 if k not in keys:
181 r[k] = d[k]
182 return r
184 if is_dict(x):
185 return _omit(x)
186 if is_data_object(x):
187 return type(x)(_omit(vars(x)))
188 return {}
191def collect(pairs):
192 m = {}
194 for key, val in pairs:
195 if key is not None:
196 m.setdefault(key, []).append(val)
198 return m
201def first(it):
202 for x in it:
203 return x
206def first_not_none(*args):
207 for a in args:
208 if a is not None:
209 return a
212def merge(*args, **kwargs) -> Union[dict, 'Data']:
213 """Create a new dict/Data object by merging values from dicts/Datas or kwargs.
214 Latter vales overwrite former ones unless None.
216 Args:
217 *args: dicts or Datas.
218 **kwargs: Keyword args.
220 Returns:
221 A new object (dict or Data).
222 """
224 def _merge(arg):
225 for k, v in to_dict(arg).items():
226 if v is not None:
227 m[k] = v
229 m = {}
231 for a in args:
232 if a:
233 _merge(a)
234 if kwargs:
235 _merge(kwargs)
237 if not args or isinstance(args[0], dict) or args[0] is None:
238 return m
239 return type(args[0])(m)
242def deep_merge(x, y, concat_lists=True):
243 """Deeply merge dicts/Datas into a nested dict/Data.
244 Latter vales overwrite former ones unless None.
246 Args:
247 x: dict or Data.
248 y: dict or Data.
249 concat_lists: if true, list will be concatenated, otherwise merged
251 Returns:
252 A new object (dict or Data).
253 """
255 if (is_dict(x) or is_data_object(x)) and (is_dict(y) or is_data_object(y)):
256 xd = to_dict(x)
257 yd = to_dict(y)
258 d = {k: deep_merge(xd.get(k), yd.get(k), concat_lists) for k in xd.keys() | yd.keys()}
259 return d if is_dict(x) else type(x)(d)
261 if is_list(x) and is_list(y):
262 xc = compact(x)
263 yc = compact(y)
264 if concat_lists:
265 return xc + yc
266 return [deep_merge(x1, y1, concat_lists) for x1, y1 in zip(xc, yc)]
268 return y if y is not None else x
271def compact(x):
272 """Remove all None values from a collection."""
274 if is_dict(x):
275 return {k: v for k, v in x.items() if v is not None}
276 if is_data_object(x):
277 d = {k: v for k, v in vars(x).items() if v is not None}
278 return type(x)(d)
279 return [v for v in x if v is not None]
282def strip(x):
283 """Strip all strings and remove empty values from a collection."""
285 def _strip(v):
286 if isinstance(v, (str, bytes, bytearray)):
287 return v.strip()
288 return v
290 def _dict(x1):
291 d = {}
292 for k, v in x1.items():
293 v = _strip(v)
294 if not is_empty(v):
295 d[k] = v
296 return d
298 if is_dict(x):
299 return _dict(x)
300 if is_data_object(x):
301 return type(x)(_dict(vars(x)))
303 r = [_strip(v) for v in x]
304 return [v for v in r if not is_empty(v)]
307def uniq(x):
308 """Remove duplicate elements from a collection."""
310 s = set()
311 r = []
313 for y in x:
314 try:
315 if y not in s:
316 s.add(y)
317 r.append(y)
318 except TypeError:
319 if y not in r:
320 r.append(y)
322 return r
325##
328def to_int(x) -> int:
329 """Convert a value to an int or 0 if this fails."""
331 try:
332 return int(x)
333 except:
334 return 0
337def to_rounded_int(x) -> int:
338 """Round and convert a value to an int or 0 if this fails."""
340 try:
341 if isinstance(x, float):
342 return int(round(x))
343 return int(x)
344 except:
345 return 0
348def to_float(x) -> float:
349 """Convert a value to a float or 0.0 if this fails."""
351 try:
352 return float(x)
353 except:
354 return 0.0
357def to_str(x, encodings: list[str] = None) -> str:
358 """Convert a value to a string.
360 Args:
361 x: Value.
362 encodings: A list of acceptable encodings. If the value is bytes, try each encoding,
363 and return the first one which passes without errors.
365 Returns:
366 A string.
367 """
369 if isinstance(x, str):
370 return x
371 if x is None:
372 return ''
373 if not is_bytes(x):
374 return str(x)
375 if encodings:
376 for enc in encodings:
377 try:
378 return x.decode(encoding=enc, errors='strict')
379 except UnicodeDecodeError:
380 pass
381 return x.decode(encoding='utf-8', errors='ignore')
384def to_bytes(x, encoding='utf8') -> bytes:
385 """Convert a value to bytes by converting it to string and encoding."""
387 if is_bytes(x):
388 return bytes(x)
389 if x is None:
390 return b''
391 if not isinstance(x, str):
392 x = str(x)
393 return x.encode(encoding or 'utf8')
396def to_list(x, delimiter: str = ',') -> list:
397 """Convert a value to a list.
399 Args:
400 x: A value. Is it's a string, split it by the delimiter
401 delimiter:
403 Returns:
404 A list.
405 """
407 if isinstance(x, list):
408 return x
409 if is_empty(x):
410 return []
411 if is_bytes(x):
412 x = to_str(x)
413 if isinstance(x, str):
414 if delimiter:
415 ls = [s.strip() for s in x.split(delimiter)]
416 return [s for s in ls if s]
417 return [x]
418 if isinstance(x, (int, float, bool)):
419 return [x]
420 try:
421 return [s for s in x]
422 except TypeError:
423 return []
426def to_dict(x) -> dict:
427 """Convert a value to a dict. If the argument is an object, return its `dict`."""
429 if is_dict(x):
430 return x
431 if x is None:
432 return {}
433 try:
434 f = getattr(x, '_asdict', None)
435 if f:
436 return f()
437 return vars(x)
438 except TypeError:
439 raise ValueError(f'cannot convert {x!r} to dict')
442def to_json_value(x) -> Union[dict, list, str, int, float, bool, None]:
443 """Recursively convert a value to a JSON serializable type."""
445 if is_atom(x):
446 return x
447 if is_dict(x):
448 return {k: to_json_value(v) for k, v in x.items()}
449 if is_list(x):
450 return [to_json_value(v) for v in x]
451 if is_data_object(x):
452 return {k: to_json_value(v) for k, v in vars(x).items()}
453 return str(x)
456def to_upper_dict(x) -> dict:
457 x = to_dict(x)
458 return {k.upper(): v for k, v in x.items()}
461def to_lower_dict(x) -> dict:
462 x = to_dict(x)
463 return {k.lower(): v for k, v in x.items()}
466##
468_UID_DE_TRANS = {
469 ord('ä'): 'ae',
470 ord('ö'): 'oe',
471 ord('ü'): 'ue',
472 ord('ß'): 'ss',
473}
476def to_uid(x) -> str:
477 """Convert a value to an uid (alphanumeric string)."""
479 if not x:
480 return ''
481 x = to_str(x).lower().strip().translate(_UID_DE_TRANS)
482 x = re.sub(r'[^a-z0-9]+', '_', x)
483 return x.strip('_')
486def to_lines(txt: str, comment: str = None) -> list[str]:
487 """Convert a multiline string into a list of strings.
489 Strip each line, skip empty lines, if `comment` is given, also remove lines starting with it.
490 """
492 ls = []
494 for s in txt.splitlines():
495 if comment and comment in s:
496 s = s.split(comment)[0]
497 s = s.strip()
498 if s:
499 ls.append(s)
501 return ls
504##
507def parse_acl(acl):
508 """Parse an ACL config into an ACL.
510 Args:
511 acl: an ACL config. Can be given as a string ``allow X, allow Y, deny Z``,
512 or as a list of dicts ``{ role X type allow }, { role Y type deny }``,
513 or it can already be an ACL ``[1 X], [0 Y]``,
514 or it can be None.
516 Returns:
517 Access list.
518 """
520 if not acl:
521 return []
523 a = 'allow'
524 d = 'deny'
525 bits = {const.ALLOW, const.DENY}
526 err = 'invalid ACL'
528 access = []
530 if isinstance(acl, str):
531 for p in acl.strip().split(','):
532 s = p.strip().split()
533 if len(s) != 2:
534 raise ValueError(err)
535 if s[0] == a:
536 access.append((const.ALLOW, s[1]))
537 elif s[0] == d:
538 access.append((const.DENY, s[1]))
539 else:
540 raise ValueError(err)
541 return access
543 if not isinstance(acl, list):
544 raise ValueError(err)
546 if isinstance(acl[0], (list, tuple)):
547 try:
548 if all(len(s) == 2 and s[0] in bits for s in acl):
549 return acl
550 except (TypeError, IndexError):
551 pass
552 raise ValueError(err)
554 if isinstance(acl[0], dict):
555 for s in acl:
556 tk = s.get('type', '')
557 rk = s.get('role', '')
558 if not isinstance(rk, str):
559 raise ValueError(err)
560 if tk == a:
561 access.append((const.ALLOW, rk))
562 elif tk == d:
563 access.append((const.DENY, rk))
564 else:
565 raise ValueError(err)
566 return access
568 raise ValueError(err)
571##
573UID_DELIMITER = '::'
576def join_uid(parent_uid, object_uid):
577 p = parent_uid.split(UID_DELIMITER)
578 u = object_uid.split(UID_DELIMITER)
579 return p[-1] + UID_DELIMITER + u[-1]
582def split_uid(joined_uid: str) -> tuple[str, str]:
583 p, _, u = joined_uid.partition(UID_DELIMITER)
584 return p, u
587##
590def is_file(path):
591 return os.path.isfile(path)
594def is_dir(path):
595 return os.path.isdir(path)
598def read_file(path: str) -> str:
599 try:
600 with open(path, 'rt', encoding='utf8') as fp:
601 return fp.read()
602 except Exception as exc:
603 log.debug(f'error reading {path=} {exc=}')
604 raise
607def read_file_b(path: str) -> bytes:
608 try:
609 with open(path, 'rb') as fp:
610 return fp.read()
611 except Exception as exc:
612 log.debug(f'error reading {path=} {exc=}')
613 raise
616def write_file(path: str, s: str, user: int = None, group: int = None):
617 try:
618 with open(path, 'wt', encoding='utf8') as fp:
619 fp.write(s)
620 chown_default(path, user, group)
621 return path
622 except Exception as exc:
623 log.debug(f'error writing {path=} {exc=}')
624 raise
627def write_file_b(path: str, s: str | bytes, user: int = None, group: int = None):
628 if isinstance(s, str):
629 s = s.encode('utf8')
630 try:
631 with open(path, 'wb') as fp:
632 fp.write(s)
633 chown_default(path, user, group)
634 return path
635 except Exception as exc:
636 log.debug(f'error writing {path=} {exc=}')
637 raise
640def write_debug_file(path: str, s: str | bytes):
641 """Write a debug file with the given content."""
643 if isinstance(s, str):
644 s = s.encode('utf8')
645 try:
646 d = ensure_dir(f'{const.VAR_DIR}/debug')
647 with open(f'{d}/{path}', 'wb') as fp:
648 fp.write(s)
649 except Exception as exc:
650 log.debug(f'error writing debug {path=} {exc=}')
653def dirname(path):
654 return os.path.dirname(path)
657def ensure_dir(dir_path: str, base_dir: str = None, mode: int = 0o755, user: int = None, group: int = None) -> str:
658 """Check if a (possibly nested) directory exists and create if it does not.
660 Args:
661 dir_path: Path to a directory.
662 base_dir: Base directory.
663 mode: Directory creation mode.
664 user: Directory user (defaults to gws.c.UID)
665 group: Directory group (defaults to gws.c.GID)
667 Returns:
668 The absolute path to the directory.
669 """
671 if base_dir:
672 if os.path.isabs(dir_path):
673 raise ValueError(f'cannot use an absolute path {dir_path!r} with a base dir')
674 bpath = cast(bytes, os.path.join(base_dir.encode('utf8'), dir_path.encode('utf8')))
675 else:
676 if not os.path.isabs(dir_path):
677 raise ValueError(f'cannot use a relative path {dir_path!r} without a base dir')
678 bpath = dir_path.encode('utf8')
680 if os.path.isdir(bpath):
681 return bpath.decode('utf8')
683 parts = []
685 for p in bpath.split(b'/'):
686 parts.append(p)
687 path = b'/'.join(parts)
688 if path and not os.path.isdir(path):
689 os.mkdir(path, mode)
691 chown_default(bpath, user, group)
692 return bpath.decode('utf8')
695def ensure_system_dirs():
696 for d in const.ALL_DIRS:
697 ensure_dir(d)
700def chown_default(path, user=None, group=None):
701 try:
702 os.chown(path, user or const.UID, group or const.GID)
703 except OSError:
704 pass
707_ephemeral_state = dict(
708 last_check_time=0,
709 check_interval=2 * 3600,
710 max_age=2 * 3600,
711)
714def ephemeral_path(name: str) -> str:
715 """Return a new ephemeral path name."""
717 if stime() > _ephemeral_state['last_check_time'] + _ephemeral_state['check_interval']:
718 ephemeral_cleanup()
720 name = str(os.getpid()) + '_' + random_string(64) + '_' + name
721 return const.EPHEMERAL_DIR + '/' + name
724def ephemeral_dir(name: str) -> str:
725 """Create and return an ephemeral directory."""
727 return ensure_dir(const.EPHEMERAL_DIR + '/' + name)
729def ephemeral_set_max_age(seconds: int):
730 """Set the maximum age for ephemeral paths."""
732 _ephemeral_state['max_age'] = seconds
735def ephemeral_cleanup():
736 """Remove ephemeral paths older than max age."""
738 cnt = 0
739 ts = stime()
741 for de in os.scandir(const.EPHEMERAL_DIR):
742 try:
743 age = int(ts - de.stat().st_mtime)
744 if age > _ephemeral_state['max_age']:
745 if de.is_dir():
746 shutil.rmtree(de.path)
747 else:
748 os.unlink(de.path)
749 cnt += 1
750 except (OSError, FileNotFoundError):
751 pass
753 _ephemeral_state['last_check_time'] = ts
755 if cnt > 0:
756 log.debug(f'ephemeral_cleanup: {cnt}')
759def random_string(size: int) -> str:
760 """Generate a random string of length `size`."""
762 a = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
763 r = random.SystemRandom()
764 return ''.join(r.choice(a) for _ in range(size))
767class _FormatMapDefault:
768 def __init__(self, d, default):
769 self.d = d
770 self.default = default
772 def __getitem__(self, item):
773 val = self.d.get(item)
774 return val if val is not None else self.default
777def format_map(fmt: str, x: Union[dict, 'Data'], default: str = '') -> str:
778 return fmt.format_map(_FormatMapDefault(x, default))
781def sha256(x):
782 def _bytes(x):
783 if is_bytes(x):
784 return bytes(x)
785 if isinstance(x, (int, float, bool)):
786 return str(x).encode('utf8')
787 if isinstance(x, str):
788 return x.encode('utf8')
790 def _default(x):
791 if is_data_object(x):
792 return vars(x)
793 return str(x)
795 c = _bytes(x)
796 if c is None:
797 j = json.dumps(x, default=_default, sort_keys=True, ensure_ascii=True)
798 c = j.encode('utf8')
800 return hashlib.sha256(c).hexdigest()
803class cached_property:
804 """Decorator for a cached property."""
806 def __init__(self, fn):
807 self._fn = fn
808 self.__doc__ = getattr(fn, '__doc__')
810 def __get__(self, obj, objtype=None):
811 value = self._fn(obj)
812 setattr(obj, self._fn.__name__, value)
813 return value
816# application lock/globals are global to one application
817# server locks lock the whole server
818# server globals are pickled in /tmp
821_app_lock = threading.RLock()
824def app_lock(name=''):
825 return _app_lock
828_app_globals: dict = {}
831def get_app_global(name, init_fn):
832 if name in _app_globals:
833 return _app_globals[name]
835 with app_lock(name):
836 if name not in _app_globals:
837 _app_globals[name] = init_fn()
839 return _app_globals[name]
842def set_app_global(name, value):
843 with app_lock(name):
844 _app_globals[name] = value
845 return _app_globals[name]
848def delete_app_global(name):
849 with app_lock(name):
850 _app_globals.pop(name, None)
853##
856def serialize_to_path(obj, path):
857 tmp = path + random_string(64)
858 with open(tmp, 'wb') as fp:
859 pickle.dump(obj, fp)
860 os.replace(tmp, path)
861 chown_default(path)
862 return path
865def unserialize_from_path(path):
866 with open(path, 'rb') as fp:
867 return pickle.load(fp)
870_server_globals = {}
873def get_cached_object(name: str, life_time: int, init_fn):
874 uid = to_uid(name)
875 path = const.OBJECT_CACHE_DIR + '/' + uid
877 def _get():
878 if not os.path.isfile(path):
879 return
880 try:
881 age = int(time.time() - os.stat(path).st_mtime)
882 except OSError:
883 return
884 if age < life_time:
885 try:
886 obj = unserialize_from_path(path)
887 log.debug(f'get_cached_object {uid!r} {life_time=} {age=} - loaded')
888 return obj
889 except:
890 log.exception(f'get_cached_object {uid!r} LOAD ERROR')
892 obj = _get()
893 if obj:
894 return obj
896 with server_lock(uid):
897 obj = _get()
898 if obj:
899 return obj
901 obj = init_fn()
902 try:
903 serialize_to_path(obj, path)
904 log.debug(f'get_cached_object {uid!r} - stored')
905 except:
906 log.exception(f'get_cached_object {uid!r} STORE ERROR')
908 return obj
911def get_cached_file(path: str, life_time: int, init_fn) -> str:
912 uid = to_uid(path)
914 def _get():
915 if not os.path.isfile(path):
916 return
917 try:
918 age = int(time.time() - os.stat(path).st_mtime)
919 except OSError:
920 return
921 if age < life_time:
922 log.debug(f'get_cached_file {path!r} {life_time=} {age=} - loaded')
923 return path
925 p = _get()
926 if p:
927 return p
929 with server_lock(uid):
930 p = _get()
931 if p:
932 return p
934 tmp = path + random_string(64)
935 write_file_b(tmp, init_fn())
936 os.replace(tmp, path)
937 log.debug(f'get_cached_file {path!r} - stored')
939 return path
942def get_server_global(name: str, init_fn):
943 uid = to_uid(name)
944 path = const.GLOBALS_DIR + '/' + uid
946 def _get():
947 if uid in _server_globals:
948 log.debug(f'get_server_global {uid!r} - found')
949 return True
951 if os.path.isfile(path):
952 try:
953 _server_globals[uid] = unserialize_from_path(path)
954 log.debug(f'get_server_global {uid!r} - loaded')
955 return True
956 except:
957 log.exception(f'get_server_global {uid!r} LOAD ERROR')
959 if _get():
960 return _server_globals[uid]
962 with server_lock(uid):
963 if _get():
964 return _server_globals[uid]
966 _server_globals[uid] = init_fn()
968 try:
969 serialize_to_path(_server_globals[uid], path)
970 log.debug(f'get_server_global {uid!r} - stored')
971 except:
972 log.exception(f'get_server_global {uid!r} STORE ERROR')
974 return _server_globals[uid]
977class _FileLock:
978 _PAUSE = 2
979 _TIMEOUT = 60
981 def __init__(self, uid):
982 self.uid = to_uid(uid)
983 self.path = const.LOCKS_DIR + '/' + self.uid
985 def __enter__(self):
986 self.acquire()
987 log.debug(f'server lock {self.uid!r} ACQUIRED')
989 def __exit__(self, exc_type, exc_val, exc_tb):
990 self.release()
992 def acquire(self):
993 ts = time.time()
995 while True:
996 try:
997 fp = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
998 os.write(fp, bytes(os.getpid()))
999 os.close(fp)
1000 return
1001 except FileExistsError:
1002 pass
1004 t = time.time() - ts
1006 if t > self._TIMEOUT:
1007 raise ValueError('lock timeout', self.uid)
1009 log.debug(f'server lock {self.uid!r} WAITING time={t:.3f}')
1010 time.sleep(self._PAUSE)
1012 def release(self):
1013 try:
1014 os.unlink(self.path)
1015 log.debug(f'server lock {self.uid!r} RELEASED')
1016 except:
1017 log.exception(f'server lock {self.uid!r} RELEASE ERROR')
1020def server_lock(uid):
1021 return _FileLock(uid)
1024##
1027def action_url_path(name: str, **kwargs) -> str:
1028 ls = []
1030 for k, v in kwargs.items():
1031 if not is_empty(v):
1032 ls.append(urllib.parse.quote(k))
1033 ls.append(urllib.parse.quote(to_str(v)))
1035 path = const.SERVER_ENDPOINT + '/' + name
1036 if ls:
1037 path += '/' + '/'.join(ls)
1038 return path
1041##
1044def utime() -> float:
1045 """Unix time as a float number."""
1046 return time.time()
1049def stime() -> int:
1050 """Unix time as an integer number of seconds."""
1051 return int(time.time())
1054def sleep(n: float):
1055 """Sleep for n seconds."""
1056 time.sleep(n)
1059def mstime() -> int:
1060 """Unix time as an integer number of milliseconds."""
1061 return int(time.time() * 1000)
1064def microtime() -> int:
1065 """Unix time as an integer number of microseconds."""
1066 return int(time.time() * 1000000)