5 ------------------------------------------------------------------------------
6 This file is part of grepros - grep for ROS1 bag files and live topics.
7 Released under the BSD License.
12 ------------------------------------------------------------------------------
15 from __future__
import print_function
34 except ImportError: curses =
None
38 except ImportError: zstandard =
None
42 PATH_TYPES = (six.binary_type, six.text_type)
43 if six.PY34: PATH_TYPES += (importlib.import_module(
"pathlib").Path, )
45 STRING_TYPES = (six.binary_type, six.text_type)
47 TEXT_TYPES = (six.binary_type, six.text_type)
if six.PY2
else (six.text_type, )
51 """Highlight markers for matches in message values."""
62 EMPTY_REPL =
"%s''%s" % (START, END)
66 """Populates highlight markers with specified value."""
77 Prints to console, supports color output.
79 If configured with `apimode=True`, logs debugs and warnings to logger and raises errors.
82 STYLE_RESET =
"\x1b(B\x1b[m"
83 STYLE_HIGHLIGHT =
"\x1b[31m"
84 STYLE_LOWLIGHT =
"\x1b[38;2;105;105;105m"
85 STYLE_SPECIAL =
"\x1b[35m"
86 STYLE_SPECIAL2 =
"\x1b[36m"
87 STYLE_WARN =
"\x1b[33m"
88 STYLE_ERROR =
"\x1b[31m\x1b[2m"
90 DEBUG_START, DEBUG_END = STYLE_LOWLIGHT, STYLE_RESET
91 WARN_START, WARN_END = STYLE_WARN, STYLE_RESET
92 ERROR_START, ERROR_END = STYLE_ERROR, STYLE_RESET
115 Initializes printer, for terminal output or library mode.
117 For terminal output, initializes terminal colors, or disables colors if unsupported.
119 @param color True / False / None for auto-detect from TTY support;
120 will be disabled if terminal does not support colors
121 @param apimode whether to log debugs and warnings to logger and raise errors,
135 """Initializes terminal for color output, or disables color output if unsupported."""
136 if cls.
COLOR is not None:
return
138 try: cls.
WIDTH = shutil.get_terminal_size().columns
139 except Exception:
pass
143 if cls.
COLOR and not sys.stdout.isatty():
148 if sys.stdout.isatty()
or cls.
COLOR:
149 cls.
WIDTH = curses.initscr().getmaxyx()[1]
151 except Exception:
pass
164 def print(cls, text="", *args, **kwargs):
166 Prints text, formatted with args and kwargs.
168 @param __file file object to print to if not sys.stdout
169 @param __end line end to use if not linefeed "\n"
170 @param __once whether text should be printed only once
171 and discarded on any further calls (applies to unformatted text)
174 if kwargs.pop(
"__once",
False):
177 fileobj, end = kwargs.pop(
"__file", sys.stdout), kwargs.pop(
"__end",
"\n")
178 pref, suff = kwargs.pop(
"__prefix",
""), kwargs.pop(
"__suffix",
"")
179 if cls.
_LINEOPEN and "\n" in end: pref =
"\n" + pref
180 text = cls.
_format(text, *args, **kwargs)
185 print(pref + text + suff, end=end, file=fileobj)
186 not fileobj.isatty()
and fileobj.flush()
190 def error(cls, text="", *args, **kwargs):
192 Prints error to stderr, formatted with args and kwargs, in error colors if supported.
194 Raises exception instead if APIMODE.
197 raise Exception(cls.
_format(text, *args, __once=
False, **kwargs))
199 cls.
print(text, *args, **dict(kwargs, **KWS))
203 def warn(cls, text="", *args, **kwargs):
205 Prints warning to stderr, or logs to logger if APIMODE.
207 Text is formatted with args and kwargs, in warning colors if supported.
210 text = cls.
_format(text, *args, **kwargs)
211 if text: logging.getLogger(__name__).warning(text)
214 cls.
print(text, *args, **dict(kwargs, **KWS))
218 def debug(cls, text="", *args, **kwargs):
220 Prints debug text to stderr, or logs to logger if APIMODE.
222 Text is formatted with args and kwargs, in warning colors if supported.
225 text = cls.
_format(text, *args, **kwargs)
226 if text: logging.getLogger(__name__).
debug(text)
229 cls.
print(text, *args, **dict(kwargs, **KWS))
233 def log(cls, level, text="", *args, **kwargs):
235 Prints text to stderr, or logs to logger if APIMODE.
237 Text is formatted with args and kwargs, in level colors if supported.
239 @param level logging level like `logging.ERROR` or "ERROR"
242 text = cls.
_format(text, *args, **kwargs)
243 if text: logging.getLogger(__name__).
log(level, text)
245 level = logging.getLevelName(level)
246 if not isinstance(level, TEXT_TYPES): level = logging.getLevelName(level)
248 func(text, *args, **dict(kwargs, __file=sys.stderr))
253 """Ends current open line, if any."""
261 Returns text formatted with printf-style or format() arguments.
263 @param __once registers text, returns "" if text not unique
265 text, fmted = str(text),
False
266 if kwargs.get(
"__once"):
269 for k
in (
"__file",
"__end",
"__once",
"__prefix",
"__suffix"): kwargs.pop(k,
None)
270 try: text, fmted = (text % args
if args
else text), bool(args)
271 except Exception:
pass
272 try: text, fmted = (text % kwargs
if kwargs
else text), fmted
or bool(kwargs)
273 except Exception:
pass
274 try: text = text.format(*args, **kwargs)
if not fmted
and (args
or kwargs)
else text
275 except Exception:
pass
280 """Decompresses zstandard archives."""
283 EXTENSIONS = (
".zst",
".zstd")
286 ZSTD_MAGIC = b
"\x28\xb5\x2f\xfd"
292 Decompresses file to same directory, showing optional progress bar.
294 @return uncompressed file path
297 path2, bar, size, processed = os.path.splitext(path)[0],
None, os.path.getsize(path), 0
300 tpl =
" Decompressing %s (%s): {afterword}" % (os.path.basename(path), fmt(size))
303 ConsolePrinter.warn(
"Compressed file %s (%s), decompressing to %s.", path, fmt(size), path2)
304 bar
and bar.update(0).start()
306 with open(path,
"rb")
as f, open(path2,
"wb")
as g:
307 reader = zstandard.ZstdDecompressor().stream_reader(f)
309 chunk = reader.read(1048576)
313 processed += len(chunk)
314 bar
and (setattr(bar,
"afterword", fmt(processed)), bar.update(processed))
319 finally: bar
and (setattr(bar,
"pulse",
False), bar.update(processed).stop())
325 """Returns whether file is a recognized archive."""
326 result = os.path.isfile(path)
328 result = any(str(path).lower().endswith(x)
for x
in cls.
EXTENSIONS)
330 with open(path,
"rb")
as f:
337 """Returns the path without archive extension, if any."""
338 return os.path.splitext(path)[0]
if cls.
is_compressed(path)
else path
343 """Raises error if decompression library not available."""
344 if not zstandard:
raise Exception(
"zstandard not installed, cannot decompress")
350 A simple ASCII progress bar with a ticker thread
353 '[---------/ 36% ] Progressing text..'.
355 '[ ---- ] Progressing text..'.
358 def __init__(self, max=100, value=0, min=0, width=30, forechar="-",
359 backchar=" ", foreword="", afterword="", interval=1,
360 pulse=False, aftertemplate=" {afterword}"):
362 Creates a new progress bar, without drawing it yet.
364 @param max progress bar maximum value, 100%
365 @param value progress bar initial value
366 @param min progress bar minimum value, for 0%
367 @param width progress bar width (in characters)
368 @param forechar character used for filling the progress bar
369 @param backchar character used for filling the background
370 @param foreword text in front of progress bar
371 @param afterword text after progress bar
372 @param interval ticker thread interval, in seconds
373 @param pulse ignore value-min-max, use constant pulse instead
374 @param aftertemplate afterword format() template, populated with vars(self)
376 threading.Thread.__init__(self)
377 for k, v
in locals().items(): setattr(self, k, v)
if "self" != k
else 0
378 afterword = aftertemplate.format(**vars(self))
384 self.
bar =
"%s[%s%s]%s" % (foreword,
385 backchar
if pulse
else forechar,
386 backchar * (width - 3),
393 def update(self, value=None, draw=True, flush=False):
394 """Updates the progress bar value, and refreshes by default; returns self."""
395 if value
is not None: self.
value = min(self.max, max(self.min, value))
396 afterword = self.aftertemplate.format(**vars(self))
397 w_full = self.width - 2
400 bartext =
"%s[%s]%s" % (self.foreword,
401 self.forechar * (self.width - 2),
404 dash = self.forechar * max(1, int((self.width - 2) / 7))
408 elif pos >= self.width - 1:
409 dash = dash[:-(pos - self.width - 2)]
411 bar =
"[%s]" % (self.backchar * w_full)
413 pos1 = min(self.width - 1, pos + 1)
414 bar = bar[:pos1 - len(dash)] + dash + bar[pos1:]
415 bartext =
"%s%s%s" % (self.foreword, bar, afterword)
418 percent = int(round(100.0 * self.
value / (self.max
or 1)))
419 percent = 99
if percent == 100
and self.
value < self.max
else percent
420 w_done = max(1, int(round((percent / 100.0) * w_full)))
422 char_last = self.forechar
423 if draw
and w_done < w_full: char_last = next(self.
progresschar)
424 bartext =
"%s[%s%s%s]%s" % (
425 self.foreword, self.forechar * (w_done - 1), char_last,
426 self.backchar * (w_full - w_done), afterword)
428 centertxt =
" %2d%% " % percent
429 pos = len(self.foreword) + int(self.width / 2 - len(centertxt) / 2)
430 bartext = bartext[:pos] + centertxt + bartext[pos + len(centertxt):]
432 self.
printbar = bartext +
" " * max(0, len(self.
bar) - len(bartext))
433 self.
bar, prevbar = bartext, self.
bar
434 if draw
and (flush
or prevbar != self.
bar): self.
draw(flush)
440 Prints the progress bar, from the beginning of the current line.
442 @param flush add linefeed to end, forcing a new line for any next print
444 ConsolePrinter.print(
"\r" + self.
printbar, __end=
" ")
447 ConsolePrinter.print(
"\r" + self.
printbar, __end=
" ")
448 if flush: ConsolePrinter.flush()
455 time.sleep(self.interval)
464 """Wrapper for iterable value with specified fixed length."""
468 @param iterable any iterable value
469 @param count value to return for len(self), or callable to return value from
484 TextWrapper that supports custom substring widths in line width calculation.
486 Intended for wrapping text containing ANSI control codes.
487 Heavily refactored from Python standard library textwrap.TextWrapper.
491 SPACE_RGX = re.compile(
r"([%s]+)" % re.escape(
"\t\n\x0b\x0c\r "))
497 def __init__(self, width=80, subsequent_indent=" ", break_long_words=True,
498 drop_whitespace=False, max_lines=None, placeholder=" ...", custom_widths=None):
500 @param width default maximum width to wrap at, 0 disables
501 @param subsequent_indent string prepended to all consecutive lines
502 @param break_long_words break words longer than width
503 @param drop_whitespace drop leading and trailing whitespace from lines
504 @param max_lines count to truncate lines from
505 @param placeholder appended to last retained line when truncating
506 @param custom_widths {substring: len} to use in line width calculation
516 self.
customs = {s: l
for s, l
in (custom_widths
or {}).items()
if s}
527 """Returns a list of wrapped text lines, without linebreaks."""
530 for i, line
in enumerate(text.splitlines()):
531 chunks = [c
for c
in self.
SPACE_RGX.split(line)
if c]
539 or len(result) == self.
max_lines and not text.endswith(result[-1].
strip())):
541 if not result[-1].endswith(self.
placeholder.lstrip()):
548 """Decreases the configured width by given amount (number or string)."""
549 reserved = self.
strlen(reserved)
if isinstance(reserved, TEXT_TYPES)
else reserved
554 """Returns length of string, using custom substring widths."""
561 """Returns string with custom substrings and whitespace stripped."""
566 """Returns a list of lines joined from text chunks, wrapped to width."""
572 cur_line, cur_len = [], 0
580 l = self.
strlen(chunks[-1])
581 if cur_len + l <= width:
582 cur_line.append(chunks.pop())
587 if chunks
and self.
strlen(chunks[-1]) > width:
590 cur_len = sum(map(self.
strlen, cur_line))
593 cur_len -= len(cur_line[-1])
599 and len(chunks) == 1
and not self.
strip(chunks[0])) \
600 and cur_len <= width):
601 lines.append(indent +
"".join(cur_line))
607 if self.
strip(cur_line[-1]):
608 if cur_len + placeholder_len <= width:
609 lines.append(indent +
"".join(cur_line))
611 if len(cur_line) == 1:
612 lines.append(indent + cur_line[-1])
613 cur_len -= self.
strlen(cur_line[-1])
616 if not lines
or self.
strlen(lines[-1]) + placeholder_len > self.
width:
625 Breaks last chunk if not only containing a custom-width string,
626 else adds last chunk to current line if line still empty.
628 text = reversed_chunks[-1]
629 break_pos = 1
if width < 1
else width - cur_len
632 unbreakable_spans = [m.span()
for m
in self.
custom_rgx.finditer(text)]
633 text_in_spans = [x
for x
in unbreakable_spans
if x[0] <= break_pos < x[1]]
634 last_span = text_in_spans
and sorted(text_in_spans, key=
lambda x: -x[1])[0]
635 break_pos = last_span[1]
if last_span
else break_pos
636 breakable = 0 < break_pos < len(text)
639 cur_line.append(text[:break_pos])
640 reversed_chunks[-1] = text[break_pos:]
642 cur_line.append(reversed_chunks.pop())
647 """Drops or replaces trailing zeros and empty decimal separator, if any."""
648 return re.sub(
r"\.?0+$",
lambda x: len(x.group()) * replace, str(v))
652 """Returns text ellipsized if beyond limit."""
653 if limit <= 0
or len(text) < limit:
655 return text[:max(0, limit - len(ellipsis))] + ellipsis
660 Returns a copy of value as `argparse.Namespace`, with all keys uppercase.
662 Arguments with list/tuple values in defaults are ensured to have list/tuple values.
664 @param val `argparse.Namespace` or dictionary or `None`
665 @param defaults additional arguments to set to namespace if missing
666 @param dashify names of dictionary arguments where to replace
667 the first underscore in string keys with a dash
668 @param kwargs any and all argument overrides as keyword overrides
670 if val
is None or isinstance(val, dict): val = argparse.Namespace(**val
or {})
672 for k, v
in vars(val).items():
675 setattr(val, k.upper(), v)
676 for k, v
in ((k.upper(), v)
for k, v
in (defaults.items()
if defaults
else ())):
677 if not hasattr(val, k): setattr(val, k,
structcopy(v))
678 for k, v
in ((k.upper(), v)
for k, v
in kwargs.items()): setattr(val, k, v)
679 for k, v
in ((k.upper(), v)
for k, v
in (defaults.items()
if defaults
else ())):
680 if isinstance(v, (tuple, list))
and not isinstance(getattr(val, k), (tuple, list)):
681 setattr(val, k, [getattr(val, k)])
682 for arg
in (getattr(val, n,
None)
for n
in dashify
or ()):
683 for k
in (list(arg)
if isinstance(arg, dict)
else []):
684 if isinstance(k, six.text_type)
and "_" in k
and 0 < k.index(
"_") < len(k) - 1:
685 arg[k.replace(
"_",
"-", 1)] = arg.pop(k)
691 Filters string dictionary by keys and values, supporting * wildcards.
692 Dictionary values may be additional lists; keys with emptied lists are dropped.
694 Retains only entries that find a match (supports * wildcards);
695 if reverse, retains only entries that do not find a match.
700 for k, vv
in dct.items()
if not reverse
else ():
701 is_array = isinstance(vv, (list, tuple))
702 for v
in (vv
if is_array
else [vv]):
703 if (
not keys
or k
in keys
or any(p.match(k)
for p
in kpatterns)) \
704 and (
not values
or v
in values
or any(p.match(v)
for p
in vpatterns)):
705 result.setdefault(k, []).append(v)
if is_array
else result.update({k: v})
706 for k, vv
in dct.items()
if reverse
else ():
707 is_array = isinstance(vv, (list, tuple))
708 for v
in (vv
if is_array
else [vv]):
709 if (k
not in keys
and not any(p.match(k)
for p
in kpatterns)) \
710 and (v
not in values
and not any(p.match(v)
for p
in vpatterns)):
711 result.setdefault(k, []).append(v)
if is_array
else result.update({k: v})
715 def find_files(names=(), paths=(), extensions=(), skip_extensions=(), recurse=
False):
717 Yields filenames from current directory or given paths.
719 Seeks only files with given extensions if names not given.
720 Logs errors for names and paths not found.
722 @param names list of specific files to return (supports * wildcards)
723 @param paths list of paths to look under, if not using current directory
724 @param extensions list of extensions to select if not using names, as (".ext1", ..)
725 @param skip_extensions list of extensions to skip if not using names, as (".ext1", ..)
726 @param recurse whether to recurse into subdirectories
728 namesfound, pathsfound = set(), set()
729 def iter_files(directory):
730 """Yields matching filenames from path."""
731 if os.path.isfile(directory):
732 ConsolePrinter.log(logging.ERROR,
"%s: Is a file", directory)
734 for path
in sorted(glob.glob(directory)):
735 pathsfound.add(directory)
737 p = n
if not paths
or os.path.isabs(n)
else os.path.join(path, n)
738 for f
in (f
for f
in glob.glob(p)
if "*" not in n
739 or not any(map(f.endswith, skip_extensions))):
741 ConsolePrinter.log(logging.ERROR,
"%s: Is a directory", f)
745 for root, _, files
in os.walk(path)
if not names
else ():
746 for f
in (os.path.join(root, f)
for f
in sorted(files)
747 if (
not extensions
or any(map(f.endswith, extensions)))
748 and not any(map(f.endswith, skip_extensions))):
754 for f
in (f
for p
in paths
or [
"."]
for f
in iter_files(p)):
755 if os.path.abspath(f)
not in processed:
756 processed.add(os.path.abspath(f))
757 if not paths
and f == os.path.join(
".", os.path.basename(f)):
758 f = os.path.basename(f)
761 for path
in (p
for p
in paths
if p
not in pathsfound):
762 ConsolePrinter.log(logging.ERROR,
"%s: No such directory", path)
763 for name
in (n
for n
in names
if n
not in namesfound):
764 ConsolePrinter.log(logging.ERROR,
"%s: No such file", name)
768 """Formats the datetime.timedelta as "3d 40h 23min 23.1sec"."""
769 dd, rem = divmod(delta.total_seconds(), 24*3600)
770 hh, rem = divmod(rem, 3600)
771 mm, ss = divmod(rem, 60)
773 for c, n
in (dd,
"d"), (hh,
"h"), (mm,
"min"), (ss,
"sec"):
774 f =
"%d" % c
if "sec" != n
else drop_zeros(round(c, 9))
775 if f !=
"0": items += [f + n]
776 return " ".join(items
or [
"0sec"])
780 """Returns a formatted byte size (like 421.40 MB), trailing zeros optionally removed."""
783 UNITS = [(
"bytes",
"byte")[1 == size]] + [x +
"B" for x
in "KMGTPEZY"]
784 exponent = min(int(math.log(size, 1024)), len(UNITS) - 1)
785 result =
"%.*f" % (precision, size / (1024. ** exponent))
786 result +=
"" if precision > 0
else "."
787 result = (
drop_zeros(result)
if strip
else result) + inter + UNITS[exponent]
792 """Returns ISO datetime from UNIX timestamp."""
793 return datetime.datetime.fromtimestamp(stamp).isoformat(sep=
" ")
798 Returns the fully namespaced name for a Python module, class, function or object.
800 E.g. "my.thing" or "my.module.MyCls" or "my.module.MyCls.my_method"
801 or "my.module.MyCls<0x1234abcd>" or "my.module.MyCls<0x1234abcd>.my_method".
803 namer =
lambda x: getattr(x,
"__qualname__", getattr(x,
"__name__",
""))
804 if inspect.ismodule(obj):
return namer(obj)
805 if inspect.isclass(obj):
return ".".join((obj.__module__, namer(obj)))
806 if inspect.isroutine(obj):
807 parts, self = [], six.get_method_self(obj)
808 if self
is not None: parts.extend((
get_name(self), obj.__name__))
809 elif hasattr(obj,
"im_class"): parts.extend((
get_name(obj.im_class), namer(obj)))
810 else: parts.extend((obj.__module__, namer(obj)))
811 return ".".join(parts)
813 return "%s.%s<0x%x>" % (cls.__module__, namer(cls), id(obj))
817 """Returns whether function supports taking specified argument by name."""
818 spec = getattr(inspect,
"getfullargspec", getattr(inspect,
"getargspec",
None))(func)
819 return name
in spec.args
or name
in getattr(spec,
"kwonlyargs", ())
or \
820 getattr(spec,
"varkw",
None)
or getattr(spec,
"keywords",
None)
825 Returns imported module, or identifier from imported namespace; raises on error.
827 @param name Python module name like "my.module"
828 or module namespace identifier like "my.module.Class"
830 result, parts =
None, name.split(
".")
831 for i, item
in enumerate(parts):
832 path, success =
".".join(parts[:i + 1]),
False
833 try: result, success = importlib.import_module(path),
True
834 except ImportError:
pass
835 if not success
and i:
836 try: result, success = getattr(result, item),
True
837 except AttributeError:
pass
839 raise ImportError(
"No module or identifier named %r" % path)
844 """Returns whether value is iterable."""
846 except Exception:
return False
851 """Returns whether value is a file-like object."""
852 try:
return isinstance(value, (file, io.IOBase))
853 except NameError:
return isinstance(value, io.IOBase)
857 """Creates directory structure for path if not already existing."""
858 parts, accum = list(filter(bool, os.path.realpath(path).split(os.sep))), []
860 accum.append(parts.pop(0))
861 curpath = os.path.join(os.sep, accum[0] + os.sep, *accum[1:])
862 if not os.path.exists(curpath):
868 Returns a deep copy of a standard data structure (dict, list, set, tuple),
869 other object types reused instead of copied.
871 COLLECTIONS = (dict, list, set, tuple)
874 if isinstance(x, argparse.Namespace): x = vars(x)
875 if not isinstance(x, COLLECTIONS):
return memo.update([(id(x), x)])
876 for y
in sum(map(list, x.items()), [])
if isinstance(x, dict)
else x: collect(y)
878 return copy.deepcopy(value, memo)
882 """Returns a results-caching wrapper for the function, cache used if arguments hashable."""
884 def inner(*args, **kwargs):
885 key = args + sum(kwargs.items(), ())
887 except Exception:
return func(*args, **kwargs)
889 cache[key] = func(*args, **kwargs)
891 return functools.update_wrapper(inner, func)
895 """Merges d2 into d1, recursively for nested dicts."""
896 for k, v
in d2.items():
897 if k
in d1
and isinstance(v, dict)
and isinstance(d1[k], dict):
905 Returns a sorted list of (start, end) spans with overlapping spans merged.
907 @param join_blanks whether to merge consecutive zero-length spans,
908 e.g. [(0, 0), (1, 1)] -> [(0, 1)]
910 result = sorted(spans)
911 if result
and join_blanks:
912 blanks = [(a, b)
for a, b
in result
if a == b]
913 others = [(a, b)
for a, b
in result
if a != b]
914 others.extend(blanks[:1])
915 for span
in blanks[1:]:
916 if span[0] == others[-1][1] + 1:
917 others[-1] = (others[-1][0], span[1])
920 result = sorted(others)
921 result, rest = result[:1], result[1:]
923 if span[0] <= result[-1][1]:
924 result[-1] = (result[-1][0], max(span[1], result[-1][1]))
931 """Returns datetime object from ISO datetime string (may be partial). Raises if invalid."""
932 BASE = re.sub(
r"\D",
"", datetime.datetime.min.isoformat())
933 text = re.sub(
r"\D",
"", text)
934 text += BASE[len(text):]
if text
else ""
935 dt = datetime.datetime.strptime(text[:len(BASE)],
"%Y%m%d%H%M%S")
936 return dt + datetime.timedelta(microseconds=int(text[len(BASE):]
or "0"))
941 Returns an integer parsed from text, raises on error.
943 @param value text or binary string to parse, may contain abbrevations like "12K"
944 @param suffixes a dictionary of multipliers like {"K": 1024}, case-insensitive
946 value, suffix = value.decode()
if isinstance(value, six.binary_type)
else value,
None
948 suffix = next((k
for k, v
in suffixes.items()
if value.lower().endswith(k.lower())),
None)
949 value = value[:-len(suffix)]
if suffix
else value
950 return int(float(value) * (suffixes[suffix]
if suffix
else 1))
953 def plural(word, items=None, numbers=True, single="1", sep=",", pref="", suf=""):
955 Returns the word as 'count words', or '1 word' if count is 1,
956 or 'words' if count omitted.
958 @param items item collection or count,
959 or None to get just the plural of the word
960 @param numbers if False, count is omitted from final result
961 @param single prefix to use for word if count is 1, e.g. "a"
962 @param sep thousand-separator to use for count
963 @param pref prefix to prepend to count, e.g. "~150"
964 @param suf suffix to append to count, e.g. "150+"
966 count = len(items)
if hasattr(items,
"__len__")
else items
or 0
967 isupper = word[-1:].isupper()
968 suffix =
"es" if word
and word[-1:].lower()
in "xyz" \
969 and not word[-2:].lower().endswith(
"ay") \
970 else "s" if word
else ""
971 if isupper: suffix = suffix.upper()
972 if count != 1
and "es" == suffix
and "y" == word[-1:].lower():
973 word = word[:-1] + (
"I" if isupper
else "i")
974 result = word + (
"" if 1 == count
else suffix)
975 if numbers
and items
is not None:
976 if 1 == count: fmtcount = single
977 elif not count: fmtcount =
"0"
978 elif sep: fmtcount =
"".join([
979 x + (sep
if i
and not i % 3
else "")
for i, x
in enumerate(str(count)[::-1])
981 else: fmtcount = str(count)
983 fmtcount = pref + fmtcount + suf
984 result =
"%s %s" % (single
if 1 == count
else fmtcount, result)
985 return result.strip()
990 Returns a unique version of the path.
992 If a file or directory with the same name already exists, returns a unique
993 version (e.g. "/tmp/my.2.file" if ""/tmp/my.file" already exists).
995 @param empty_ok whether to ignore existence if file is empty
998 if "linux2" == sys.platform
and six.PY2
and isinstance(result, six.text_type) \
999 and "utf-8" != sys.getfilesystemencoding():
1000 result = result.encode(
"utf-8")
1001 if os.path.isfile(result)
and empty_ok
and not os.path.getsize(result):
1002 return result
if isinstance(result, STRING_TYPES)
else str(result)
1003 path, name = os.path.split(result)
1004 base, ext = os.path.splitext(name)
1006 name = base[:255 - len(ext) - 2] +
".." + ext
1007 result = os.path.join(path, name)
1009 while os.path.exists(result):
1010 suffix =
".%s%s" % (counter, ext)
1011 name = base + suffix
1013 name = base[:255 - len(suffix) - 2] +
".." + suffix
1014 result = os.path.join(path, name)
1021 Returns whether stream or file path can be read from and/or written to as binary.
1023 Prints or raises error if not.
1025 Tries to open file in append mode if verifying path writability,
1026 auto-creating missing directories if any, will delete any file or directory created.
1028 @param f file path, or stream
1029 @param mode "r" for readable, "w" for writable, "a" for readable and writable
1031 result, op =
True,
""
1035 if mode
in (
"r",
"a"):
1036 op =
" reading from"
1037 result = isinstance(f.read(1), bytes)
1038 if result
and mode
in (
"w",
"a"):
1040 result, _ =
True, f.write(b
"")
1043 except Exception
as e:
1044 ConsolePrinter.log(logging.ERROR,
"Error%s %s: %s", op, type(f).__name__, e)
1047 present, paths_created = os.path.exists(f), []
1049 if not present
and mode
in (
"w",
"a"):
1051 path = os.path.realpath(os.path.dirname(f))
1052 parts, accum = [x
for x
in path.split(os.sep)
if x], []
1054 accum.append(parts.pop(0))
1055 curpath = os.path.join(os.sep, accum[0] + os.sep, *accum[1:])
1056 if not os.path.exists(curpath):
1058 paths_created.append(curpath)
1059 elif not present
and "r" == mode:
1061 with open(f, {
"r":
"rb",
"w":
"ab",
"a":
"ab+"}[mode])
as g:
1062 if mode
in (
"r",
"a"):
1063 op =
" reading from"
1064 result = isinstance(g.read(1), bytes)
1065 if result
and mode
in (
"w",
"a"):
1067 result, _ =
True, g.write(b
"")
1069 except Exception
as e:
1070 ConsolePrinter.log(logging.ERROR,
"Error%s %s: %s", f, e)
1075 except Exception:
pass
1076 for path
in paths_created[::-1]:
1078 except Exception:
pass
1083 Returns plain wildcard like "foo*bar" as re.Pattern("foo.*bar", re.I).
1085 @param end whether pattern should match until end (adds $)
1087 suff =
"$" if end
else ""
1088 return re.compile(
".*".join(map(re.escape, text.split(
"*"))) + suff, re.I)
1092 "PATH_TYPES",
"ConsolePrinter",
"Decompressor",
"MatchMarkers",
"ProgressBar",
"TextWrapper",
1093 "drop_zeros",
"ellipsize",
"ensure_namespace",
"filter_dict",
"find_files",
1094 "format_bytes",
"format_stamp",
"format_timedelta",
"get_name",
"has_arg",
"import_item",
1095 "is_iterable",
"is_stream",
"makedirs",
"memoize",
"merge_dicts",
"merge_spans",
1096 "parse_datetime",
"parse_number",
"plural",
"unique_path",
"verify_io",
"wildcard_to_regex",