comparison mupdf-source/scripts/jlib.py @ 2:b50eed0cc0ef upstream

ADD: MuPDF v1.26.7: the MuPDF source as downloaded by a default build of PyMuPDF 1.26.4. The directory name has changed: no version number in the expanded directory now.
author Franz Glasner <fzglas.hg@dom66.de>
date Mon, 15 Sep 2025 11:43:07 +0200
parents
children 5ab937c03c27 aa33339d6b8a
comparison
equal deleted inserted replaced
1:1d09e1dec1d9 2:b50eed0cc0ef
1 import calendar
2 import codecs
3 import inspect
4 import io
5 import os
6 import platform
7 import re
8 import shlex
9 import shutil
10 import subprocess
11 import sys
12 import tarfile
13 import textwrap
14 import time
15 import traceback
16 import types
17 import typing
18
19
20 def place( frame_record=1):
21 '''
22 Useful debugging function - returns representation of source position of
23 caller.
24
25 frame_record:
26 Integer number of frames up stack, or a `FrameInfo` (for example from
27 `inspect.stack()`).
28 '''
29 if isinstance( frame_record, int):
30 frame_record = inspect.stack( context=0)[ frame_record+1]
31 filename = frame_record.filename
32 line = frame_record.lineno
33 function = frame_record.function
34 ret = os.path.split( filename)[1] + ':' + str( line) + ':' + function + ':'
35 if 0: # lgtm [py/unreachable-statement]
36 tid = str( threading.currentThread())
37 ret = '[' + tid + '] ' + ret
38 return ret
39
40
41 def text_nv( text, caller=1):
42 '''
43 Returns `text` with special handling of `{<expression>}` items
44 constituting an enhanced and deferred form of Python f-strings
45 (https://docs.python.org/3/reference/lexical_analysis.html#f-strings).
46
47 text:
48 String containing `{<expression>}` items.
49 caller:
50 If an `int`, the number of frames to step up when looking for file:line
51 information or evaluating expressions.
52
53 Otherwise should be a frame record as returned by `inspect.stack()[]`.
54
55 `<expression>` items are evaluated in `caller`'s context using `eval()`.
56
57 If `expression` ends with `=` or has a `=` before `!` or `:`, this
58 character is removed and we prefix the result with `<expression>`=.
59
60 >>> x = 45
61 >>> y = 'hello'
62 >>> text_nv( 'foo {x} {y=}')
63 "foo 45 y='hello'"
64
65 `<expression>` can also use ':' and '!' to control formatting, like
66 `str.format()`. We support '=' being before (PEP 501) or after the ':' or
67 `'!'.
68
69 >>> x = 45
70 >>> y = 'hello'
71 >>> text_nv( 'foo {x} {y} {y!r=}')
72 "foo 45 hello y='hello'"
73 >>> text_nv( 'foo {x} {y=!r}')
74 "foo 45 y='hello'"
75
76 If `<expression>` starts with '=', this character is removed and we show
77 each space-separated item in the remaining text as though it was appended
78 with '='.
79
80 >>> foo = 45
81 >>> y = 'hello'
82 >>> text_nv('{=foo y}')
83 "foo=45 y='hello'"
84
85 Also see https://peps.python.org/pep-0501/.
86
87 Check handling of ':' within brackets:
88
89 >>> text_nv('{time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(1670059297))=}')
90 'time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(1670059297))=\\'2022-12-03 09:21:37\\''
91 '''
92 if isinstance( caller, int):
93 frame_record = inspect.stack()[ caller]
94 else:
95 frame_record = caller
96 frame = frame_record.frame
97 try:
98 def get_items():
99 '''
100 Yields `(pre, item)`, where `item` is contents of next `{...}` or
101 `None`, and `pre` is preceding text.
102 '''
103 pos = 0
104 pre = ''
105 while 1:
106 if pos == len( text):
107 yield pre, None
108 break
109 rest = text[ pos:]
110 if rest.startswith( '{{') or rest.startswith( '}}'):
111 pre += rest[0]
112 pos += 2
113 elif text[ pos] == '{':
114 close = text.find( '}', pos)
115 if close < 0:
116 raise Exception( 'After "{" at offset %s, cannot find closing "}". text is: %r' % (
117 pos, text))
118 text2 = text[ pos+1 : close]
119 if text2.startswith('='):
120 text2 = text2[1:]
121 for i, text3 in enumerate(text2.split()):
122 pre2 = ' ' if i else pre
123 yield pre2, text3 + '='
124 else:
125 yield pre, text[ pos+1 : close]
126 pre = ''
127 pos = close + 1
128 else:
129 pre += text[ pos]
130 pos += 1
131
132 ret = ''
133 for pre, item in get_items():
134 ret += pre
135 nv = False
136 if item:
137 if item.endswith( '='):
138 nv = True
139 item = item[:-1]
140 expression, tail = text_split_last_of( item, ')]!:')
141 if tail.startswith( (')', ']')):
142 expression, tail = item, ''
143 if expression.endswith('='):
144 # Basic PEP 501 support.
145 nv = True
146 expression = expression[:-1]
147 if nv and not tail:
148 # Default to !r as in PEP 501.
149 tail = '!r'
150 try:
151 value = eval( expression, frame.f_globals, frame.f_locals)
152 value_text = ('{0%s}' % tail).format( value)
153 except Exception as e:
154 value_text = '{??Failed to evaluate %r in context %s:%s; expression=%r tail=%r: %s}' % (
155 expression,
156 frame_record.filename,
157 frame_record.lineno,
158 expression,
159 tail,
160 e,
161 )
162 if nv:
163 ret += '%s=' % expression
164 ret += value_text
165
166 return ret
167
168 finally:
169 del frame # lgtm [py/unnecessary-delete]
170
171
172 class LogPrefixTime:
173 def __init__( self, date=False, time_=True, elapsed=False):
174 self.date = date
175 self.time = time_
176 self.elapsed = elapsed
177 self.t0 = time.time()
178 def __call__( self):
179 ret = ''
180 if self.date:
181 ret += time.strftime( ' %F')
182 if self.time:
183 ret += time.strftime( ' %T')
184 if self.elapsed:
185 ret += ' (+%s)' % time_duration( time.time() - self.t0, s_format='%.1f')
186 if ret:
187 ret = ret.strip() + ': '
188 return ret
189
190 class LogPrefixFileLine:
191 def __call__( self, caller):
192 if isinstance( caller, int):
193 caller = inspect.stack()[ caller]
194 return place( caller) + ' '
195
196 class LogPrefixScopes:
197 '''
198 Internal use only.
199 '''
200 def __init__( self):
201 self.items = []
202 def __call__( self):
203 ret = ''
204 for item in self.items:
205 if callable( item):
206 item = item()
207 ret += item
208 return ret
209
210
211 class LogPrefixScope:
212 '''
213 Can be used to insert scoped prefix to log output.
214 '''
215 def __init__( self, prefix):
216 self.prefix = prefix
217 def __enter__( self):
218 g_log_prefix_scopes.items.append( self.prefix)
219 def __exit__( self, exc_type, exc_value, traceback):
220 global g_log_prefix
221 g_log_prefix_scopes.items.pop()
222
223
224 g_log_delta = 0
225
226 class LogDeltaScope:
227 '''
228 Can be used to temporarily change verbose level of logging.
229
230 E.g to temporarily increase logging::
231
232 with jlib.LogDeltaScope(-1):
233 ...
234 '''
235 def __init__( self, delta):
236 self.delta = delta
237 global g_log_delta
238 g_log_delta += self.delta
239 def __enter__( self):
240 pass
241 def __exit__( self, exc_type, exc_value, traceback):
242 global g_log_delta
243 g_log_delta -= self.delta
244
245 # Special item that can be inserted into <g_log_prefixes> to enable
246 # temporary addition of text into log prefixes.
247 #
248 g_log_prefix_scopes = LogPrefixScopes()
249
250 # List of items that form prefix for all output from log().
251 #
252 g_log_prefixes = [
253 LogPrefixTime( time_=False, elapsed=True),
254 g_log_prefix_scopes,
255 LogPrefixFileLine(),
256 ]
257
258
259 _log_text_line_start = True
260
261 def log_text( text=None, caller=1, nv=True, raw=False, nl=True):
262 '''
263 Returns log text, prepending all lines with text from `g_log_prefixes`.
264
265 text:
266 The text to output.
267 caller:
268 If an int, the number of frames to step up when looking for file:line
269 information or evaluating expressions.
270
271 Otherwise should be a frame record as returned by `inspect.stack()[]`.
272 nv:
273 If true, we expand `{...}` in `text` using `jlib.text_nv()`.
274 raw:
275 If true we don't terminate with newlines and store state in
276 `_log_text_line_start` so that we generate correct content if sent sent
277 partial lines.
278 nl:
279 If true (the default) we terminate text with a newline if not already
280 present. Ignored if `raw` is true.
281 '''
282 if isinstance( caller, int):
283 caller += 1
284 # Construct line prefix.
285 prefix = ''
286 for p in g_log_prefixes:
287 if callable( p):
288 if isinstance( p, LogPrefixFileLine):
289 p = p(caller)
290 else:
291 p = p()
292 prefix += p
293
294 if text is None:
295 return prefix
296
297 # Expand {...} using our enhanced f-string support.
298 if nv:
299 text = text_nv( text, caller)
300
301 # Prefix each line. If <raw> is false, we terminate the last line with a
302 # newline. Otherwise we use _log_text_line_start to remember whether we are
303 # at the beginning of a line.
304 #
305 global _log_text_line_start
306 text2 = ''
307 pos = 0
308 while 1:
309 if pos == len(text):
310 break
311 if not raw or _log_text_line_start:
312 text2 += prefix
313 nlp = text.find('\n', pos)
314 if nlp == -1:
315 text2 += text[pos:]
316 if not raw and nl:
317 text2 += '\n'
318 pos = len(text)
319 else:
320 text2 += text[pos:nlp+1]
321 pos = nlp+1
322 if raw:
323 _log_text_line_start = (nlp >= 0)
324 return text2
325
326
327 s_log_levels_cache = dict()
328 s_log_levels_items = []
329
330 def log_levels_find( caller):
331 if not s_log_levels_items:
332 return 0
333
334 tb = traceback.extract_stack( None, 1+caller)
335 if len(tb) == 0:
336 return 0
337 filename, line, function, text = tb[0]
338
339 key = function, filename, line,
340 delta = s_log_levels_cache.get( key)
341
342 if delta is None:
343 # Calculate and populate cache.
344 delta = 0
345 for item_function, item_filename, item_delta in s_log_levels_items:
346 if item_function and not function.startswith( item_function):
347 continue
348 if item_filename and not filename.startswith( item_filename):
349 continue
350 delta = item_delta
351 break
352
353 s_log_levels_cache[ key] = delta
354
355 return delta
356
357
358 def log_levels_add( delta, filename_prefix, function_prefix):
359 '''
360 `jlib.log()` calls from locations with filenames starting with
361 `filename_prefix` and/or function names starting with `function_prefix`
362 will have `delta` added to their level.
363
364 Use -ve `delta` to increase verbosity from particular filename or function
365 prefixes.
366 '''
367 log( 'adding level: {filename_prefix=!r} {function_prefix=!r}')
368
369 # Sort in reverse order so that long functions and filename specs come
370 # first.
371 #
372 s_log_levels_items.append( (function_prefix, filename_prefix, delta))
373 s_log_levels_items.sort( reverse=True)
374
375
376 s_log_out = sys.stdout
377
378 def log( text, level=0, caller=1, nv=True, out=None, raw=False):
379 '''
380 Writes log text, with special handling of `{<expression>}` items in `text`
381 similar to python3's f-strings.
382
383 text:
384 The text to output.
385 level:
386 Lower values are more verbose.
387 caller:
388 How many frames to step up to get caller's context when evaluating
389 file:line information and/or expressions. Or frame record as returned
390 by `inspect.stack()[]`.
391 nv:
392 If true, we expand `{...}` in `text` using `jlib.text_nv()`.
393 out:
394 Where to send output. If None we use sys.stdout.
395 raw:
396 If true we don't ensure output text is terminated with a newline. E.g.
397 use by `jlib.system()` when sending us raw output which is not
398 line-based.
399
400 `<expression>` is evaluated in our caller's context (`n` stack frames up)
401 using `eval()`, and expanded to `<expression>` or `<expression>=<value>`.
402
403 If `<expression>` ends with '=', this character is removed and we prefix
404 the result with <expression>=.
405
406 E.g.::
407
408 x = 45
409 y = 'hello'
410 text_nv( 'foo {x} {y=}')
411
412 returns::
413
414 foo 45 y=hello
415
416 `<expression>` can also use ':' and '!' to control formatting, like
417 `str.format()`.
418 '''
419 if out is None:
420 out = s_log_out
421 level += g_log_delta
422 if isinstance( caller, int):
423 caller += 1
424 level += log_levels_find( caller)
425 if level <= 0:
426 text = log_text( text, caller, nv=nv, raw=raw)
427 try:
428 out.write( text)
429 except UnicodeEncodeError:
430 # Retry, ignoring errors by encoding then decoding with
431 # errors='replace'.
432 #
433 out.write('[***write encoding error***]')
434 text_encoded = codecs.encode(text, out.encoding, errors='replace')
435 text_encoded_decoded = codecs.decode(text_encoded, out.encoding, errors='replace')
436 out.write(text_encoded_decoded)
437 out.write('[/***write encoding error***]')
438 out.flush()
439
440 def log_raw( text, level=0, caller=1, nv=False, out=None):
441 '''
442 Like `jlib.log()` but defaults to `nv=False` so any `{...}` are not
443 evaluated as expressions.
444
445 Useful for things like::
446
447 jlib.system(..., out=jlib.log_raw)
448 '''
449 log( text, level=0, caller=caller+1, nv=nv, out=out)
450
451 def log0( text, caller=1, nv=True, out=None):
452 '''
453 Most verbose log. Same as log().
454 '''
455 log( text, level=0, caller=caller+1, nv=nv, out=out)
456
457 def log1( text, caller=1, nv=True, out=None):
458 log( text, level=1, caller=caller+1, nv=nv, out=out)
459
460 def log2( text, caller=1, nv=True, out=None):
461 log( text, level=2, caller=caller+1, nv=nv, out=out)
462
463 def log3( text, caller=1, nv=True, out=None):
464 log( text, level=3, caller=caller+1, nv=nv, out=out)
465
466 def log4( text, caller=1, nv=True, out=None):
467 log( text, level=4, caller=caller+1, nv=nv, out=out)
468
469 def log5( text, caller=1, nv=True, out=None):
470 '''
471 Least verbose log.
472 '''
473 log( text, level=5, caller=caller+1, nv=nv, out=out)
474
475 def logx( text, caller=1, nv=True, out=None):
476 '''
477 Does nothing, useful when commenting out a log().
478 '''
479 pass
480
481
482 _log_interval_t0 = 0
483
484 def log_interval( text, level=0, caller=1, nv=True, out=None, raw=False, interval=10):
485 '''
486 Like `jlib.log()` but outputs no more than one diagnostic every `interval`
487 seconds, and `text` can be a callable taking no args and returning a
488 string.
489 '''
490 global _log_interval_t0
491 t = time.time()
492 if t - _log_interval_t0 > interval:
493 _log_interval_t0 = t
494 if callable( text):
495 text = text()
496 log( text, level=level, caller=caller+1, nv=nv, out=out, raw=raw)
497
498
499 def log_levels_add_env( name='JLIB_log_levels'):
500 '''
501 Added log levels encoded in an environmental variable.
502 '''
503 t = os.environ.get( name)
504 if t:
505 for ffll in t.split( ','):
506 ffl, delta = ffll.split( '=', 1)
507 delta = int( delta)
508 ffl = ffl.split( ':')
509 if 0: # lgtm [py/unreachable-statement]
510 pass
511 elif len( ffl) == 1:
512 filename = ffl
513 function = None
514 elif len( ffl) == 2:
515 filename, function = ffl
516 else:
517 assert 0
518 log_levels_add( delta, filename, function)
519
520
521 class TimingsItem:
522 '''
523 Helper for `Timings` class.
524 '''
525 def __init__( self, name):
526 self.name = name
527 self.children = dict()
528 self.t_begin = None
529 self.t = 0
530 self.n = 0
531 def begin( self, t):
532 assert self.t_begin is None
533 self.t_begin = t
534 def end( self, t):
535 assert self.t_begin is not None, f't_begin is None, .name={self.name}'
536 self.t += t - self.t_begin
537 self.n += 1
538 self.t_begin = None
539 def __str__( self):
540 return f'[name={self.name} t={self.t} n={self.n} t_begin={self.t_begin}]'
541 def __repr__( self):
542 return self.__str__()
543
544 class Timings:
545 '''
546 Allows gathering of hierarchical timing information. Can also generate
547 useful diagnostics.
548
549 Caller can generate a tree of `TimingsItem` items via our `begin()` and
550 `end()` methods.
551
552 >>> ts = Timings()
553 >>> ts.begin('a')
554 >>> time.sleep(0.1)
555 >>> ts.begin('b')
556 >>> time.sleep(0.2)
557 >>> ts.begin('c')
558 >>> time.sleep(0.3)
559 >>> ts.end('c')
560 >>> ts.begin('c')
561 >>> time.sleep(0.3)
562 >>> ts.end('b') # will also end 'c'.
563 >>> ts.begin('d')
564 >>> ts.begin('e')
565 >>> time.sleep(0.1)
566 >>> ts.end_all() # will end everything.
567 >>> print(ts)
568 Timings (in seconds):
569 1.0 a
570 0.8 b
571 0.6/2 c
572 0.1 d
573 0.1 e
574 <BLANKLINE>
575
576 One can also use as a context manager:
577
578 >>> ts = Timings()
579 >>> with ts( 'foo'):
580 ... time.sleep(1)
581 ... with ts( 'bar'):
582 ... time.sleep(1)
583 >>> print( ts)
584 Timings (in seconds):
585 2.0 foo
586 1.0 bar
587 <BLANKLINE>
588
589 Must specify name, otherwise we assert-fail.
590
591 >>> with ts:
592 ... pass
593 Traceback (most recent call last):
594 AssertionError: Must specify <name> etc when using "with ...".
595 '''
596 def __init__( self, name='', active=True):
597 '''
598 If `active` is False, returned instance does nothing.
599 '''
600 self.active = active
601 self.root_item = TimingsItem( name)
602 self.nest = [ self.root_item]
603 self.nest[0].begin( time.time())
604 self.name_max_len = 0
605 self.call_enter_state = None
606 self.call_enter_stack = []
607
608 def begin( self, name=None, text=None, level=0, t=None):
609 '''
610 Starts a new timing item as child of most recent in-progress timing
611 item.
612
613 name:
614 Used in final statistics. If `None`, we use `jlib.place()`.
615 text:
616 If not `None`, this is output here with `jlib.log()`.
617 level:
618 Verbosity. Added to `g_verbose`.
619 '''
620 if not self.active:
621 return
622 if t is None:
623 t = time.time()
624 if name is None:
625 name = place(2)
626 self.name_max_len = max( self.name_max_len, len(name))
627 leaf = self.nest[-1].children.setdefault( name, TimingsItem( name))
628 self.nest.append( leaf)
629 leaf.begin( t)
630 if text:
631 log( text, nv=0)
632
633 def end( self, name=None, t=None):
634 '''
635 Repeatedly ends the most recent item until we have ended item called
636 `name`. Ends just the most recent item if name is `None`.
637 '''
638 if not self.active:
639 return
640 if t is None:
641 t = time.time()
642 if name is None:
643 name = self.nest[-1].name
644 while self.nest:
645 leaf = self.nest.pop()
646 leaf.end( t)
647 if leaf.name == name:
648 break
649 else:
650 if name is not None:
651 log( f'*** Warning: cannot end timing item called {name} because not found.')
652
653 def end_all( self):
654 self.end( self.nest[0].name)
655
656 def mid( self, name=None):
657 '''
658 Ends current leaf item and starts a new item called `name`. Useful to
659 define multiple timing blocks at same level.
660 '''
661 if not self.active:
662 return
663 t = time.time()
664 if len( self.nest) > 1:
665 self.end( self.nest[-1].name, t)
666 self.begin( name, t=t)
667
668 def __enter__( self):
669 if not self.active:
670 return
671 assert self.call_enter_state, 'Must specify <name> etc when using "with ...".'
672 name, text, level = self.call_enter_state
673 self.begin( name, text, level)
674 self.call_enter_state = None
675 self.call_enter_stack.append( name)
676
677 def __exit__( self, type, value, traceback):
678 if not self.active:
679 return
680 assert not self.call_enter_state, f'self.call_enter_state is not false: {self.call_enter_state}'
681 name = self.call_enter_stack.pop()
682 self.end( name)
683
684 def __call__( self, name=None, text=None, level=0):
685 '''
686 Allow scoped timing.
687 '''
688 if not self.active:
689 return self
690 assert not self.call_enter_state, f'self.call_enter_state is not false: {self.call_enter_state}'
691 self.call_enter_state = ( name, text, level)
692 return self
693
694 def text( self, item, depth=0, precision=1):
695 '''
696 Returns text showing hierarchical timing information.
697 '''
698 if not self.active:
699 return ''
700 if item is self.root_item and not item.name:
701 # Don't show top-level.
702 ret = ''
703 else:
704 tt = ' None' if item.t is None else f'{item.t:6.{precision}f}'
705 n = f'/{item.n}' if item.n >= 2 else ''
706 ret = f'{" " * 4 * depth} {tt}{n} {item.name}\n'
707 depth += 1
708 for _, timing2 in item.children.items():
709 ret += self.text( timing2, depth, precision)
710 return ret
711
712 def __str__( self):
713 ret = 'Timings (in seconds):\n'
714 ret += self.text( self.root_item, 0)
715 return ret
716
717
718 def text_strpbrk_reverse( text, substrings):
719 '''
720 Finds last occurrence of any item in `substrings` in `text`.
721
722 Returns `(pos, substring)` or `(len(text), None)` if not found.
723 '''
724 ret_pos = -1
725 ret_substring = None
726 for substring in substrings:
727 pos = text.rfind( substring)
728 if pos >= 0 and pos > ret_pos:
729 ret_pos = pos
730 ret_substring = substring
731 if ret_pos == -1:
732 ret_pos = len( text)
733 return ret_pos, ret_substring
734
735
736 def text_split_last_of( text, substrings):
737 '''
738 Returns `(pre, post)`, where `pre` doesn't contain any item in `substrings`
739 and `post` is empty or starts with an item in `substrings`.
740 '''
741 pos, _ = text_strpbrk_reverse( text, substrings)
742
743 return text[ :pos], text[ pos:]
744
745
746
747 log_levels_add_env()
748
749
750 def force_line_buffering():
751 '''
752 Ensure `sys.stdout` and `sys.stderr` are line-buffered. E.g. makes things
753 work better if output is piped to a file via 'tee'.
754
755 Returns original out,err streams.
756 '''
757 stdout0 = sys.stdout
758 stderr0 = sys.stderr
759 sys.stdout = os.fdopen( sys.stdout.fileno(), 'w', 1)
760 sys.stderr = os.fdopen( sys.stderr.fileno(), 'w', 1)
761 return stdout0, stderr0
762
763
764 def exception_info(
765 exception_or_traceback=None,
766 limit=None,
767 file=None,
768 chain=True,
769 outer=True,
770 show_exception_type=True,
771 _filelinefn=True,
772 ):
773 '''
774 Shows an exception and/or backtrace.
775
776 Alternative to `traceback.*` functions that print/return information about
777 exceptions and backtraces, such as:
778
779 * `traceback.format_exc()`
780 * `traceback.format_exception()`
781 * `traceback.print_exc()`
782 * `traceback.print_exception()`
783
784 Install as system default with:
785
786 `sys.excepthook = lambda type_, exception, traceback: jlib.exception_info( exception)`
787
788 Returns `None`, or the generated text if `file` is 'return'.
789
790 Args:
791 exception_or_traceback:
792 `None`, a `BaseException`, a `types.TracebackType` (typically from
793 an exception's `.__traceback__` member) or an `inspect.FrameInfo`.
794
795 If `None` we use current exception from `sys.exc_info()` if set,
796 otherwise the current backtrace from `inspect.stack()`.
797 limit:
798 As in `traceback.*` functions: `None` to show all frames, positive
799 to show last `limit` frames, negative to exclude outermost `-limit`
800 frames. Zero to not show any backtraces.
801 file:
802 As in `traceback.*` functions: file-like object to which we write
803 output, or `sys.stderr` if `None`. Special value 'return' makes us
804 return our output as a string.
805 chain:
806 As in `traceback.*` functions: if true (the default) we show
807 chained exceptions as described in PEP-3134. Special value
808 'because' reverses the usual ordering, showing higher-level
809 exceptions first and joining with 'Because:' text.
810 outer:
811 If true (the default) we also show an exception's outer frames
812 above the `catch` block (see next section for details). We
813 use `outer=false` internally for chained exceptions to avoid
814 duplication.
815 show_exception_type:
816 Controls whether exception text is prefixed by
817 `f'{type(exception)}: '`. If callable we only include this prefix
818 if `show_exception_type(exception)` is true. Otherwise if true (the
819 default) we include the prefix for all exceptions (this mimcs the
820 behaviour of `traceback.*` functions). Otherwise we exclude the
821 prefix for all exceptions.
822 _filelinefn:
823 Internal only; makes us omit file:line: information to allow simple
824 doctest comparison with expected output.
825
826 Differences from `traceback.*` functions:
827
828 Frames are displayed as one line in the form::
829
830 <file>:<line>:<function>: <text>
831
832 Filenames are displayed as relative to the current directory if
833 applicable.
834
835 Inclusion of outer frames:
836 Unlike `traceback.*` functions, stack traces for exceptions include
837 outer stack frames above the point at which an exception was caught
838 - i.e. frames from the top-level <module> or thread creation to the
839 catch block. [Search for 'sys.exc_info backtrace incomplete' for
840 more details.]
841
842 We separate the two parts of the backtrace using a marker line
843 '^except raise:' where '^except' points upwards to the frame that
844 caught the exception and 'raise:' refers downwards to the frame
845 that raised the exception.
846
847 So the backtrace for an exception looks like this::
848
849 <file>:<line>:<fn>: <text> [in root module.]
850 ... [... other frames]
851 <file>:<line>:<fn>: <text> [in except: block where exception was caught.]
852 ^except raise: [marker line]
853 <file>:<line>:<fn>: <text> [in try: block.]
854 ... [... other frames]
855 <file>:<line>:<fn>: <text> [where the exception was raised.]
856
857 Examples:
858
859 In these examples we use `file=sys.stdout` so we can check the output
860 with `doctest`, and set `_filelinefn=0` so that the output can be
861 matched easily. We also use `+ELLIPSIS` and `...` to match arbitrary
862 outer frames from the doctest code itself.
863
864 Basic handling of an exception:
865
866 >>> def c():
867 ... raise Exception( 'c() failed')
868 >>> def b():
869 ... try:
870 ... c()
871 ... except Exception as e:
872 ... exception_info( e, file=sys.stdout, _filelinefn=0)
873 >>> def a():
874 ... b()
875
876 >>> a() # doctest: +REPORT_UDIFF +ELLIPSIS
877 Traceback (most recent call last):
878 ...
879 a(): b()
880 b(): exception_info( e, file=sys.stdout, _filelinefn=0)
881 ^except raise:
882 b(): c()
883 c(): raise Exception( 'c() failed')
884 Exception: c() failed
885
886 Handling of chained exceptions:
887
888 >>> def e():
889 ... raise Exception( 'e(): deliberate error')
890 >>> def d():
891 ... e()
892 >>> def c():
893 ... try:
894 ... d()
895 ... except Exception as e:
896 ... raise Exception( 'c: d() failed') from e
897 >>> def b():
898 ... try:
899 ... c()
900 ... except Exception as e:
901 ... exception_info( file=sys.stdout, chain=g_chain, _filelinefn=0)
902 >>> def a():
903 ... b()
904
905 With `chain=True` (the default), we output low-level exceptions
906 first, matching the behaviour of `traceback.*` functions:
907
908 >>> g_chain = True
909 >>> a() # doctest: +REPORT_UDIFF +ELLIPSIS
910 Traceback (most recent call last):
911 c(): d()
912 d(): e()
913 e(): raise Exception( 'e(): deliberate error')
914 Exception: e(): deliberate error
915 <BLANKLINE>
916 The above exception was the direct cause of the following exception:
917 Traceback (most recent call last):
918 ...
919 <module>(): a() # doctest: +REPORT_UDIFF +ELLIPSIS
920 a(): b()
921 b(): exception_info( file=sys.stdout, chain=g_chain, _filelinefn=0)
922 ^except raise:
923 b(): c()
924 c(): raise Exception( 'c: d() failed') from e
925 Exception: c: d() failed
926
927 With `chain='because'`, we output high-level exceptions first:
928 >>> g_chain = 'because'
929 >>> a() # doctest: +REPORT_UDIFF +ELLIPSIS
930 Traceback (most recent call last):
931 ...
932 <module>(): a() # doctest: +REPORT_UDIFF +ELLIPSIS
933 a(): b()
934 b(): exception_info( file=sys.stdout, chain=g_chain, _filelinefn=0)
935 ^except raise:
936 b(): c()
937 c(): raise Exception( 'c: d() failed') from e
938 Exception: c: d() failed
939 <BLANKLINE>
940 Because:
941 Traceback (most recent call last):
942 c(): d()
943 d(): e()
944 e(): raise Exception( 'e(): deliberate error')
945 Exception: e(): deliberate error
946
947 Show current backtrace by passing `exception_or_traceback=None`:
948 >>> def c():
949 ... exception_info( None, file=sys.stdout, _filelinefn=0)
950 >>> def b():
951 ... return c()
952 >>> def a():
953 ... return b()
954
955 >>> a() # doctest: +REPORT_UDIFF +ELLIPSIS
956 Traceback (most recent call last):
957 ...
958 <module>(): a() # doctest: +REPORT_UDIFF +ELLIPSIS
959 a(): return b()
960 b(): return c()
961 c(): exception_info( None, file=sys.stdout, _filelinefn=0)
962
963 Show an exception's `.__traceback__` backtrace:
964 >>> def c():
965 ... raise Exception( 'foo') # raise
966 >>> def b():
967 ... return c() # call c
968 >>> def a():
969 ... try:
970 ... b() # call b
971 ... except Exception as e:
972 ... exception_info( e.__traceback__, file=sys.stdout, _filelinefn=0)
973
974 >>> a() # doctest: +REPORT_UDIFF +ELLIPSIS
975 Traceback (most recent call last):
976 ...
977 a(): b() # call b
978 b(): return c() # call c
979 c(): raise Exception( 'foo') # raise
980 '''
981 # Set exactly one of <exception> and <tb>.
982 #
983 if isinstance( exception_or_traceback, (types.TracebackType, inspect.FrameInfo)):
984 # Simple backtrace, no Exception information.
985 exception = None
986 tb = exception_or_traceback
987 elif isinstance( exception_or_traceback, BaseException):
988 exception = exception_or_traceback
989 tb = None
990 elif exception_or_traceback is None:
991 # Show exception if available, else backtrace.
992 _, exception, tb = sys.exc_info()
993 tb = None if exception else inspect.stack()[1:]
994 else:
995 assert 0, f'Unrecognised exception_or_traceback type: {type(exception_or_traceback)}'
996
997 if file == 'return':
998 out = io.StringIO()
999 else:
1000 out = file if file else sys.stderr
1001
1002 def do_chain( exception):
1003 exception_info(
1004 exception,
1005 limit,
1006 out,
1007 chain,
1008 outer=False,
1009 show_exception_type=show_exception_type,
1010 _filelinefn=_filelinefn,
1011 )
1012
1013 if exception and chain and chain != 'because' and chain != 'because-compact':
1014 # Output current exception first.
1015 if exception.__cause__:
1016 do_chain( exception.__cause__)
1017 out.write( '\nThe above exception was the direct cause of the following exception:\n')
1018 elif exception.__context__:
1019 do_chain( exception.__context__)
1020 out.write( '\nDuring handling of the above exception, another exception occurred:\n')
1021
1022 cwd = os.getcwd() + os.sep
1023
1024 def output_frames( frames, reverse, limit):
1025 if limit == 0:
1026 return
1027 if reverse:
1028 assert isinstance( frames, list)
1029 frames = reversed( frames)
1030 if limit is not None:
1031 frames = list( frames)
1032 frames = frames[ -limit:]
1033 for frame in frames:
1034 f, filename, line, fnname, text, index = frame
1035 text = text[0].strip() if text else ''
1036 if filename.startswith( cwd):
1037 filename = filename[ len(cwd):]
1038 if filename.startswith( f'.{os.sep}'):
1039 filename = filename[ 2:]
1040 if _filelinefn:
1041 out.write( f' {filename}:{line}:{fnname}(): {text}\n')
1042 else:
1043 out.write( f' {fnname}(): {text}\n')
1044
1045 if limit != 0:
1046 out.write( 'Traceback (most recent call last):\n')
1047 if exception:
1048 tb = exception.__traceback__
1049 assert tb
1050 if outer:
1051 output_frames( inspect.getouterframes( tb.tb_frame), reverse=True, limit=limit)
1052 out.write( ' ^except raise:\n')
1053 limit2 = 0 if limit == 0 else None
1054 output_frames( inspect.getinnerframes( tb), reverse=False, limit=limit2)
1055 else:
1056 if not isinstance( tb, list):
1057 inner = inspect.getinnerframes(tb)
1058 outer = inspect.getouterframes(tb.tb_frame)
1059 tb = outer + inner
1060 tb.reverse()
1061 output_frames( tb, reverse=True, limit=limit)
1062
1063 if exception:
1064 if callable(show_exception_type):
1065 show_exception_type2 = show_exception_type( exception)
1066 else:
1067 show_exception_type2 = show_exception_type
1068 if show_exception_type2:
1069 lines = traceback.format_exception_only( type(exception), exception)
1070 for line in lines:
1071 out.write( line)
1072 else:
1073 out.write( str( exception) + '\n')
1074
1075 if exception and (chain == 'because' or chain == 'because-compact'):
1076 # Output current exception afterwards.
1077 pre, post = ('\n', '\n') if chain == 'because' else ('', ' ')
1078 if exception.__cause__:
1079 out.write( f'{pre}Because:{post}')
1080 do_chain( exception.__cause__)
1081 elif exception.__context__:
1082 out.write( f'{pre}Because: error occurred handling this exception:{post}')
1083 do_chain( exception.__context__)
1084
1085 if file == 'return':
1086 return out.getvalue()
1087
1088
1089 def number_sep( s):
1090 '''
1091 Simple number formatter, adds commas in-between thousands. `s` can be a
1092 number or a string. Returns a string.
1093
1094 >>> number_sep(1)
1095 '1'
1096 >>> number_sep(12)
1097 '12'
1098 >>> number_sep(123)
1099 '123'
1100 >>> number_sep(1234)
1101 '1,234'
1102 >>> number_sep(12345)
1103 '12,345'
1104 >>> number_sep(123456)
1105 '123,456'
1106 >>> number_sep(1234567)
1107 '1,234,567'
1108 '''
1109 if not isinstance( s, str):
1110 s = str( s)
1111 c = s.find( '.')
1112 if c==-1: c = len(s)
1113 end = s.find('e')
1114 if end == -1: end = s.find('E')
1115 if end == -1: end = len(s)
1116 ret = ''
1117 for i in range( end):
1118 ret += s[i]
1119 if i<c-1 and (c-i-1)%3==0:
1120 ret += ','
1121 elif i>c and i<end-1 and (i-c)%3==0:
1122 ret += ','
1123 ret += s[end:]
1124 return ret
1125
1126
1127 class Stream:
1128 '''
1129 Base layering abstraction for streams - abstraction for things like
1130 `sys.stdout` to allow prefixing of all output, e.g. with a timestamp.
1131 '''
1132 def __init__( self, stream):
1133 self.stream = stream
1134 def write( self, text):
1135 self.stream.write( text)
1136
1137 class StreamPrefix:
1138 '''
1139 Prefixes output with a prefix, which can be a string, or a callable that
1140 takes no parameters and return a string, or an integer number of spaces.
1141 '''
1142 def __init__( self, stream, prefix):
1143 if callable(stream):
1144 self.stream_write = stream
1145 self.stream_flush = lambda: None
1146 else:
1147 self.stream_write = stream.write
1148 self.stream_flush = stream.flush
1149 self.at_start = True
1150 if callable(prefix):
1151 self.prefix = prefix
1152 elif isinstance( prefix, int):
1153 self.prefix = lambda: ' ' * prefix
1154 else:
1155 self.prefix = lambda : prefix
1156
1157 def write( self, text):
1158 if self.at_start:
1159 text = self.prefix() + text
1160 self.at_start = False
1161 append_newline = False
1162 if text.endswith( '\n'):
1163 text = text[:-1]
1164 self.at_start = True
1165 append_newline = True
1166 text = text.replace( '\n', '\n%s' % self.prefix())
1167 if append_newline:
1168 text += '\n'
1169 self.stream_write( text)
1170
1171 def flush( self):
1172 self.stream_flush()
1173
1174
1175 def time_duration( seconds, verbose=False, s_format='%i'):
1176 '''
1177 Returns string expressing an interval.
1178
1179 seconds:
1180 The duration in seconds
1181 verbose:
1182 If true, return like '4 days 1 hour 2 mins 23 secs', otherwise as
1183 '4d3h2m23s'.
1184 s_format:
1185 If specified, use as printf-style format string for seconds.
1186
1187 >>> time_duration( 303333)
1188 '3d12h15m33s'
1189
1190 We pad single-digit numbers with '0' to keep things aligned:
1191 >>> time_duration( 302703.33, s_format='%.1f')
1192 '3d12h05m03.3s'
1193
1194 When verbose, we pad single-digit numbers with ' ' to keep things aligned:
1195 >>> time_duration( 302703, verbose=True)
1196 '3 days 12 hours 5 mins 3 secs'
1197
1198 >>> time_duration( 302703.33, verbose=True, s_format='%.1f')
1199 '3 days 12 hours 5 mins 3.3 secs'
1200
1201 >>> time_duration( 0)
1202 '0s'
1203
1204 >>> time_duration( 0, verbose=True)
1205 '0 sec'
1206 '''
1207 x = abs(seconds)
1208 ret = ''
1209 i = 0
1210 for div, text in [
1211 ( 60, 'sec'),
1212 ( 60, 'min'),
1213 ( 24, 'hour'),
1214 ( None, 'day'),
1215 ]:
1216 force = ( x == 0 and i == 0)
1217 if div:
1218 remainder = x % div
1219 x = int( x/div)
1220 else:
1221 remainder = x
1222 x = 0
1223 if not verbose:
1224 text = text[0]
1225 if remainder or force:
1226 if verbose and remainder > 1:
1227 # plural.
1228 text += 's'
1229 if verbose:
1230 text = ' %s ' % text
1231 if i == 0:
1232 remainder_string = s_format % remainder
1233 else:
1234 remainder_string = str( remainder)
1235 if x and (remainder < 10):
1236 # Pad with space or '0' to keep alignment.
1237 pad = ' ' if verbose else '0'
1238 remainder_string = pad + str(remainder_string)
1239 ret = '%s%s%s' % ( remainder_string, text, ret)
1240 i += 1
1241 ret = ret.strip()
1242 if ret == '':
1243 ret = '0s'
1244 if seconds < 0:
1245 ret = '-%s' % ret
1246 return ret
1247
1248
1249 def date_time( t=None):
1250 if t is None:
1251 t = time.time()
1252 return time.strftime( "%F-%T", time.gmtime( t))
1253
1254
1255 def time_read_date1( text):
1256 '''
1257 <text> is:
1258 <year>-<month>-<day>-<hour>-<min>-<sec>
1259
1260 Trailing values can be omitted, e.g. `2004-3' is treated as
1261 2004-03-0-0-0-0, i.e. 1st of March 2004. I think GMT is used,
1262 not the local time though.
1263
1264 >>> assert time_read_date1( '2010') == calendar.timegm( ( 2010, 1, 1, 0, 0, 0, 0, 0, 0))
1265 >>> assert time_read_date1( '2010-1') == calendar.timegm( ( 2010, 1, 1, 0, 0, 0, 0, 0, 0))
1266 >>> assert time_read_date1( '2015-4-25-14-39-39') == calendar.timegm( time.strptime( 'Sat Apr 25 14:39:39 2015'))
1267 '''
1268 pieces = text.split( '-')
1269 if len( pieces) == 1:
1270 pieces.append( '1') # mon
1271 if len( pieces) == 2:
1272 pieces.append( '1') # mday
1273 if len( pieces) == 3:
1274 pieces.append( '0') # hour
1275 if len( pieces) == 4:
1276 pieces.append( '0') # minute
1277 if len( pieces) == 5:
1278 pieces.append( '0') # second
1279 pieces = pieces[:6] + [ 0, 0, 0]
1280 time_tup = tuple( map( int, pieces))
1281 t = calendar.timegm( time_tup)
1282 return t
1283
1284
1285 def time_read_date2( text):
1286 '''
1287 Parses strings like '2y4d8h34m5s', returning seconds.
1288
1289 Supported time periods are:
1290 s: seconds
1291 m: minutes
1292 h: hours
1293 d: days
1294 w: weeks
1295 y: years
1296 '''
1297 #print 'text=%r' % text
1298 text0 = ''
1299 t = 0
1300 i0 = 0
1301 for i in range( len( text)):
1302 if text[i] in 'ywdhms':
1303 dt = int( text[i0:i])
1304 i0=i+1
1305 if text[i]=='s': dt *= 1
1306 elif text[i]=='m': dt *= 60
1307 elif text[i]=='h': dt *= 60*60
1308 elif text[i]=='d': dt *= 60*60*24
1309 elif text[i]=='w': dt *= 60*60*24*7
1310 elif text[i]=='y': dt *= 60*60*24*365
1311 t += dt
1312 return t
1313
1314 def time_read_date3( t, origin=None):
1315 '''
1316 Reads a date/time specification and returns absolute time in seconds.
1317
1318 If <text> starts with '+' or '-', reads relative time with read_date2() and
1319 adds/subtracts from <origin> (or time.time() if None).
1320
1321 Otherwise parses date/time with read_date1().
1322 '''
1323 if t[0] in '+-':
1324 if origin is None:
1325 origin = time.time()
1326 dt = time_read_date2( t[1:])
1327 if t[0] == '+':
1328 return origin + dt
1329 else:
1330 return origin - dt
1331 return time_read_date1( t)
1332
1333
1334 def stream_prefix_time( stream):
1335 '''
1336 Returns `StreamPrefix` that prefixes lines with time and elapsed time.
1337 '''
1338 t_start = time.time()
1339 def prefix_time():
1340 return '%s (+%s): ' % (
1341 time.strftime( '%T'),
1342 time_duration( time.time() - t_start, s_format='0.1f'),
1343 )
1344 return StreamPrefix( stream, prefix_time)
1345
1346 def stdout_prefix_time():
1347 '''
1348 Changes `sys.stdout` to prefix time and elapsed time; returns original
1349 `sys.stdout`.
1350 '''
1351 ret = sys.stdout
1352 sys.stdout = stream_prefix_time( sys.stdout)
1353 return ret
1354
1355
1356 def make_out_callable( out):
1357 '''
1358 Returns a stream-like object with a `.write()` method that writes to `out`.
1359 out:
1360
1361 * Where output is sent.
1362 * If `None`, output is lost.
1363 * Otherwise if an integer, we do: `os.write( out, text)`
1364 * Otherwise if callable, we do: `out( text)`
1365 * Otherwise we assume `out` is python stream or similar, and do: `out.write(text)`
1366 '''
1367 class Ret:
1368 def write( self, text):
1369 pass
1370 def flush( self):
1371 pass
1372 ret = Ret()
1373 if out == log:
1374 # A hack to avoid expanding '{...}' in text, if caller
1375 # does: jlib.system(..., out=jlib.log, ...).
1376 out = lambda text: log(text, nv=False)
1377 if out is None:
1378 ret.write = lambda text: None
1379 elif isinstance( out, int):
1380 ret.write = lambda text: os.write( out, text)
1381 elif callable( out):
1382 ret.write = out
1383 else:
1384 ret.write = lambda text: out.write( text)
1385 return ret
1386
1387 def _env_extra_text( env_extra):
1388 ret = ''
1389 if env_extra:
1390 for n, v in env_extra.items():
1391 assert isinstance( n, str), f'env_extra has non-string name {n!r}: {env_extra!r}'
1392 assert isinstance( v, str), f'env_extra name={n!r} has non-string value {v!r}: {env_extra!r}'
1393 ret += f'{n}={shlex.quote(v)} '
1394 return ret
1395
1396 def command_env_text( command, env_extra):
1397 '''
1398 Returns shell command that would run `command` with environmental settings
1399 in `env_extra`.
1400
1401 Useful for diagnostics - the returned text can be pasted into terminal to
1402 re-run a command manually.
1403
1404 `command` is expected to be already shell escaped, we do not escape it with
1405 `shlex.quote()`.
1406 '''
1407 prefix = _env_extra_text( env_extra)
1408 return f'{prefix}{command}'
1409
1410 def system(
1411 command,
1412 verbose=True,
1413 raise_errors=True,
1414 out=sys.stdout,
1415 prefix=None,
1416 shell=True,
1417 encoding='utf8',
1418 errors='replace',
1419 executable=None,
1420 caller=1,
1421 bufsize=-1,
1422 env_extra=None,
1423 multiline=True,
1424 ):
1425 '''
1426 Runs a command like `os.system()` or `subprocess.*`, but with more
1427 flexibility.
1428
1429 We give control over where the command's output is sent, whether to return
1430 the output and/or exit code, and whether to raise an exception if the
1431 command fails.
1432
1433 Args:
1434
1435 command:
1436 The command to run.
1437 verbose:
1438 If true, we write information about the command that was run, and
1439 its result, to `jlib.log()`.
1440 raise_errors:
1441 If true, we raise an exception if the command fails, otherwise we
1442 return the failing error code or zero.
1443 out:
1444 Where to send output from child process.
1445
1446 `out` is `o` or `(o, prefix)` or list of such items. Each `o` is
1447 matched as follows:
1448
1449 `None`: child process inherits this process's stdout and
1450 stderr. (Must be the only item, and `prefix` is not supported.)
1451
1452 `subprocess.DEVNULL`: child process's output is lost. (Must be
1453 the only item, and `prefix` is not supported.)
1454
1455 'return': we store the output and include it in our return
1456 value or exception. Can only be specified once.
1457
1458 'log': we write to `jlib.log()` using our caller's stack
1459 frame. Can only be specified once.
1460
1461 An integer: we do: `os.write(o, text)`
1462
1463 Is callable: we do: `o(text)`
1464
1465 Otherwise we assume `o` is python stream or similar, and do:
1466 `o.write(text)`
1467
1468 If `prefix` is specified, it is applied to each line in the output
1469 before being sent to `o`.
1470 prefix:
1471 Default prefix for all items in `out`. Can be a string, a callable
1472 taking no args that returns a string, or an integer designating the
1473 number of spaces.
1474 shell:
1475 Passed to underlying `subprocess.Popen()` call.
1476 encoding:
1477 Specify the encoding used to translate the command's output to
1478 characters. If `None` we send bytes to items in `out`.
1479 errors:
1480 How to handle encoding errors; see docs for `codecs` module
1481 for details. Defaults to 'replace' so we never raise a
1482 `UnicodeDecodeError`.
1483 executable=None:
1484 .
1485 caller:
1486 The number of frames to look up stack when call `jlib.log()` (used
1487 for `out='log'` and `verbose`).
1488 bufsize:
1489 As `subprocess.Popen()`'s `bufsize` arg, sets buffer size
1490 when creating stdout, stderr and stdin pipes. Use 0 for
1491 unbuffered, e.g. to see login/password prompts that don't end
1492 with a newline. Default -1 means `io.DEFAULT_BUFFER_SIZE`. +1
1493 (line-buffered) does not work because we read raw bytes and decode
1494 ourselves into string.
1495 env_extra:
1496 If not `None`, a `dict` with extra items that are added to the
1497 environment passed to the child process.
1498 multiline:
1499 If true (the default) we convert a multiline command into a single
1500 command, but preserve the multiline representation in verbose
1501 diagnostics.
1502
1503 Returns:
1504
1505 * If raise_errors is true:
1506
1507 If the command failed, we raise an exception; if `out` contains
1508 'return' the exception text includes the output.
1509
1510 Else if `out` contains 'return' we return the text output from the
1511 command.
1512
1513 Else we return `None`.
1514
1515 * If raise_errors is false:
1516
1517 If `out` contains 'return', we return `(e, text)` where `e` is the
1518 command's exit code and `text` is the output from the command.
1519
1520 Else we return `e`, the command's return code.
1521
1522 In the above, `e` is the `subprocess`-style returncode - the exit
1523 code, or `-N` if killed by signal `N`.
1524
1525 >>> print(system('echo hello a', prefix='foo:', out='return'))
1526 foo:hello a
1527 foo:
1528
1529 >>> system('echo hello b', prefix='foo:', out='return', raise_errors=False)
1530 (0, 'foo:hello b\\nfoo:')
1531
1532 >>> system('echo hello c && false', prefix='foo:', out='return', env_extra=dict(FOO='bar qwerty'))
1533 Traceback (most recent call last):
1534 Exception: Command failed: FOO='bar qwerty' echo hello c && false
1535 Output was:
1536 foo:hello c
1537 foo:
1538 <BLANKLINE>
1539 '''
1540 out_pipe = 0
1541 out_none = 0
1542 out_devnull = 0
1543 out_return = None
1544 out_log = 0
1545
1546 outs = out if isinstance(out, list) else [out]
1547 decoders = dict()
1548 def decoders_ensure(encoding):
1549 d = decoders.get(encoding)
1550 if d is None:
1551 class D:
1552 pass
1553 d = D()
1554 # subprocess's universal_newlines and codec.streamreader seem to
1555 # always use buffering even with bufsize=0, so they don't reliably
1556 # display prompts or other text that doesn't end with a newline.
1557 #
1558 # So we create our own incremental decode, which seems to work
1559 # better.
1560 #
1561 d.decoder = codecs.getincrementaldecoder(encoding)(errors)
1562 d.out = ''
1563 decoders[ encoding] = d
1564 return d
1565
1566 for i, o in enumerate(outs):
1567 if o is None:
1568 out_none += 1
1569 elif o == subprocess.DEVNULL:
1570 out_devnull += 1
1571 else:
1572 out_pipe += 1
1573 o_prefix = prefix
1574 if isinstance(o, tuple) and len(o) == 2:
1575 o, o_prefix = o
1576 assert o not in (None, subprocess.DEVNULL), f'out[]={o} does not make sense with a prefix ({o_prefix})'
1577 assert not isinstance(o, (tuple, list))
1578 o_decoder = None
1579 if o == 'return':
1580 assert not out_return, f'"return" specified twice does not make sense'
1581 out_return = io.StringIO()
1582 o_fn = out_return.write
1583 elif o == 'log':
1584 assert not out_log, f'"log" specified twice does not make sense'
1585 out_log += 1
1586 out_frame_record = inspect.stack()[caller]
1587 o_fn = lambda text: log( text, caller=out_frame_record, nv=False, raw=True)
1588 elif isinstance(o, int):
1589 def fn(text, o=o):
1590 os.write(o, text.encode())
1591 o_fn = fn
1592 elif callable(o):
1593 o_fn = o
1594 else:
1595 assert hasattr(o, 'write') and callable(o.write), (
1596 f'Do not understand o={o}, must be one of:'
1597 ' None, subprocess.DEVNULL, "return", "log", <int>,'
1598 ' or support o() or o.write().'
1599 )
1600 o_decoder = decoders_ensure(o.encoding)
1601 def o_fn(text, o=o):
1602 if errors == 'strict':
1603 o.write(text)
1604 else:
1605 # This is probably only necessary on Windows, where
1606 # sys.stdout can be cp1252 and will sometimes raise
1607 # UnicodeEncodeError. We hard-ignore these errors.
1608 try:
1609 o.write(text)
1610 except Exception as e:
1611 o.write(f'\n[Ignoring Exception: {e}]\n')
1612 o.flush() # Seems to be necessary on Windows.
1613 if o_prefix:
1614 o_fn = StreamPrefix( o_fn, o_prefix).write
1615 if not o_decoder:
1616 o_decoder = decoders_ensure(encoding)
1617 outs[i] = o_fn, o_decoder
1618
1619 if out_pipe:
1620 stdout = subprocess.PIPE
1621 stderr = subprocess.STDOUT
1622 elif out_none == len(outs):
1623 stdout = None
1624 stderr = None
1625 elif out_devnull == len(outs):
1626 stdout = subprocess.DEVNULL
1627 stderr = subprocess.DEVNULL
1628 else:
1629 assert 0, f'Inconsistent out: {out}'
1630
1631 if multiline and '\n' in command:
1632 command = textwrap.dedent(command)
1633 lines = list()
1634 for line in command.split( '\n'):
1635 h = 0 if line.startswith( '#') else line.find(' #')
1636 if h >= 0:
1637 line = line[:h]
1638 if line.strip():
1639 line = line.rstrip()
1640 lines.append(line)
1641 sep = ' ' if platform.system() == 'Windows' else ' \\\n'
1642 command = sep.join(lines)
1643
1644 if verbose:
1645 log(f'running: {command_env_text( command, env_extra)}', nv=0, caller=caller+1)
1646
1647 env = None
1648 if env_extra:
1649 env = os.environ.copy()
1650 env.update(env_extra)
1651
1652 child = subprocess.Popen(
1653 command,
1654 shell=shell,
1655 stdin=None,
1656 stdout=stdout,
1657 stderr=stderr,
1658 close_fds=True,
1659 executable=executable,
1660 bufsize=bufsize,
1661 env=env
1662 )
1663
1664 if out_pipe:
1665 while 1:
1666 # os.read() seems to be better for us than child.stdout.read()
1667 # because it returns a short read if data is not available. Where
1668 # as child.stdout.read() appears to be more willing to wait for
1669 # data until the requested number of bytes have been received.
1670 #
1671 # Also, os.read() does the right thing if the sender has made
1672 # multiple calls to write() - it returns all available data, not
1673 # just from the first unread write() call.
1674 #
1675 output0 = os.read( child.stdout.fileno(), 10000)
1676 final = not output0
1677 for _, decoder in decoders.items():
1678 decoder.out = decoder.decoder.decode(output0, final)
1679 for o_fn, o_decoder in outs:
1680 o_fn( o_decoder.out)
1681 if not output0:
1682 break
1683
1684 e = child.wait()
1685
1686 if out_log:
1687 global _log_text_line_start
1688 if not _log_text_line_start:
1689 # Terminate last incomplete line of log outputs.
1690 sys.stdout.write('\n')
1691 _log_text_line_start = True
1692 if verbose:
1693 log(f'[returned e={e}]', nv=0, caller=caller+1)
1694
1695 if out_return:
1696 out_return = out_return.getvalue()
1697
1698 if raise_errors:
1699 if e:
1700 message = f'Command failed: {command_env_text( command, env_extra)}'
1701 if out_return is not None:
1702 if not out_return.endswith('\n'):
1703 out_return += '\n'
1704 raise Exception(
1705 message + '\n'
1706 + 'Output was:\n'
1707 + out_return
1708 )
1709 else:
1710 raise Exception( message)
1711 elif out_return is not None:
1712 return out_return
1713 else:
1714 return
1715
1716 if out_return is not None:
1717 return e, out_return
1718 else:
1719 return e
1720
1721
1722 def system_rusage(
1723 command,
1724 verbose=None,
1725 raise_errors=True,
1726 out=sys.stdout,
1727 prefix=None,
1728 rusage=False,
1729 shell=True,
1730 encoding='utf8',
1731 errors='replace',
1732 executable=None,
1733 caller=1,
1734 bufsize=-1,
1735 env_extra=None,
1736 ):
1737 '''
1738 Old code that gets timing info; probably doesn't work.
1739 '''
1740 command2 = ''
1741 command2 += '/usr/bin/time -o ubt-out -f "D=%D E=%D F=%F I=%I K=%K M=%M O=%O P=%P R=%r S=%S U=%U W=%W X=%X Z=%Z c=%c e=%e k=%k p=%p r=%r s=%s t=%t w=%w x=%x C=%C"'
1742 command2 += ' '
1743 command2 += command
1744
1745 e = system(
1746 command2,
1747 out,
1748 shell,
1749 encoding,
1750 errors,
1751 executable=executable,
1752 )
1753 if e:
1754 raise Exception('/usr/bin/time failed')
1755 with open('ubt-out') as f:
1756 rusage_text = f.read()
1757 #print 'have read rusage output: %r' % rusage_text
1758 if rusage_text.startswith( 'Command '):
1759 # Annoyingly, /usr/bin/time appears to write 'Command
1760 # exited with ...' or 'Command terminated by ...' to the
1761 # output file before the rusage info if command doesn't
1762 # exit 0.
1763 nl = rusage_text.find('\n')
1764 rusage_text = rusage_text[ nl+1:]
1765 return rusage_text
1766
1767
1768 def git_get_files( directory, submodules=False, relative=True):
1769 '''
1770 Returns list of all files known to git in `directory`; `directory` must be
1771 somewhere within a git checkout.
1772
1773 Returned names are all relative to `directory`.
1774
1775 If `<directory>.git` exists we use git-ls-files and write list of files to
1776 `<directory>/jtest-git-files`.
1777
1778 Otherwise we require that `<directory>/jtest-git-files` already exists.
1779 '''
1780 def is_within_git_checkout( d):
1781 while 1:
1782 #log( '{d=}')
1783 if not d or d=='/':
1784 break
1785 if os.path.isdir( f'{d}/.git'):
1786 return True
1787 d = os.path.dirname( d)
1788
1789 ret = []
1790 if is_within_git_checkout( directory):
1791 command = 'cd ' + directory + ' && git ls-files'
1792 if submodules:
1793 command += ' --recurse-submodules'
1794 command += ' > jtest-git-files'
1795 system( command, verbose=False)
1796 with open( '%s/jtest-git-files' % directory, 'r') as f:
1797 text = f.read()
1798 for p in text.strip().split( '\n'):
1799 if not relative:
1800 p = os.path.join( directory, p)
1801 ret.append( p)
1802 return ret
1803
1804 def git_get_id_raw( directory):
1805 if not os.path.isdir( '%s/.git' % directory):
1806 return
1807 text = system(
1808 f'cd {directory} && (PAGER= git show --pretty=oneline|head -n 1 && git diff)',
1809 out='return',
1810 )
1811 return text
1812
1813 def git_get_id( directory, allow_none=False):
1814 '''
1815 Returns text where first line is '<git-sha> <commit summary>' and remaining
1816 lines contain output from 'git diff' in <directory>.
1817
1818 directory:
1819 Root of git checkout.
1820 allow_none:
1821 If true, we return None if `directory` is not a git checkout and
1822 jtest-git-id file does not exist.
1823 '''
1824 filename = f'{directory}/jtest-git-id'
1825 text = git_get_id_raw( directory)
1826 if text:
1827 with open( filename, 'w') as f:
1828 f.write( text)
1829 elif os.path.isfile( filename):
1830 with open( filename) as f:
1831 text = f.read()
1832 else:
1833 if not allow_none:
1834 raise Exception( f'Not in git checkout, and no file called: {filename}.')
1835 text = None
1836 return text
1837
1838 class Args:
1839 '''
1840 Iterates over argv items.
1841 '''
1842 def __init__( self, argv):
1843 self.items = iter( argv)
1844 def next( self):
1845 if sys.version_info[0] == 3:
1846 return next( self.items)
1847 else:
1848 return self.items.next()
1849 def next_or_none( self):
1850 try:
1851 return self.next()
1852 except StopIteration:
1853 return None
1854
1855
1856 def fs_read( path, binary=False):
1857 with open( path, 'rb' if binary else 'r') as f:
1858 return f.read()
1859
1860 def fs_write( path, data, binary=False):
1861 with open( path, 'wb' if binary else 'w') as f:
1862 return f.write( data)
1863
1864
1865 def fs_update( text, filename, return_different=False):
1866 '''
1867 Writes `text` to `filename`. Does nothing if contents of `filename` are
1868 already `text`.
1869
1870 If `return_different` is true, we return existing contents if `filename`
1871 already exists and differs from `text`.
1872
1873 Otherwise we return true if file has changed.
1874 '''
1875 try:
1876 with open( filename) as f:
1877 text0 = f.read()
1878 except OSError:
1879 text0 = None
1880 if text != text0:
1881 if return_different and text0 is not None:
1882 return text
1883 # Write to temp file and rename, to ensure we are atomic.
1884 filename_temp = f'{filename}-jlib-temp'
1885 with open( filename_temp, 'w') as f:
1886 f.write( text)
1887 fs_rename( filename_temp, filename)
1888 return True
1889
1890
1891 def fs_find_in_paths( name, paths=None, verbose=False):
1892 '''
1893 Looks for `name` in paths and returns complete path. `paths` is list/tuple
1894 or `os.pathsep`-separated string; if `None` we use `$PATH`. If `name`
1895 contains `/`, we return `name` itself if it is a file, regardless of $PATH.
1896 '''
1897 if '/' in name:
1898 return name if os.path.isfile( name) else None
1899 if paths is None:
1900 paths = os.environ.get( 'PATH', '')
1901 if verbose:
1902 log('From os.environ["PATH"]: {paths=}')
1903 if isinstance( paths, str):
1904 paths = paths.split( os.pathsep)
1905 if verbose:
1906 log('After split: {paths=}')
1907 for path in paths:
1908 p = os.path.join( path, name)
1909 if verbose:
1910 log('Checking {p=}')
1911 if os.path.isfile( p):
1912 if verbose:
1913 log('Returning because is file: {p!r}')
1914 return p
1915 if verbose:
1916 log('Returning None because not found: {name!r}')
1917
1918
1919 def fs_mtime( filename, default=0):
1920 '''
1921 Returns mtime of file, or `default` if error - e.g. doesn't exist.
1922 '''
1923 try:
1924 return os.path.getmtime( filename)
1925 except OSError:
1926 return default
1927
1928
1929 def fs_filesize( filename, default=0):
1930 try:
1931 return os.path.getsize( filename)
1932 except OSError:
1933 return default
1934
1935
1936 def fs_paths( paths):
1937 '''
1938 Yields each file in `paths`, walking any directories.
1939
1940 If `paths` is a tuple `(paths2, filter_)` and `filter_` is callable, we
1941 yield all files in `paths2` for which `filter_(path2)` returns true.
1942 '''
1943 filter_ = lambda path: True
1944 if isinstance( paths, tuple) and len( paths) == 2 and callable( paths[1]):
1945 paths, filter_ = paths
1946 if isinstance( paths, str):
1947 paths = (paths,)
1948 for name in paths:
1949 if os.path.isdir( name):
1950 for dirpath, dirnames, filenames in os.walk( name):
1951 for filename in filenames:
1952 path = os.path.join( dirpath, filename)
1953 if filter_( path):
1954 yield path
1955 else:
1956 if filter_( name):
1957 yield name
1958
1959 def fs_remove( path, backup=False):
1960 '''
1961 Removes file or directory, without raising exception if it doesn't exist.
1962
1963 path:
1964 The path to remove.
1965 backup:
1966 If true, we rename any existing file/directory called `path` to
1967 `<path>-<datetime>`.
1968
1969 We assert-fail if the path still exists when we return, in case of
1970 permission problems etc.
1971 '''
1972 if backup and os.path.exists( path):
1973 datetime = date_time()
1974 if platform.system() == 'Windows' or platform.system().startswith( 'CYGWIN'):
1975 # os.rename() fails if destination contains colons, with:
1976 # [WinError87] The parameter is incorrect ...
1977 datetime = datetime.replace( ':', '')
1978 p = f'{path}-{datetime}'
1979 log( 'Moving out of way: {path} => {p}')
1980 os.rename( path, p)
1981 try:
1982 os.remove( path)
1983 except Exception:
1984 pass
1985 shutil.rmtree( path, ignore_errors=1)
1986 assert not os.path.exists( path)
1987
1988 def fs_remove_dir_contents( path):
1989 '''
1990 Removes all items in directory `path`; does not remove `path` itself.
1991 '''
1992 for leaf in os.listdir( path):
1993 path2 = os.path.join( path, leaf)
1994 fs_remove(path2)
1995
1996 def fs_ensure_empty_dir( path):
1997 os.makedirs( path, exist_ok=True)
1998 fs_remove_dir_contents( path)
1999
2000 def fs_rename(src, dest):
2001 '''
2002 Renames `src` to `dest`. If we get an error, we try to remove `dest`
2003 explicitly and then retry; this is to make things work on Windows.
2004 '''
2005 try:
2006 os.rename(src, dest)
2007 except Exception:
2008 os.remove(dest)
2009 os.rename(src, dest)
2010
2011 def fs_copy(src, dest, verbose=False):
2012 '''
2013 Wrapper for `shutil.copy()` that also ensures parent of `dest` exists and
2014 optionally calls `jlib.log()` with diagnostic.
2015 '''
2016 if verbose:
2017 log('Copying {src} to {dest}')
2018 dirname = os.path.dirname(dest)
2019 if dirname:
2020 os.makedirs( dirname, exist_ok=True)
2021 shutil.copy2( src, dest)
2022
2023
2024 def untar(path, mode='r:gz', prefix=None):
2025 '''
2026 Extracts tar file.
2027
2028 We fail if items in tar file have different top-level directory names, or
2029 if tar file's top-level directory name already exists locally.
2030
2031 path:
2032 The tar file.
2033 mode:
2034 As `tarfile.open()`.
2035 prefix:
2036 If not `None`, we fail if tar file's top-level directory name is not
2037 `prefix`.
2038
2039 Returns the directory name (which will be `prefix` if not `None`).
2040 '''
2041 with tarfile.open( path, mode) as t:
2042 items = t.getnames()
2043 assert items
2044 item = items[0]
2045 assert not item.startswith('.')
2046 s = item.find('/')
2047 if s == -1:
2048 prefix_actual = item + '/'
2049 else:
2050 prefix_actual = item[:s+1]
2051 if prefix:
2052 assert prefix == prefix_actual, f'prefix={prefix} prefix_actual={prefix_actual}'
2053 for item in items[1:]:
2054 assert item.startswith( prefix_actual), f'prefix_actual={prefix_actual!r} != item={item!r}'
2055 assert not os.path.exists( prefix_actual)
2056 t.extractall()
2057 return prefix_actual
2058
2059
2060 # Things for figuring out whether files need updating, using mtimes.
2061 #
2062 def fs_newest( names):
2063 '''
2064 Returns mtime of newest file in `filenames`. Returns 0 if no file exists.
2065 '''
2066 assert isinstance( names, (list, tuple))
2067 assert names
2068 ret_t = 0
2069 ret_name = None
2070 for filename in fs_paths( names):
2071 if filename.endswith('.pyc'):
2072 continue
2073 t = fs_mtime( filename)
2074 if t > ret_t:
2075 ret_t = t
2076 ret_name = filename
2077 return ret_t, ret_name
2078
2079 def fs_oldest( names):
2080 '''
2081 Returns mtime of oldest file in `filenames` or 0 if no file exists.
2082 '''
2083 assert isinstance( names, (list, tuple))
2084 assert names
2085 ret_t = None
2086 ret_name = None
2087 for filename in fs_paths( names):
2088 t = fs_mtime( filename)
2089 if ret_t is None or t < ret_t:
2090 ret_t = t
2091 ret_name = filename
2092 if ret_t is None:
2093 ret_t = 0
2094 return ret_t, ret_name
2095
2096 def fs_any_newer( infiles, outfiles):
2097 '''
2098 If any file in `infiles` is newer than any file in `outfiles`, returns
2099 string description. Otherwise returns `None`.
2100 '''
2101 in_tmax, in_tmax_name = fs_newest( infiles)
2102 out_tmin, out_tmin_name = fs_oldest( outfiles)
2103 if in_tmax > out_tmin:
2104 text = f'{in_tmax_name} is newer than {out_tmin_name}'
2105 return text
2106
2107 def fs_ensure_parent_dir( path):
2108 parent = os.path.dirname( path)
2109 if parent:
2110 os.makedirs( parent, exist_ok=True)
2111
2112 def fs_newer( pattern, t):
2113 '''
2114 Returns list of files matching glob `pattern` whose mtime is >= `t`.
2115 '''
2116 paths = glob.glob(pattern)
2117 paths_new = []
2118 for path in paths:
2119 tt = fs_mtime(path)
2120 if tt >= t:
2121 paths_new.append(path)
2122 return paths_new
2123
2124 def build(
2125 infiles,
2126 outfiles,
2127 command,
2128 force_rebuild=False,
2129 out=None,
2130 all_reasons=False,
2131 verbose=True,
2132 executable=None,
2133 ):
2134 '''
2135 Ensures that `outfiles` are up to date using enhanced makefile-like
2136 determinism of dependencies.
2137
2138 Rebuilds `outfiles` by running `command` if we determine that any of them
2139 are out of date, or if `command` has changed.
2140
2141 infiles:
2142 Names of files that are read by `command`. Can be a single filename. If
2143 an item is a directory, we expand to all filenames in the directory's
2144 tree. Can be `(files2, filter_)` as supported by `jlib.fs_paths()`.
2145 outfiles:
2146 Names of files that are written by `command`. Can also be a single
2147 filename. Can be `(files2, filter_)` as supported by `jlib.fs_paths()`.
2148 command:
2149 Command to run. {IN} and {OUT} are replaced by space-separated
2150 `infiles` and `outfiles` with '/' changed to '\' on Windows.
2151 force_rebuild:
2152 If true, we always re-run the command.
2153 out:
2154 A callable, passed to `jlib.system()`. If `None`, we use `jlib.log()`
2155 with our caller's stack record (by passing `(out='log', caller=2)` to
2156 `jlib.system()`).
2157 all_reasons:
2158 If true we check all ways for a build being needed, even if we already
2159 know a build is needed; this only affects the diagnostic that we
2160 output.
2161 verbose:
2162 Passed to `jlib.system()`.
2163
2164 Returns:
2165 true if we have run the command, otherwise None.
2166
2167 We compare mtimes of `infiles` and `outfiles`, and we also detect changes
2168 to the command itself.
2169
2170 If any of infiles are newer than any of `outfiles`, or `command` is
2171 different to contents of commandfile `<outfile[0]>.cmd`, then truncates
2172 commandfile and runs `command`. If `command` succeeds we writes `command`
2173 to commandfile.
2174 '''
2175 if isinstance( infiles, str):
2176 infiles = (infiles,)
2177 if isinstance( outfiles, str):
2178 outfiles = (outfiles,)
2179
2180 if out is None:
2181 out = 'log'
2182
2183 command_filename = f'{outfiles[0]}.cmd'
2184 reasons = []
2185
2186 if not reasons or all_reasons:
2187 if force_rebuild:
2188 reasons.append( 'force_rebuild was specified')
2189
2190 os_name = platform.system()
2191 os_windows = (os_name == 'Windows' or os_name.startswith('CYGWIN'))
2192 def files_string(files):
2193 if isinstance(files, tuple) and len(files) == 2 and callable(files[1]):
2194 files = files[0],
2195 ret = ' '.join(files)
2196 if os_windows:
2197 # This works on Cygwyn; we might only need '\\' if running in a Cmd
2198 # window.
2199 ret = ret.replace('/', '\\\\')
2200 return ret
2201 command = command.replace('{IN}', files_string(infiles))
2202 command = command.replace('{OUT}', files_string(outfiles))
2203
2204 if not reasons or all_reasons:
2205 try:
2206 with open( command_filename) as f:
2207 command0 = f.read()
2208 except Exception:
2209 command0 = None
2210 if command != command0:
2211 reasons.append( f'command has changed:\n{command0}\n=>\n{command}')
2212
2213 if not reasons or all_reasons:
2214 reason = fs_any_newer( infiles, outfiles)
2215 if reason:
2216 reasons.append( reason)
2217
2218 if not reasons:
2219 log( 'Already up to date: ' + ' '.join(outfiles), caller=2, nv=0)
2220 return
2221
2222 log( f'Rebuilding because {", and ".join(reasons)}: {" ".join(outfiles)}',
2223 caller=2,
2224 nv=0,
2225 )
2226
2227 # Empty <command_filename) while we run the command so that if command
2228 # fails but still creates target(s), then next time we will know target(s)
2229 # are not up to date.
2230 #
2231 # We rename the command to a temporary file and then rename back again
2232 # after the command finishes so that its mtime is unchanged if the command
2233 # has not changed.
2234 #
2235 fs_ensure_parent_dir( command_filename)
2236 command_filename_temp = command_filename + '-'
2237 fs_remove(command_filename_temp)
2238 if os.path.exists( command_filename):
2239 fs_rename(command_filename, command_filename_temp)
2240 fs_update( command, command_filename_temp)
2241 assert os.path.isfile( command_filename_temp)
2242
2243 system( command, out=out, verbose=verbose, executable=executable, caller=2)
2244
2245 assert os.path.isfile( command_filename_temp), \
2246 f'Command seems to have deleted {command_filename_temp=}: {command!r}'
2247
2248 fs_rename( command_filename_temp, command_filename)
2249
2250 return True
2251
2252
2253 def link_l_flags( sos, ld_origin=None):
2254 '''
2255 Returns link flags suitable for linking with each .so in <sos>.
2256
2257 We return -L flags for each unique parent directory and -l flags for each
2258 leafname.
2259
2260 In addition on non-Windows we append " -Wl,-rpath,'$ORIGIN,-z,origin"
2261 so that libraries will be searched for next to each other. This can be
2262 disabled by setting ld_origin to false.
2263 '''
2264 darwin = (platform.system() == 'Darwin')
2265 dirs = set()
2266 names = []
2267 if isinstance( sos, str):
2268 sos = [sos]
2269 ret = ''
2270 for so in sos:
2271 if not so:
2272 continue
2273 dir_ = os.path.dirname( so)
2274 name = os.path.basename( so)
2275 assert name.startswith( 'lib'), f'name={name}'
2276 m = re.search( '(.so[.0-9]*)$', name)
2277 if m:
2278 l = len(m.group(1))
2279 dirs.add( dir_)
2280 names.append( f'-l {name[3:-l]}')
2281 elif darwin and name.endswith( '.dylib'):
2282 dirs.add( dir_)
2283 names.append( f'-l {name[3:-6]}')
2284 elif name.endswith( '.a'):
2285 names.append( so)
2286 else:
2287 assert 0, f'leaf does not end in .so or .a: {so}'
2288 ret = ''
2289 # Important to use sorted() here, otherwise ordering from set() is
2290 # arbitrary causing occasional spurious rebuilds.
2291 for dir_ in sorted(dirs):
2292 ret += f' -L {os.path.relpath(dir_)}'
2293 for name in names:
2294 ret += f' {name}'
2295 if ld_origin is None:
2296 if platform.system() != 'Windows':
2297 ld_origin = True
2298 if ld_origin:
2299 if darwin:
2300 # As well as this link flag, it is also necessary to use
2301 # `install_name_tool -change` to rename internal names to
2302 # `@rpath/<leafname>`.
2303 ret += ' -Wl,-rpath,@loader_path/.'
2304 else:
2305 ret += " -Wl,-rpath,'$ORIGIN',-z,origin"
2306 #log('{sos=} {ld_origin=} {ret=}')
2307 return ret.strip()