cs-lex 20240519__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cs/lex.py ADDED
@@ -0,0 +1,1994 @@
1
+ #!/usr/bin/python
2
+
3
+ r'''
4
+ Lexical analysis functions, tokenisers, transcribers:
5
+ an arbitrary assortment of lexical and tokenisation functions useful
6
+ for writing recursive descent parsers, of which I have several.
7
+ There are also some transcription functions for producing text
8
+ from various objects, such as `hexify` and `unctrl`.
9
+
10
+ Generally the get_* functions accept a source string and an offset
11
+ (usually optional, default `0`) and return a token and the new offset,
12
+ raising `ValueError` on failed tokenisation.
13
+ '''
14
+
15
+ # pylint: disable=too-many-lines
16
+
17
+ import binascii
18
+ from functools import partial
19
+ from json import JSONEncoder
20
+ import os
21
+ from pathlib import Path, PurePosixPath, PureWindowsPath
22
+ import re
23
+ from string import (
24
+ ascii_letters,
25
+ ascii_uppercase,
26
+ digits,
27
+ printable,
28
+ whitespace,
29
+ Formatter,
30
+ )
31
+ import sys
32
+ from textwrap import dedent
33
+ from threading import Lock
34
+ from typing import Tuple, Union
35
+
36
+ from dateutil.tz import tzlocal
37
+ from icontract import require
38
+ from typeguard import typechecked
39
+
40
+ from cs.dateutils import unixtime2datetime, UTC
41
+ from cs.deco import fmtdoc, decorator
42
+ from cs.gimmicks import warning
43
+ from cs.pfx import Pfx, pfx_call, pfx_method
44
+ from cs.py.func import funcname
45
+ from cs.seq import common_prefix_length, common_suffix_length
46
+
47
+ __version__ = '20240519'
48
+
49
+ DISTINFO = {
50
+ 'keywords': ["python2", "python3"],
51
+ 'classifiers': [
52
+ "Programming Language :: Python",
53
+ "Programming Language :: Python :: 3",
54
+ ],
55
+ 'install_requires': [
56
+ 'cs.dateutils',
57
+ 'cs.deco',
58
+ 'cs.gimmicks',
59
+ 'cs.pfx',
60
+ 'cs.py.func',
61
+ 'cs.seq>=20200914',
62
+ 'python-dateutil',
63
+ 'icontract',
64
+ 'typeguard',
65
+ ],
66
+ }
67
+
68
+ unhexify = binascii.unhexlify # pylint: disable=c-extension-no-member
69
+ hexify = binascii.hexlify # pylint: disable=c-extension-no-member
70
+ if sys.hexversion >= 0x030000:
71
+ _hexify = hexify
72
+
73
+ # pylint: disable=function-redefined
74
+ def hexify(bs):
75
+ ''' A flavour of `binascii.hexlify` returning a `str`.
76
+ '''
77
+ return _hexify(bs).decode()
78
+
79
+ ord_space = ord(' ')
80
+
81
+ # pylint: disable=too-many-branches,redefined-outer-name
82
+ def unctrl(s, tabsize=8):
83
+ ''' Return the string `s` with `TAB`s expanded and control characters
84
+ replaced with printable representations.
85
+ '''
86
+ if tabsize < 1:
87
+ raise ValueError("tabsize(%r) < 1" % (tabsize,))
88
+ s2 = ''
89
+ sofar = 0
90
+ for i, ch in enumerate(s):
91
+ ch2 = None
92
+ if ch == '\t':
93
+ if sofar < i:
94
+ s2 += s[sofar:i]
95
+ sofar = i
96
+ ch2 = ' ' * (tabsize - (len(s2) % tabsize))
97
+ elif ch == '\f':
98
+ ch2 = '\\f'
99
+ elif ch == '\n':
100
+ ch2 = '\\n'
101
+ elif ch == '\r':
102
+ ch2 = '\\r'
103
+ elif ch == '\v':
104
+ ch2 = '\\v'
105
+ else:
106
+ o = ord(ch)
107
+ if o < ord_space or printable.find(ch) == -1:
108
+ if o >= 256:
109
+ ch2 = "\\u%04x" % o
110
+ else:
111
+ ch2 = "\\%03o" % o
112
+
113
+ if ch2 is not None:
114
+ if sofar < i:
115
+ s2 += s[sofar:i]
116
+ s2 += ch2
117
+ sofar = i + 1
118
+
119
+ if sofar < len(s):
120
+ s2 += s[sofar:]
121
+
122
+ return s2.expandtabs(tabsize)
123
+
124
+ def lc_(value):
125
+ ''' Return `value.lower()`
126
+ with `'-'` translated into `'_'` and `' '` translated into `'-'`.
127
+
128
+ I use this to construct lowercase filenames containing a
129
+ readable transcription of a title string.
130
+
131
+ See also `titleify_lc()`, an imperfect reversal of this.
132
+ '''
133
+ return value.lower().replace('-', '_').replace(' ', '-')
134
+
135
+ def titleify_lc(value_lc):
136
+ ''' Translate `'-'` into `' '` and `'_'` translated into `'-'`,
137
+ then titlecased.
138
+
139
+ See also `lc_()`, which this reverses imperfectly.
140
+ '''
141
+ return value_lc.replace('-', ' ').replace('_', '-').title()
142
+
143
+ def tabpadding(padlen, tabsize=8, offset=0):
144
+ ''' Compute some spaces to use a tab padding at an offfset.
145
+ '''
146
+ pad = ''
147
+ nexttab = tabsize - offset % tabsize
148
+ while nexttab <= padlen:
149
+ pad += '\t'
150
+ padlen -= nexttab
151
+ nexttab = tabsize
152
+
153
+ if padlen > 0:
154
+ pad += "%*s" % (padlen, ' ')
155
+
156
+ return pad
157
+
158
+ def typed_str(o, use_cls=False, use_repr=False, max_length=32):
159
+ ''' Return "type(o).__name__:str(o)" for some object `o`.
160
+ This is available as both `typed_str` and `s`.
161
+
162
+ Parameters:
163
+ * `use_cls`: default `False`;
164
+ if true, use `str(type(o))` instead of `type(o).__name__`
165
+ * `use_repr`: default `False`;
166
+ if true, use `repr(o)` instead of `str(o)`
167
+
168
+ I use this a lot when debugging. Example:
169
+
170
+ from cs.lex import typed_str as s
171
+ ......
172
+ X("foo = %s", s(foo))
173
+ '''
174
+ # pylint: disable=redefined-outer-name
175
+ o_s = repr(o) if use_repr else str(o)
176
+ if max_length is not None:
177
+ o_s = cropped(o_s, max_length)
178
+ s = "%s:%s" % (type(o) if use_cls else type(o).__name__, o_s)
179
+ return s
180
+
181
+ # convenience alias
182
+ s = typed_str
183
+
184
+ def typed_repr(o, max_length=None, *, use_cls=False):
185
+ ''' Like `typed_str` but using `repr` instead of `str`.
186
+ This is available as both `typed_repr` and `r`.
187
+ '''
188
+ return typed_str(o, use_cls=use_cls, max_length=max_length, use_repr=True)
189
+
190
+ # convenience alias
191
+ r = typed_repr
192
+
193
+ def strlist(ary, sep=", "):
194
+ ''' Convert an iterable to strings and join with `sep` (default `', '`).
195
+ '''
196
+ return sep.join([str(a) for a in ary])
197
+
198
+ # pylint: disable=redefined-outer-name
199
+ def htmlify(s, nbsp=False):
200
+ ''' Convert a string for safe transcription in HTML.
201
+
202
+ Parameters:
203
+ * `s`: the string
204
+ * `nbsp`: replaces spaces with `"&nbsp;"` to prevent word folding,
205
+ default `False`.
206
+ '''
207
+ s = s.replace("&", "&amp;")
208
+ s = s.replace("<", "&lt;")
209
+ s = s.replace(">", "&gt;")
210
+ if nbsp:
211
+ s = s.replace(" ", "&nbsp;")
212
+ return s
213
+
214
+ def htmlquote(s):
215
+ ''' Quote a string for use in HTML.
216
+ '''
217
+ s = htmlify(s)
218
+ s = s.replace("\"", "&dquot;")
219
+ return "\"" + s + "\""
220
+
221
+ def jsquote(s):
222
+ ''' Quote a string for use in JavaScript.
223
+ '''
224
+ s = s.replace("\"", "&dquot;")
225
+ return "\"" + s + "\""
226
+
227
+ def phpquote(s):
228
+ ''' Quote a string for use in PHP code.
229
+ '''
230
+ return "'" + s.replace('\\', '\\\\').replace("'", "\\'") + "'"
231
+
232
+ # characters that may appear in text sections of a texthexify result
233
+ # Notable exclusions:
234
+ # \ - to avoid double in slosh escaped presentation
235
+ # % - likewise, for percent escaped presentation
236
+ # [ ] - the delimiters of course
237
+ # { } - used for JSON data and some other markup
238
+ # / - path separator
239
+ #
240
+ _texthexify_white_chars = ascii_letters + digits + '_-+.,'
241
+
242
+ def texthexify(bs, shiftin='[', shiftout=']', whitelist=None):
243
+ ''' Transcribe the bytes `bs` to text using compact text runs for
244
+ some common text values.
245
+
246
+ This can be reversed with the `untexthexify` function.
247
+
248
+ This is an ad doc format devised to be compact but also to
249
+ expose "text" embedded within to the eye. The original use
250
+ case was transcribing a binary directory entry format, where
251
+ the filename parts would be somewhat visible in the transcription.
252
+
253
+ The output is a string of hexadecimal digits for the encoded
254
+ bytes except for runs of values from the whitelist, which are
255
+ enclosed in the shiftin and shiftout markers and transcribed
256
+ as is. The default whitelist is values of the ASCII letters,
257
+ the decimal digits and the punctuation characters '_-+.,'.
258
+ The default shiftin and shiftout markers are '[' and ']'.
259
+
260
+ String objects converted with either `hexify` and `texthexify`
261
+ output strings may be freely concatenated and decoded with
262
+ `untexthexify`.
263
+
264
+ Example:
265
+
266
+ >>> texthexify(b'&^%&^%abcdefghi)(*)(*')
267
+ '265e25265e25[abcdefghi]29282a29282a'
268
+
269
+ Parameters:
270
+ * `bs`: the bytes to transcribe
271
+ * `shiftin`: Optional. The marker string used to indicate a shift to
272
+ direct textual transcription of the bytes, default: `'['`.
273
+ * `shiftout`: Optional. The marker string used to indicate a
274
+ shift from text mode back into hexadecimal transcription,
275
+ default `']'`.
276
+ * `whitelist`: an optional bytes or string object indicating byte
277
+ values which may be represented directly in text;
278
+ the default value is the ASCII letters, the decimal digits
279
+ and the punctuation characters `'_-+.,'`.
280
+ '''
281
+ if whitelist is None:
282
+ whitelist = _texthexify_white_chars
283
+ if isinstance(whitelist, str):
284
+ whitelist = bytes(ord(ch) for ch in whitelist)
285
+ inout_len = len(shiftin) + len(shiftout)
286
+ chunks = []
287
+ offset = 0
288
+ offset0 = offset
289
+ inwhite = False
290
+ while offset < len(bs):
291
+ b = bs[offset]
292
+ if inwhite:
293
+ if b not in whitelist:
294
+ inwhite = False
295
+ if offset - offset0 > inout_len:
296
+ # gather up whitelist span if long enough to bother
297
+ chunk = (
298
+ shiftin + ''.join(chr(bs[o])
299
+ for o in range(offset0, offset)) + shiftout
300
+ )
301
+ else:
302
+ # transcribe as hex anyway - too short
303
+ chunk = hexify(bs[offset0:offset])
304
+ chunks.append(chunk)
305
+ offset0 = offset
306
+ else:
307
+ if b in whitelist:
308
+ inwhite = True
309
+ chunk = hexify(bs[offset0:offset])
310
+ chunks.append(chunk)
311
+ offset0 = offset
312
+ offset += 1
313
+ if offset > offset0:
314
+ if inwhite and offset - offset0 > inout_len:
315
+ chunk = (
316
+ shiftin + ''.join(chr(bs[o])
317
+ for o in range(offset0, offset)) + shiftout
318
+ )
319
+ else:
320
+ chunk = hexify(bs[offset0:offset])
321
+ chunks.append(chunk)
322
+ return ''.join(chunks)
323
+
324
+ # pylint: disable=redefined-outer-name
325
+ def untexthexify(s, shiftin='[', shiftout=']'):
326
+ ''' Decode a textual representation of binary data into binary data.
327
+
328
+ This is the reverse of the `texthexify` function.
329
+
330
+ Outside of the `shiftin`/`shiftout` markers the binary data
331
+ are represented as hexadecimal. Within the markers the bytes
332
+ have the values of the ordinals of the characters.
333
+
334
+ Example:
335
+
336
+ >>> untexthexify('265e25265e25[abcdefghi]29282a29282a')
337
+ b'&^%&^%abcdefghi)(*)(*'
338
+
339
+ Parameters:
340
+ * `s`: the string containing the text representation.
341
+ * `shiftin`: Optional. The marker string commencing a sequence
342
+ of direct text transcription, default `'['`.
343
+ * `shiftout`: Optional. The marker string ending a sequence
344
+ of direct text transcription, default `']'`.
345
+ '''
346
+ chunks = []
347
+ while s:
348
+ hexlen = s.find(shiftin)
349
+ if hexlen < 0:
350
+ break
351
+ if hexlen > 0:
352
+ hextext = s[:hexlen]
353
+ if hexlen % 2 != 0:
354
+ raise ValueError("uneven hex sequence %r" % (hextext,))
355
+ chunks.append(unhexify(s[:hexlen]))
356
+ s = s[hexlen + len(shiftin):]
357
+ textlen = s.find(shiftout)
358
+ if textlen < 0:
359
+ raise ValueError("missing shift out marker %r" % (shiftout,))
360
+ if sys.hexversion < 0x03000000:
361
+ chunks.append(s[:textlen])
362
+ else:
363
+ chunks.append(bytes(ord(c) for c in s[:textlen]))
364
+ s = s[textlen + len(shiftout):]
365
+ if s:
366
+ if len(s) % 2 != 0:
367
+ raise ValueError("uneven hex sequence %r" % (s,))
368
+ chunks.append(unhexify(s))
369
+ return b''.join(chunks)
370
+
371
+ # pylint: disable=redefined-outer-name
372
+ def get_chars(s, offset, gochars):
373
+ ''' Scan the string `s` for characters in `gochars` starting at `offset`.
374
+ Return `(match,new_offset)`.
375
+
376
+ `gochars` may also be a callable, in which case a character
377
+ `ch` is accepted if `gochars(ch)` is true.
378
+ '''
379
+ ooffset = offset
380
+ if callable(gochars):
381
+ while offset < len(s) and gochars(s[offset]):
382
+ offset += 1
383
+ else:
384
+ while offset < len(s) and s[offset] in gochars:
385
+ offset += 1
386
+ return s[ooffset:offset], offset
387
+
388
+ # pylint: disable=redefined-outer-name
389
+ def get_white(s, offset=0):
390
+ ''' Scan the string `s` for characters in `string.whitespace`
391
+ starting at `offset` (default `0`).
392
+ Return `(match,new_offset)`.
393
+ '''
394
+ return get_chars(s, offset, whitespace)
395
+
396
+ # pylint: disable=redefined-outer-name
397
+ def skipwhite(s, offset=0):
398
+ ''' Convenience routine for skipping past whitespace;
399
+ returns the offset of the next nonwhitespace character.
400
+ '''
401
+ _, offset = get_white(s, offset=offset)
402
+ return offset
403
+
404
+ def stripped_dedent(s):
405
+ ''' Slightly smarter dedent which ignores a string's opening indent.
406
+
407
+ Algorithm:
408
+ strip the supplied string `s`, pull off the leading line,
409
+ dedent the rest, put back the leading line.
410
+
411
+ This supports my preferred docstring layout, where the opening
412
+ line of text is on the same line as the opening quote.
413
+
414
+ Example:
415
+
416
+ >>> def func(s):
417
+ ... """ Slightly smarter dedent which ignores a string's opening indent.
418
+ ... Strip the supplied string `s`. Pull off the leading line.
419
+ ... Dedent the rest. Put back the leading line.
420
+ ... """
421
+ ... pass
422
+ ...
423
+ >>> from cs.lex import stripped_dedent
424
+ >>> print(stripped_dedent(func.__doc__))
425
+ Slightly smarter dedent which ignores a string's opening indent.
426
+ Strip the supplied string `s`. Pull off the leading line.
427
+ Dedent the rest. Put back the leading line.
428
+ '''
429
+ s = s.strip()
430
+ lines = s.split('\n')
431
+ if not lines:
432
+ return ''
433
+ line1 = lines.pop(0)
434
+ if not lines:
435
+ return line1
436
+ adjusted = dedent('\n'.join(lines))
437
+ return line1 + '\n' + adjusted
438
+
439
+ @require(lambda offset: offset >= 0)
440
+ def get_prefix_n(s, prefix, n=None, *, offset=0):
441
+ ''' Strip a leading `prefix` and numeric value `n` from the string `s`
442
+ starting at `offset` (default `0`).
443
+ Return the matched prefix, the numeric value and the new offset.
444
+ Returns `(None,None,offset)` on no match.
445
+
446
+ Parameters:
447
+ * `s`: the string to parse
448
+ * `prefix`: the prefix string which must appear at `offset`
449
+ or an object with a `match(str,offset)` method
450
+ such as an `re.Pattern` regexp instance
451
+ * `n`: optional integer value;
452
+ if omitted any value will be accepted, otherwise the numeric
453
+ part must match `n`
454
+
455
+ If `prefix` is a `str`, the "matched prefix" return value is `prefix`.
456
+ Otherwise the "matched prefix" return value is the result of
457
+ the `prefix.match(s,offset)` call. The result must also support
458
+ a `.end()` method returning the offset in `s` beyond the match,
459
+ used to locate the following numeric portion.
460
+
461
+ Examples:
462
+
463
+ >>> import re
464
+ >>> get_prefix_n('s03e01--', 's')
465
+ ('s', 3, 3)
466
+ >>> get_prefix_n('s03e01--', 's', 3)
467
+ ('s', 3, 3)
468
+ >>> get_prefix_n('s03e01--', 's', 4)
469
+ (None, None, 0)
470
+ >>> get_prefix_n('s03e01--', re.compile('[es]',re.I))
471
+ (<re.Match object; span=(0, 1), match='s'>, 3, 3)
472
+ >>> get_prefix_n('s03e01--', re.compile('[es]',re.I), offset=3)
473
+ (<re.Match object; span=(3, 4), match='e'>, 1, 6)
474
+ '''
475
+ no_match = None, None, offset
476
+ if isinstance(prefix, str):
477
+ if s.startswith(prefix, offset):
478
+ matched = prefix
479
+ offset += len(prefix)
480
+ else:
481
+ # no match, return unchanged
482
+ return no_match
483
+ else:
484
+ matched = pfx_call(prefix.match, s, offset)
485
+ if not matched:
486
+ return no_match
487
+ offset = matched.end()
488
+ if offset >= len(s) or not s[offset].isdigit():
489
+ return no_match
490
+ gn, offset = get_decimal_value(s, offset)
491
+ if n is not None and gn != n:
492
+ return no_match
493
+ return matched, gn, offset
494
+
495
+ NUMERAL_NAMES = {
496
+ 'en': {
497
+ # all the single word numbers
498
+ 'zero': 0,
499
+ 'nought': 0,
500
+ 'one': 1,
501
+ 'two': 2,
502
+ 'three': 3,
503
+ 'four': 4,
504
+ 'five': 5,
505
+ 'six': 6,
506
+ 'seven': 7,
507
+ 'eight': 8,
508
+ 'nine': 9,
509
+ 'ten': 10,
510
+ 'eleven': 11,
511
+ 'twelve': 12,
512
+ 'thirteen': 13,
513
+ 'fourteen': 14,
514
+ 'fifteen': 15,
515
+ 'sixteen': 16,
516
+ 'seventeen': 17,
517
+ 'eighteen': 18,
518
+ 'nineteen': 19,
519
+ 'twenty': 20,
520
+ },
521
+ }
522
+
523
+ def get_suffix_part(s, *, keywords=('part',), numeral_map=None):
524
+ ''' Strip a trailing "part N" suffix from the string `s`.
525
+ Return the matched suffix and the number part number.
526
+ Retrn `(None,None)` on no match.
527
+
528
+ Parameters:
529
+ * `s`: the string
530
+ * `keywords`: an iterable of `str` to match, or a single `str`;
531
+ default `'part'`
532
+ * `numeral_map`: an optional mapping of numeral names to numeric values;
533
+ default `NUMERAL_NAMES['en']`, the English numerals
534
+
535
+ Exanmple:
536
+
537
+ >>> get_suffix_part('s09e10 - A New World: Part One')
538
+ (': Part One', 1)
539
+ '''
540
+ if isinstance(keywords, str):
541
+ keywords = (keywords,)
542
+ if numeral_map is None:
543
+ numeral_map = NUMERAL_NAMES['en']
544
+ regexp_s = ''.join(
545
+ (
546
+ r'\W+(',
547
+ r'|'.join(keywords),
548
+ r')\s+(?P<numeral>\d+|',
549
+ r'|'.join(numeral_map.keys()),
550
+ r')\s*$',
551
+ )
552
+ )
553
+ regexp = re.compile(regexp_s, re.I)
554
+ m = regexp.search(s)
555
+ if not m:
556
+ return None, None
557
+ numeral = m.group('numeral')
558
+ try:
559
+ part_n = int(numeral)
560
+ except ValueError:
561
+ try:
562
+ part_n = numeral_map[numeral]
563
+ except KeyError:
564
+ try:
565
+ part_n = numeral_map[numeral.lower()]
566
+ except KeyError:
567
+ return None, None
568
+ return m.group(0), part_n
569
+
570
+ # pylint: disable=redefined-outer-name
571
+ def get_nonwhite(s, offset=0):
572
+ ''' Scan the string `s` for characters not in `string.whitespace`
573
+ starting at `offset` (default `0`).
574
+ Return `(match,new_offset)`.
575
+ '''
576
+ return get_other_chars(s, offset=offset, stopchars=whitespace)
577
+
578
+ # pylint: disable=redefined-outer-name
579
+ def get_decimal(s, offset=0):
580
+ ''' Scan the string `s` for decimal characters starting at `offset` (default `0`).
581
+ Return `(dec_string,new_offset)`.
582
+ '''
583
+ return get_chars(s, offset, digits)
584
+
585
+ # pylint: disable=redefined-outer-name
586
+ def get_decimal_value(s, offset=0):
587
+ ''' Scan the string `s` for a decimal value starting at `offset` (default `0`).
588
+ Return `(value,new_offset)`.
589
+ '''
590
+ value_s, offset = get_decimal(s, offset)
591
+ if not value_s:
592
+ raise ValueError("expected decimal value")
593
+ return int(value_s), offset
594
+
595
+ # pylint: disable=redefined-outer-name
596
+ def get_hexadecimal(s, offset=0):
597
+ ''' Scan the string `s` for hexadecimal characters starting at `offset` (default `0`).
598
+ Return `(hex_string,new_offset)`.
599
+ '''
600
+ return get_chars(s, offset, '0123456789abcdefABCDEF')
601
+
602
+ # pylint: disable=redefined-outer-name
603
+ def get_hexadecimal_value(s, offset=0):
604
+ ''' Scan the string `s` for a hexadecimal value starting at `offset` (default `0`).
605
+ Return `(value,new_offset)`.
606
+ '''
607
+ value_s, offset = get_hexadecimal(s, offset)
608
+ if not value_s:
609
+ raise ValueError("expected hexadecimal value")
610
+ return int('0x' + value_s), offset
611
+
612
+ # pylint: disable=redefined-outer-name
613
+ def get_decimal_or_float_value(s, offset=0):
614
+ ''' Fetch a decimal or basic float (nnn.nnn) value
615
+ from the str `s` at `offset` (default `0`).
616
+ Return `(value,new_offset)`.
617
+ '''
618
+ int_part, offset = get_decimal(s, offset)
619
+ if not int_part:
620
+ raise ValueError("expected decimal or basic float value")
621
+ if offset == len(s) or s[offset] != '.':
622
+ return int(int_part), offset
623
+ sub_part, offset = get_decimal(s, offset + 1)
624
+ return float('.'.join((int_part, sub_part))), offset
625
+
626
+ def get_identifier(
627
+ s, offset=0, alpha=ascii_letters, number=digits, extras='_'
628
+ ):
629
+ ''' Scan the string `s` for an identifier (by default an ASCII
630
+ letter or underscore followed by letters, digits or underscores)
631
+ starting at `offset` (default 0).
632
+ Return `(match,new_offset)`.
633
+
634
+ *Note*: the empty string and an unchanged offset will be returned if
635
+ there is no leading letter/underscore.
636
+
637
+ Parameters:
638
+ * `s`: the string to scan
639
+ * `offset`: the starting offset, default `0`.
640
+ * `alpha`: the characters considered alphabetic,
641
+ default `string.ascii_letters`.
642
+ * `number`: the characters considered numeric,
643
+ default `string.digits`.
644
+ * `extras`: extra characters considered part of an identifier,
645
+ default `'_'`.
646
+ '''
647
+ if offset >= len(s):
648
+ return '', offset
649
+ ch = s[offset]
650
+ if ch not in alpha and ch not in extras:
651
+ return '', offset
652
+ idtail, offset = get_chars(s, offset + 1, alpha + number + extras)
653
+ return ch + idtail, offset
654
+
655
+ # pylint: disable=redefined-outer-name
656
+ def is_identifier(s, offset=0, **kw):
657
+ ''' Test if the string `s` is an identifier
658
+ from position `offset` (default `0`) onward.
659
+ '''
660
+ s2, offset2 = get_identifier(s, offset=offset, **kw)
661
+ return s2 and offset2 == len(s)
662
+
663
+ # pylint: disable=redefined-outer-name
664
+ def get_uc_identifier(s, offset=0, number=digits, extras='_'):
665
+ ''' Scan the string `s` for an identifier as for `get_identifier`,
666
+ but require the letters to be uppercase.
667
+ '''
668
+ return get_identifier(
669
+ s, offset=offset, alpha=ascii_uppercase, number=number, extras=extras
670
+ )
671
+
672
+ def is_uc_identifier(s, offset=0, **kw):
673
+ ''' Test if the string `s` is an uppercase identifier
674
+ from position `offset` (default `0`) onward.
675
+ '''
676
+ s2, offset2 = get_uc_identifier(s, offset=offset, **kw)
677
+ return s2 and offset2 == len(s)
678
+
679
+ # pylint: disable=redefined-outer-name
680
+ def get_dotted_identifier(s, offset=0, **kw):
681
+ ''' Scan the string `s` for a dotted identifier (by default an
682
+ ASCII letter or underscore followed by letters, digits or
683
+ underscores) with optional trailing dot and another dotted
684
+ identifier, starting at `offset` (default `0`).
685
+ Return `(match,new_offset)`.
686
+
687
+ Note: the empty string and an unchanged offset will be returned if
688
+ there is no leading letter/underscore.
689
+
690
+ Keyword arguments are passed to `get_identifier`
691
+ (used for each component of the dotted identifier).
692
+ '''
693
+ offset0 = offset
694
+ _, offset = get_identifier(s, offset=offset, **kw)
695
+ if _:
696
+ while offset < len(s) - 1 and s[offset] == '.':
697
+ _, offset2 = get_identifier(s, offset=offset + 1, **kw)
698
+ if not _:
699
+ break
700
+ offset = offset2
701
+ return s[offset0:offset], offset
702
+
703
+ # pylint: disable=redefined-outer-name
704
+ def is_dotted_identifier(s, offset=0, **kw):
705
+ ''' Test if the string `s` is an identifier from position `offset` onward.
706
+ '''
707
+ s2, offset2 = get_dotted_identifier(s, offset=offset, **kw)
708
+ return len(s2) > 0 and offset2 == len(s)
709
+
710
+ # pylint: disable=redefined-outer-name
711
+ def get_other_chars(s, offset=0, stopchars=None):
712
+ ''' Scan the string `s` for characters not in `stopchars` starting
713
+ at `offset` (default `0`).
714
+ Return `(match,new_offset)`.
715
+ '''
716
+ ooffset = offset
717
+ while offset < len(s) and s[offset] not in stopchars:
718
+ offset += 1
719
+ return s[ooffset:offset], offset
720
+
721
+ # default character map for \c notation
722
+ SLOSH_CHARMAP = {
723
+ 'a': '\a',
724
+ 'b': '\b',
725
+ 'f': '\f',
726
+ 'n': '\n',
727
+ 'r': '\r',
728
+ 't': '\t',
729
+ 'v': '\v',
730
+ }
731
+
732
+ def slosh_mapper(c, charmap=None):
733
+ ''' Return a string to replace backslash-`c`, or `None`.
734
+ '''
735
+ if charmap is None:
736
+ charmap = SLOSH_CHARMAP
737
+ return charmap.get(c)
738
+
739
+ # pylint: disable=too-many-arguments,too-many-locals,too-many-branches
740
+ # pylint: disable=too-many-statements,too-many-arguments
741
+ def get_sloshed_text(
742
+ s, delim, offset=0, slosh='\\', mapper=slosh_mapper, specials=None
743
+ ):
744
+ ''' Collect slosh escaped text from the string `s` from position
745
+ `offset` (default `0`) and return the decoded unicode string and
746
+ the offset of the completed parse.
747
+
748
+ Parameters:
749
+ * `delim`: end of string delimiter, such as a single or double quote.
750
+ * `offset`: starting offset within `s`, default `0`.
751
+ * `slosh`: escape character, default a slosh ('\\').
752
+ * `mapper`: a mapping function which accepts a single character
753
+ and returns a replacement string or `None`; this is used the
754
+ replace things such as '\\t' or '\\n'. The default is the
755
+ `slosh_mapper` function, whose default mapping is `SLOSH_CHARMAP`.
756
+ * `specials`: a mapping of other special character sequences and parse
757
+ functions for gathering them up. When one of the special
758
+ character sequences is found in the string, the parse
759
+ function is called to parse at that point.
760
+ The parse functions accept
761
+ `s` and the offset of the special character. They return
762
+ the decoded string and the offset past the parse.
763
+
764
+ The escape character `slosh` introduces an encoding of some
765
+ replacement text whose value depends on the following character.
766
+ If the following character is:
767
+ * the escape character `slosh`, insert the escape character.
768
+ * the string delimiter `delim`, insert the delimiter.
769
+ * the character 'x', insert the character with code from the following
770
+ 2 hexadecimal digits.
771
+ * the character 'u', insert the character with code from the following
772
+ 4 hexadecimal digits.
773
+ * the character 'U', insert the character with code from the following
774
+ 8 hexadecimal digits.
775
+ * a character from the keys of `mapper`
776
+ '''
777
+ if specials is not None:
778
+ # gather up starting character of special keys and a list of
779
+ # keys in reverse order of length
780
+ special_starts = set()
781
+ special_seqs = []
782
+ for special in specials.keys():
783
+ if not special:
784
+ raise ValueError(
785
+ 'empty strings may not be used as keys for specials: %r' %
786
+ (specials,)
787
+ )
788
+ special_starts.add(special[0])
789
+ special_seqs.append(special)
790
+ special_starts = ''.join(special_starts)
791
+ special_seqs = sorted(special_seqs, key=lambda s: -len(s))
792
+ chunks = []
793
+ slen = len(s)
794
+ while True:
795
+ if offset >= slen:
796
+ if delim is not None:
797
+ raise ValueError("missing delimiter %r at offset %d" % (delim, offset))
798
+ break
799
+ offset0 = offset
800
+ c = s[offset]
801
+ offset += 1
802
+ if delim is not None and c == delim:
803
+ # delimiter; end text
804
+ break
805
+ if c == slosh:
806
+ # \something
807
+ if offset >= slen:
808
+ raise ValueError('incomplete slosh escape at offset %d' % (offset0,))
809
+ offset1 = offset
810
+ c = s[offset]
811
+ offset += 1
812
+ if c == slosh or (delim is not None and c == delim):
813
+ chunks.append(c)
814
+ continue
815
+ if c == 'x':
816
+ # \xhh
817
+ if slen - offset < 2:
818
+ raise ValueError(
819
+ 'short hexcode for %sxhh at offset %d' % (slosh, offset0)
820
+ )
821
+ hh = s[offset:offset + 2]
822
+ offset += 2
823
+ chunks.append(chr(int(hh, 16)))
824
+ continue
825
+ if c == 'u':
826
+ # \uhhhh
827
+ if slen - offset < 4:
828
+ raise ValueError(
829
+ 'short hexcode for %suhhhh at offset %d' % (slosh, offset0)
830
+ )
831
+ hh = s[offset:offset + 4]
832
+ offset += 4
833
+ chunks.append(chr(int(hh, 16)))
834
+ continue
835
+ if c == 'U':
836
+ # \Uhhhhhhhh
837
+ if slen - offset < 8:
838
+ raise ValueError(
839
+ 'short hexcode for %sUhhhhhhhh at offset %d' % (slosh, offset0)
840
+ )
841
+ hh = s[offset:offset + 8]
842
+ offset += 8
843
+ chunks.append(chr(int(hh, 16)))
844
+ continue
845
+ chunk = mapper(c)
846
+ if chunk is not None:
847
+ # supplied \X mapping
848
+ chunks.append(chunk)
849
+ continue
850
+ # check for escaped special syntax
851
+ if specials is not None and c in special_starts:
852
+ # test sequence prefixes from longest to shortest
853
+ chunk = None
854
+ for seq in special_seqs:
855
+ if s.startswith(seq, offset1):
856
+ # special sequence
857
+ chunk = c
858
+ break
859
+ if chunk is not None:
860
+ chunks.append(chunk)
861
+ continue
862
+ raise ValueError(
863
+ 'unrecognised %s%s escape at offset %d' % (slosh, c, offset0)
864
+ )
865
+ if specials is not None and c in special_starts:
866
+ # test sequence prefixes from longest to shortest
867
+ chunk = None
868
+ for seq in special_seqs:
869
+ if s.startswith(seq, offset0):
870
+ # special sequence
871
+ chunk, offset = specials[seq](s, offset0)
872
+ if offset < offset0 + 1:
873
+ raise ValueError(
874
+ "special parser for %r at offset %d moved offset backwards" %
875
+ (c, offset0)
876
+ )
877
+ break
878
+ if chunk is not None:
879
+ chunks.append(chunk)
880
+ continue
881
+ chunks.append(c)
882
+ continue
883
+ while offset < slen:
884
+ c = s[offset]
885
+ if (c == slosh or (delim is not None and c == delim)
886
+ or (specials is not None and c in special_starts)):
887
+ break
888
+ offset += 1
889
+ chunks.append(s[offset0:offset])
890
+ return ''.join(chunks), offset
891
+
892
+ # pylint: disable=redefined-outer-name
893
+ def get_envvar(s, offset=0, environ=None, default=None, specials=None):
894
+ ''' Parse a simple environment variable reference to $varname or
895
+ $x where "x" is a special character.
896
+
897
+ Parameters:
898
+ * `s`: the string with the variable reference
899
+ * `offset`: the starting point for the reference
900
+ * `default`: default value for missing environment variables;
901
+ if `None` (the default) a `ValueError` is raised
902
+ * `environ`: the environment mapping, default `os.environ`
903
+ * `specials`: the mapping of special single character variables
904
+ '''
905
+ if environ is None:
906
+ environ = os.environ
907
+ offset0 = offset
908
+ if not s.startswith('$', offset):
909
+ raise ValueError("no leading '$' at offset %d: %r" % (offset, s))
910
+ offset += 1
911
+ if offset >= len(s):
912
+ raise ValueError(
913
+ "short string, nothing after '$' at offset %d" % (offset,)
914
+ )
915
+ identifier, offset = get_identifier(s, offset)
916
+ if identifier:
917
+ value = environ.get(identifier, default)
918
+ if value is None:
919
+ raise ValueError(
920
+ "unknown envvar name $%s, offset %d: %r" % (identifier, offset0, s)
921
+ )
922
+ return value, offset
923
+ c = s[offset]
924
+ offset += 1
925
+ if specials is not None and c in specials:
926
+ return specials[c], offset
927
+ raise ValueError("unsupported special variable $%s" % (c,))
928
+
929
+ # pylint: disable=too-many-arguments
930
+ def get_qstr(
931
+ s, offset=0, q='"', environ=None, default=None, env_specials=None
932
+ ):
933
+ ''' Get quoted text with slosh escapes and optional environment substitution.
934
+
935
+ Parameters:
936
+ * `s`: the string containg the quoted text.
937
+ * `offset`: the starting point, default `0`.
938
+ * `q`: the quote character, default `'"'`. If `q` is `None`,
939
+ do not expect the string to be delimited by quote marks.
940
+ * `environ`: if not `None`, also parse and expand `$`*envvar* references.
941
+ * `default`: passed to `get_envvar`
942
+ '''
943
+ if environ is None and default is not None:
944
+ raise ValueError(
945
+ "environ is None but default is not None (%r)" % (default,)
946
+ )
947
+ if q is None:
948
+ delim = None
949
+ else:
950
+ if offset >= len(s):
951
+ raise ValueError("short string, no opening quote")
952
+ delim = s[offset]
953
+ offset += 1
954
+ if delim != q:
955
+ raise ValueError("expected opening quote %r, found %r" % (
956
+ q,
957
+ delim,
958
+ ))
959
+ if environ is None:
960
+ return get_sloshed_text(s, delim, offset)
961
+ getvar = partial(
962
+ get_envvar, environ=environ, default=default, specials=env_specials
963
+ )
964
+ return get_sloshed_text(s, delim, offset, specials={'$': getvar})
965
+
966
+ # pylint: disable=redefined-outer-name
967
+ def get_qstr_or_identifier(s, offset):
968
+ ''' Parse a double quoted string or an identifier.
969
+ '''
970
+ if s.startswith('"', offset):
971
+ return get_qstr(s, offset, q='"')
972
+ return get_identifier(s, offset)
973
+
974
+ # pylint: disable=redefined-outer-name
975
+ def get_delimited(s, offset, delim):
976
+ ''' Collect text from the string `s` from position `offset` up
977
+ to the first occurence of delimiter `delim`; return the text
978
+ excluding the delimiter and the offset after the delimiter.
979
+ '''
980
+ pos = s.find(delim, offset)
981
+ if pos < offset:
982
+ raise ValueError(
983
+ "delimiter %r not found after offset %d" % (delim, offset)
984
+ )
985
+ return s[offset:pos], pos + len(delim)
986
+
987
+ # pylint: disable=redefined-outer-name
988
+ def get_tokens(s, offset, getters):
989
+ ''' Parse the string `s` from position `offset` using the supplied
990
+ tokeniser functions `getters`.
991
+ Return the list of tokens matched and the final offset.
992
+
993
+ Parameters:
994
+ * `s`: the string to parse.
995
+ * `offset`: the starting position for the parse.
996
+ * `getters`: an iterable of tokeniser specifications.
997
+
998
+ Each tokeniser specification `getter` is either:
999
+ * a callable expecting `(s,offset)` and returning `(token,new_offset)`
1000
+ * a literal string, to be matched exactly
1001
+ * a `tuple` or `list` with values `(func,args,kwargs)`;
1002
+ call `func(s,offset,*args,**kwargs)`
1003
+ * an object with a `.match` method such as a regex;
1004
+ call `getter.match(s,offset)` and return a match object with
1005
+ a `.end()` method returning the offset of the end of the match
1006
+ '''
1007
+ tokens = []
1008
+ # pylint: disable=cell-var-from-loop
1009
+ for getter in getters:
1010
+ args = ()
1011
+ kwargs = {}
1012
+ if callable(getter):
1013
+ func = getter
1014
+ elif isinstance(getter, str):
1015
+
1016
+ # pylint: disable=redefined-outer-name
1017
+ def func(s, offset):
1018
+ ''' Wrapper for a literal string: require the string to be
1019
+ present at the current offset.
1020
+ '''
1021
+ if s.startswith(getter, offset):
1022
+ return getter, offset + len(getter)
1023
+ raise ValueError("string %r not found at offset %d" % (getter, offset))
1024
+ elif isinstance(getter, (tuple, list)):
1025
+ func, args, kwargs = getter
1026
+ elif hasattr(getter, 'match'):
1027
+
1028
+ # pylint: disable=redefined-outer-name
1029
+ def func(s, offset):
1030
+ ''' Wrapper for a getter with a .match method, such as a regular
1031
+ expression.
1032
+ '''
1033
+ m = getter.match(s, offset)
1034
+ if m:
1035
+ return m, m.end()
1036
+ raise ValueError("no match for %s at offset %d" % (getter, offset))
1037
+ else:
1038
+ raise ValueError("unsupported getter: %r" % (getter,))
1039
+ token, offset = func(s, offset, *args, **kwargs)
1040
+ tokens.append(token)
1041
+ return tokens, offset
1042
+
1043
+ # pylint: disable=redefined-outer-name
1044
+ def match_tokens(s, offset, getters):
1045
+ ''' Wrapper for `get_tokens` which catches `ValueError` exceptions
1046
+ and returns `(None,offset)`.
1047
+ '''
1048
+ try:
1049
+ return get_tokens(s, offset, getters)
1050
+ except ValueError:
1051
+ return None, offset
1052
+
1053
+ def isUC_(s):
1054
+ ''' Check that a string matches the regular expression `^[A-Z][A-Z_0-9]*$`.
1055
+ '''
1056
+ if s.isalpha() and s.isupper():
1057
+ return True
1058
+ if not s:
1059
+ return False
1060
+ if not s[0].isupper():
1061
+ return False
1062
+ for c in s[1:]:
1063
+ if c != '_' and not c.isupper() and not c.isdigit():
1064
+ return False
1065
+ return True
1066
+
1067
+ def parseUC_sAttr(attr):
1068
+ ''' Take an attribute name `attr` and return `(key,is_plural)`.
1069
+
1070
+ Examples:
1071
+ * `'FOO'` returns `('FOO',False)`.
1072
+ * `'FOOs'` or `'FOOes'` returns `('FOO',True)`.
1073
+ Otherwise return `(None,False)`.
1074
+ '''
1075
+ if len(attr) > 1:
1076
+ if attr[-1] == 's':
1077
+ if attr[-2] == 'e':
1078
+ k = attr[:-2]
1079
+ if isUC_(k):
1080
+ return k, True
1081
+ else:
1082
+ k = attr[:-1]
1083
+ if isUC_(k):
1084
+ return k, True
1085
+ if isUC_(attr):
1086
+ return attr, False
1087
+ return None, False
1088
+
1089
+ def as_lines(chunks, partials=None):
1090
+ ''' Generator yielding complete lines from arbitrary pieces of text from
1091
+ the iterable of `str` `chunks`.
1092
+
1093
+ After completion, any remaining newline-free chunks remain
1094
+ in the partials list; they will be unavailable to the caller
1095
+ unless the list is presupplied.
1096
+ '''
1097
+ if partials is None:
1098
+ partials = []
1099
+ if any('\n' in p for p in partials):
1100
+ raise ValueError("newline in partials: %r" % (partials,))
1101
+ for chunk in chunks:
1102
+ pos = 0
1103
+ nl_pos = chunk.find('\n', pos)
1104
+ while nl_pos >= pos:
1105
+ partials.append(chunk[pos:nl_pos + 1])
1106
+ yield ''.join(partials)
1107
+ partials[:] = ()
1108
+ pos = nl_pos + 1
1109
+ nl_pos = chunk.find('\n', pos)
1110
+ if pos < len(chunk):
1111
+ partials.append(chunk[pos:])
1112
+
1113
+ # pylint: disable=redefined-outer-name
1114
+ def cutprefix(s, prefix):
1115
+ ''' Strip a `prefix` from the front of `s`.
1116
+ Return the suffix if `s.startswith(prefix)`, else `s`.
1117
+
1118
+ Example:
1119
+
1120
+ >>> abc_def = 'abc.def'
1121
+ >>> cutprefix(abc_def, 'abc.')
1122
+ 'def'
1123
+ >>> cutprefix(abc_def, 'zzz.')
1124
+ 'abc.def'
1125
+ >>> cutprefix(abc_def, '.zzz') is abc_def
1126
+ True
1127
+ '''
1128
+ if prefix and s.startswith(prefix):
1129
+ return s[len(prefix):]
1130
+ return s
1131
+
1132
+ # pylint: disable=redefined-outer-name
1133
+ def cutsuffix(s, suffix):
1134
+ ''' Strip a `suffix` from the end of `s`.
1135
+ Return the prefix if `s.endswith(suffix)`, else `s`.
1136
+
1137
+ Example:
1138
+
1139
+ >>> abc_def = 'abc.def'
1140
+ >>> cutsuffix(abc_def, '.def')
1141
+ 'abc'
1142
+ >>> cutsuffix(abc_def, '.zzz')
1143
+ 'abc.def'
1144
+ >>> cutsuffix(abc_def, '.zzz') is abc_def
1145
+ True
1146
+ '''
1147
+ if suffix and s.endswith(suffix):
1148
+ return s[:-len(suffix)]
1149
+ return s
1150
+
1151
+ def common_prefix(*strs):
1152
+ ''' Return the common prefix of the strings `strs`.
1153
+
1154
+ Examples:
1155
+
1156
+ >>> common_prefix('abc', 'def')
1157
+ ''
1158
+ >>> common_prefix('abc', 'abd')
1159
+ 'ab'
1160
+ >>> common_prefix('abc', 'abcdef')
1161
+ 'abc'
1162
+ >>> common_prefix('abc', 'abcdef', 'abz')
1163
+ 'ab'
1164
+ >>> # contrast with cs.fileutils.common_path_prefix
1165
+ >>> common_prefix('abc/def', 'abc/def1', 'abc/def2')
1166
+ 'abc/def'
1167
+ '''
1168
+ return strs[0][:common_prefix_length(*strs)]
1169
+
1170
+ def common_suffix(*strs):
1171
+ ''' Return the common suffix of the strings `strs`.
1172
+ '''
1173
+ length = common_suffix_length(*strs)
1174
+ if not length:
1175
+ # catch 0 length suffix specially, because -0 == 0
1176
+ return ''
1177
+ return strs[0][-length:]
1178
+
1179
+ # pylint: disable=redefined-outer-name,unsubscriptable-object
1180
+ def cropped(
1181
+ s: str, max_length: int = 32, roffset: int = 1, ellipsis: str = '...'
1182
+ ):
1183
+ ''' If the length of `s` exceeds `max_length` (default `32`),
1184
+ replace enough of the tail with `ellipsis`
1185
+ and the last `roffset` (default `1`) characters of `s`
1186
+ to fit in `max_length` characters.
1187
+ '''
1188
+ if len(s) > max_length:
1189
+ if roffset > 0:
1190
+ s = s[:max_length - len(ellipsis) - roffset] + ellipsis + s[-roffset:]
1191
+ else:
1192
+ s = s[:max_length - len(ellipsis)] + ellipsis
1193
+ return s
1194
+
1195
+ def cropped_repr(o, roffset=1, max_length=32, inner_max_length=None):
1196
+ ''' Compute a cropped `repr()` of `o`.
1197
+
1198
+ Parameters:
1199
+ * `o`: the object to represent
1200
+ * `max_length`: the maximum length of the representation, default `32`
1201
+ * `inner_max_length`: the maximum length of the representations
1202
+ of members of `o`, default `max_length//2`
1203
+ * `roffset`: the number of trailing characters to preserve, default `1`
1204
+ '''
1205
+ if inner_max_length is None:
1206
+ inner_max_length = max_length // 2
1207
+ if isinstance(o, (tuple, list)):
1208
+ left = '(' if isinstance(o, tuple) else '['
1209
+ right = (',)' if len(o) == 1 else ')') if isinstance(o, tuple) else ']'
1210
+ o_repr = left + ','.join(
1211
+ map(
1212
+ lambda m:
1213
+ cropped_repr(m, max_length=inner_max_length, roffset=roffset), o
1214
+ )
1215
+ ) + right
1216
+ elif isinstance(o, dict):
1217
+ o_repr = '{' + ','.join(
1218
+ map(
1219
+ lambda kv: cropped_repr(
1220
+ kv[0], max_length=inner_max_length, roffset=roffset
1221
+ ) + ':' +
1222
+ cropped_repr(kv[1], max_length=inner_max_length, roffset=roffset),
1223
+ o.items()
1224
+ )
1225
+ ) + '}'
1226
+ else:
1227
+ o_repr = repr(o)
1228
+ return cropped(o_repr, max_length=max_length, roffset=roffset)
1229
+
1230
+ # pylint: disable=redefined-outer-name
1231
+ def get_ini_clausename(s, offset=0):
1232
+ ''' Parse a `[`*clausename*`]` string from `s` at `offset` (default `0`).
1233
+ Return `(clausename,new_offset)`.
1234
+ '''
1235
+ if not s.startswith('[', offset):
1236
+ raise ValueError("missing opening '[' at position %d" % (offset,))
1237
+ offset = skipwhite(s, offset + 1)
1238
+ clausename, offset = get_qstr_or_identifier(s, offset)
1239
+ if not clausename:
1240
+ raise ValueError(
1241
+ "missing clausename identifier at position %d" % (offset,)
1242
+ )
1243
+ offset = skipwhite(s, offset)
1244
+ if not s.startswith(']', offset):
1245
+ raise ValueError("missing closing ']' at position %d" % (offset,))
1246
+ return clausename, offset + 1
1247
+
1248
+ # pylint: disable=redefined-outer-name
1249
+ def get_ini_clause_entryname(s, offset=0):
1250
+ ''' Parse a `[`*clausename*`]`*entryname* string
1251
+ from `s` at `offset` (default `0`).
1252
+ Return `(clausename,entryname,new_offset)`.
1253
+ '''
1254
+ clausename, offset = get_ini_clausename(s, offset=offset)
1255
+ offset = skipwhite(s, offset)
1256
+ entryname, offset = get_qstr_or_identifier(s, offset)
1257
+ if not entryname:
1258
+ raise ValueError("missing entryname identifier at position %d" % (offset,))
1259
+ return clausename, entryname, offset
1260
+
1261
+ def camelcase(snakecased, first_letter_only=False):
1262
+ ''' Convert a snake cased string `snakecased` into camel case.
1263
+
1264
+ Parameters:
1265
+ * `snakecased`: the snake case string to convert
1266
+ * `first_letter_only`: optional flag (default `False`);
1267
+ if true then just ensure that the first character of a word
1268
+ is uppercased, otherwise use `str.title`
1269
+
1270
+ Example:
1271
+
1272
+ >>> camelcase('abc_def')
1273
+ 'abcDef'
1274
+ >>> camelcase('ABc_def')
1275
+ 'abcDef'
1276
+ >>> camelcase('abc_dEf')
1277
+ 'abcDef'
1278
+ >>> camelcase('abc_dEf', first_letter_only=True)
1279
+ 'abcDEf'
1280
+ '''
1281
+ words = snakecased.split('_')
1282
+ for i, word in enumerate(words):
1283
+ if not word:
1284
+ continue
1285
+ if first_letter_only:
1286
+ word = word[0].upper() + word[1:]
1287
+ else:
1288
+ word = word.title()
1289
+ if i == 0:
1290
+ word = word[0].lower() + word[1:]
1291
+ words[i] = word
1292
+ return ''.join(words)
1293
+
1294
+ def snakecase(camelcased):
1295
+ ''' Convert a camel cased string `camelcased` into snake case.
1296
+
1297
+ Parameters:
1298
+ * `cameelcased`: the cameel case string to convert
1299
+ * `first_letter_only`: optional flag (default `False`);
1300
+ if true then just ensure that the first character of a word
1301
+ is uppercased, otherwise use `str.title`
1302
+
1303
+ Example:
1304
+
1305
+ >>> snakecase('abcDef')
1306
+ 'abc_def'
1307
+ >>> snakecase('abcDEf')
1308
+ 'abc_def'
1309
+ >>> snakecase('AbcDef')
1310
+ 'abc_def'
1311
+ '''
1312
+ strs = []
1313
+ was_lower = False
1314
+ for _, c in enumerate(camelcased):
1315
+ if c.isupper():
1316
+ c = c.lower()
1317
+ if was_lower:
1318
+ # boundary
1319
+ was_lower = False
1320
+ strs.append('_')
1321
+ else:
1322
+ was_lower = True
1323
+ strs.append(c)
1324
+ return ''.join(strs)
1325
+
1326
+ def split_remote_path(remotepath: str) -> Tuple[Union[str, None], str]:
1327
+ ''' Split a path with an optional leading `[user@]rhost:` prefix
1328
+ into the prefix and the remaining path.
1329
+ `None` is returned for the prefix is there is none.
1330
+ This is useful for things like `rsync` targets etc.
1331
+ '''
1332
+ ssh_target = None
1333
+ # check for [user@]rhost
1334
+ try:
1335
+ prefix, suffix = remotepath.split(':', 1)
1336
+ except ValueError:
1337
+ pass
1338
+ else:
1339
+ if prefix and '/' not in prefix:
1340
+ ssh_target = prefix
1341
+ remotepath = suffix
1342
+ return ssh_target, remotepath
1343
+
1344
+ # pylint: disable=redefined-outer-name
1345
+ def format_escape(s):
1346
+ ''' Escape `{}` characters in a string to protect them from `str.format`.
1347
+ '''
1348
+ return s.replace('{', '{{').replace('}', '}}')
1349
+
1350
+ class FormatAsError(LookupError):
1351
+ ''' Subclass of `LookupError` for use by `format_as`.
1352
+ '''
1353
+
1354
+ DEFAULT_SEPARATOR = '; '
1355
+
1356
+ def __init__(self, key, format_s, format_mapping, error_sep=None):
1357
+ if error_sep is None:
1358
+ error_sep = self.DEFAULT_SEPARATOR
1359
+ LookupError.__init__(self, key)
1360
+ self.args = (key, format_s, format_mapping, error_sep)
1361
+
1362
+ def __str__(self):
1363
+ key, format_s, format_mapping, error_sep = self.args
1364
+ return error_sep.join(
1365
+ (
1366
+ "format fails, missing key: %s" % (key,),
1367
+ "format string was: %r" % (format_s,),
1368
+ "available keys: %s" % (' '.join(sorted(format_mapping.keys()))),
1369
+ )
1370
+ )
1371
+
1372
+ @decorator
1373
+ def format_recover(method):
1374
+ ''' Decorator for `__format__` methods which replaces failed formats
1375
+ with `{self:format_spec}`.
1376
+ '''
1377
+
1378
+ def format_recovered(self, format_spec):
1379
+ try:
1380
+ return method(self, format_spec)
1381
+ except ValueError as e:
1382
+ warning(
1383
+ "@format_recover: %s.%s(%r): %s, falling back via %r",
1384
+ type(self).__name__, funcname(method), format_spec, e,
1385
+ "f'{{{self}:{format_spec}}}'"
1386
+ )
1387
+ return f'{{{self}:{format_spec}}}'
1388
+
1389
+ return format_recovered
1390
+
1391
+ @typechecked
1392
+ @fmtdoc
1393
+ def format_as(
1394
+ format_s: str,
1395
+ format_mapping,
1396
+ formatter=None,
1397
+ error_sep=None,
1398
+ strict=None,
1399
+ ):
1400
+ ''' Format the string `format_s` using `Formatter.vformat`,
1401
+ return the formatted result.
1402
+ This is a wrapper for `str.format_map`
1403
+ which raises a more informative `FormatAsError` exception on failure.
1404
+
1405
+ Parameters:
1406
+ * `format_s`: the format string to use as the template
1407
+ * `format_mapping`: the mapping of available replacement fields
1408
+ * `formatter`: an optional `string.Formatter`-like instance
1409
+ with a `.vformat(format_string,args,kwargs)` method,
1410
+ usually a subclass of `string.Formatter`;
1411
+ if not specified then `FormatableFormatter` is used
1412
+ * `error_sep`: optional separator for the multipart error message,
1413
+ default from `FormatAsError.DEFAULT_SEPARATOR`:
1414
+ `'{FormatAsError.DEFAULT_SEPARATOR}'`
1415
+ * `strict`: optional flag (default `False`)
1416
+ indicating that an unresolveable field should raise a
1417
+ `KeyError` instead of inserting a placeholder
1418
+ '''
1419
+ if formatter is None:
1420
+ formatter = FormatableFormatter(format_mapping)
1421
+ if strict is None:
1422
+ strict = formatter.format_mode.strict
1423
+ with formatter.format_mode(strict=strict):
1424
+ try:
1425
+ formatted = formatter.vformat(format_s, (), format_mapping)
1426
+ except KeyError as e:
1427
+ # pylint: disable=raise-missing-from
1428
+ raise FormatAsError(
1429
+ e.args[0], format_s, format_mapping, error_sep=error_sep
1430
+ )
1431
+ return formatted
1432
+
1433
+ _format_as = format_as # for reuse in the format_as method below
1434
+
1435
+ def format_attribute(method):
1436
+ ''' A decorator to mark a method as available as a format method.
1437
+ Requires the enclosing class to be decorated with `@has_format_attributes`.
1438
+
1439
+ For example,
1440
+ the `FormatableMixin.json` method is defined like this:
1441
+
1442
+ @format_attribute
1443
+ def json(self):
1444
+ return self.FORMAT_JSON_ENCODER.encode(self)
1445
+
1446
+ which allows a `FormatableMixin` subclass instance
1447
+ to be used in a format string like this:
1448
+
1449
+ {instance:json}
1450
+
1451
+ to insert a JSON transcription of the instance.
1452
+
1453
+ It is recommended that methods marked with `@format_attribute`
1454
+ have no side effects and do not modify state,
1455
+ as they are intended for use in ad hoc format strings
1456
+ supplied by an end user.
1457
+ '''
1458
+ method.is_format_attribute = True
1459
+ return method
1460
+
1461
+ @decorator
1462
+ def has_format_attributes(cls, inherit=()):
1463
+ ''' Class decorator to walk this class for direct methods
1464
+ marked as for use in format strings
1465
+ and to include them in `cls.format_attributes()`.
1466
+
1467
+ Methods are normally marked with the `@format_attribute` decorator.
1468
+
1469
+ If `inherit` is true the base format attributes will be
1470
+ obtained from other classes:
1471
+ * `inherit` is `True`: use `cls.__mro__`
1472
+ * `inherit` is a class: use that class
1473
+ * otherwise assume `inherit` is an iterable of classes
1474
+ For each class `otherclass`, update the initial attribute
1475
+ mapping from `otherclass.get_format_attributes()`.
1476
+ '''
1477
+ attributes = cls.get_format_attributes()
1478
+ if inherit:
1479
+ if inherit is True:
1480
+ classes = cls.__mro__
1481
+ elif isinstance(inherit, type):
1482
+ classes = (inherit,)
1483
+ else:
1484
+ classes = inherit
1485
+ for superclass in classes:
1486
+ try:
1487
+ super_attributes = superclass.get_format_attributes()
1488
+ except AttributeError:
1489
+ pass
1490
+ else:
1491
+ attributes.update(super_attributes)
1492
+ for attr in dir(cls):
1493
+ try:
1494
+ attribute = getattr(cls, attr)
1495
+ except AttributeError:
1496
+ pass
1497
+ else:
1498
+ if getattr(attribute, 'is_format_attribute', False):
1499
+ attributes[attr] = attribute
1500
+ return cls
1501
+
1502
+ class FormatableFormatter(Formatter):
1503
+ ''' A `string.Formatter` subclass interacting with objects
1504
+ which inherit from `FormatableMixin`.
1505
+ '''
1506
+
1507
+ FORMAT_RE_LITERAL_TEXT = re.compile(r'([^{]+|{{)*')
1508
+ FORMAT_RE_IDENTIFIER_s = r'[a-z_][a-z_0-9]*'
1509
+ FORMAT_RE_ARG_NAME_s = rf'({FORMAT_RE_IDENTIFIER_s}|\d+(\.\d+)?[a-z]+)'
1510
+ FORMAT_RE_ATTRIBUTE_NAME_s = rf'\.{FORMAT_RE_IDENTIFIER_s}'
1511
+ FORMAT_RE_ELEMENT_INDEX_s = r'[^]]*'
1512
+ FORMAT_RE_FIELD_EXPR_s = (
1513
+ rf'{FORMAT_RE_ARG_NAME_s}'
1514
+ rf'({FORMAT_RE_ATTRIBUTE_NAME_s}|\[{FORMAT_RE_ELEMENT_INDEX_s}\]'
1515
+ rf')*'
1516
+ )
1517
+ FORMAT_RE_FIELD_EXPR = re.compile(FORMAT_RE_FIELD_EXPR_s, re.I)
1518
+ FORMAT_RE_FIELD = re.compile(
1519
+ (
1520
+ r'{' + rf'(?P<arg_name>{FORMAT_RE_FIELD_EXPR_s})?' +
1521
+ r'(!(?P<conversion>[^:}]*))?' + r'(:(?P<format_spec>[^}]*))?' + r'}'
1522
+ ), re.I
1523
+ )
1524
+
1525
+ @property
1526
+ def format_mode(self):
1527
+ ''' Thread local state object.
1528
+
1529
+ Attributes:
1530
+ * `strict`: initially `False`; raise a `KeyError` for
1531
+ unresolveable field names
1532
+ '''
1533
+ try:
1534
+ lock = self.__dict__['_lock']
1535
+ except KeyError:
1536
+ lock = self.__dict__['_lock'] = Lock()
1537
+ with lock:
1538
+ try:
1539
+ mode = self.__dict__['format_mode']
1540
+ except KeyError:
1541
+ # pylint: disable=import-outside-toplevel
1542
+ from cs.threads import ThreadState
1543
+ mode = self.__dict__['format_mode'] = ThreadState(strict=False)
1544
+ return mode
1545
+
1546
+ if False: # pylint: disable=using-constant-test
1547
+
1548
+ @classmethod
1549
+ @typechecked
1550
+ def parse(cls, format_string: str):
1551
+ ''' Parse a format string after the fashion of `Formatter.parse`,
1552
+ yielding `(literal,arg_name,format_spec,conversion)` tuples.
1553
+
1554
+ Unlike `Formatter.parse`,
1555
+ this does not validate the `conversion` part preemptively,
1556
+ supporting extended values for use with the `convert_field` method.
1557
+ '''
1558
+ offset = 0
1559
+ while offset < len(format_string):
1560
+ m_literal = cls.FORMAT_RE_LITERAL_TEXT.match(format_string, offset)
1561
+ literal = m_literal.group()
1562
+ offset = m_literal.end()
1563
+ if offset == len(format_string):
1564
+ # nothing after the literal text
1565
+ if literal:
1566
+ yield literal, None, None, None
1567
+ return
1568
+ m_field = cls.FORMAT_RE_FIELD.match(format_string, offset)
1569
+ if not m_field:
1570
+ raise ValueError(
1571
+ "expected a field at offset %d: found %r" %
1572
+ (offset, format_string[offset:])
1573
+ )
1574
+ yield (
1575
+ literal,
1576
+ m_field.group('arg_name'),
1577
+ m_field.group('format_spec') or '',
1578
+ m_field.group('conversion'),
1579
+ )
1580
+ offset = m_field.end()
1581
+
1582
+ @staticmethod
1583
+ def get_arg_name(field_name):
1584
+ ''' Default initial arg_name is an identifier.
1585
+
1586
+ Returns `(prefix,offset)`, and `('',0)` if there is no arg_name.
1587
+ '''
1588
+ return get_identifier(field_name)
1589
+
1590
+ # pylint: disable=arguments-differ
1591
+ @pfx_method
1592
+ def get_field(self, field_name, args, kwargs):
1593
+ ''' Get the object referenced by the field text `field_name`.
1594
+ Raises `KeyError` for an unknown `field_name`.
1595
+ '''
1596
+ assert not args
1597
+ with Pfx("field_name=%r: kwargs=%r", field_name, kwargs):
1598
+ arg_name, offset = self.get_arg_name(field_name)
1599
+ arg_value, _ = self.get_value(arg_name, args, kwargs)
1600
+ # resolve the rest of the field
1601
+ subfield = self.get_subfield(arg_value, field_name[offset:])
1602
+ return subfield, field_name
1603
+
1604
+ @staticmethod
1605
+ def get_subfield(value, subfield_text: str):
1606
+ ''' Resolve `value` against `subfield_text`,
1607
+ the remaining field text after the term which resolved to `value`.
1608
+
1609
+ For example, a format `{name.blah[0]}`
1610
+ has the field text `name.blah[0]`.
1611
+ A `get_field` implementation might initially
1612
+ resolve `name` to some value,
1613
+ leaving `.blah[0]` as the `subfield_text`.
1614
+ This method supports taking that value
1615
+ and resolving it against the remaining text `.blah[0]`.
1616
+
1617
+ For generality, if `subfield_text` is the empty string
1618
+ `value` is returned unchanged.
1619
+ '''
1620
+ if subfield_text == '':
1621
+ return value
1622
+ if subfield_text[0] in '.[':
1623
+ subfield_fmt = f'{{value{subfield_text}}}'
1624
+ subfield_map = {'value': value}
1625
+ with Pfx("%r.format_map(%r)", subfield_fmt, subfield_map):
1626
+ value = subfield_fmt.format_map(subfield_map)
1627
+ else:
1628
+ # use the subfield_text after the colon
1629
+ fmt = f'{{value:{subfield_text}}}'
1630
+ value = fmt.format(value=value)
1631
+ return value
1632
+
1633
+ # pylint: disable=arguments-differ,arguments-renamed
1634
+ @pfx_method
1635
+ def get_value(self, arg_name, args, kwargs):
1636
+ ''' Get the object with index `arg_name`.
1637
+
1638
+ This default implementation returns `(kwargs[arg_name],arg_name)`.
1639
+ '''
1640
+ assert not args
1641
+ return kwargs[arg_name], arg_name
1642
+
1643
+ @classmethod
1644
+ def get_format_subspecs(cls, format_spec):
1645
+ ''' Parse a `format_spec` as a sequence of colon separated components,
1646
+ return a list of the components.
1647
+ '''
1648
+ subspecs = []
1649
+ offset = 0
1650
+ while offset < len(format_spec):
1651
+ if format_spec.startswith(':', offset):
1652
+ subspec = ''
1653
+ offset += 1
1654
+ else:
1655
+ m_subspec = cls.FORMAT_RE_FIELD_EXPR.match(format_spec, offset)
1656
+ if m_subspec:
1657
+ subspec = m_subspec.group()
1658
+ else:
1659
+ warning(
1660
+ "unrecognised subspec at %d: %r, falling back to split", offset,
1661
+ format_spec[offset:]
1662
+ )
1663
+ subspec, *_ = format_spec[offset:].split(':', 1)
1664
+ offset += len(subspec)
1665
+ subspecs.append(subspec)
1666
+ return subspecs
1667
+
1668
+ @classmethod
1669
+ @pfx_method
1670
+ @typechecked
1671
+ def format_field(cls, value, format_spec: str):
1672
+ ''' Format a value using `value.format_format_field`,
1673
+ returning an `FStr`
1674
+ (a `str` subclass with additional `format_spec` features).
1675
+
1676
+ We actually recognise colon separated chains of formats
1677
+ and apply each format to the previously converted value.
1678
+ The final result is promoted to an `FStr` before return.
1679
+ '''
1680
+ # parse the format_spec into multiple subspecs
1681
+ format_subspecs = cls.get_format_subspecs(format_spec) or []
1682
+ while format_subspecs:
1683
+ format_subspec = format_subspecs.pop(0)
1684
+ with Pfx("subspec %r", format_subspec):
1685
+ assert isinstance(format_subspec, str)
1686
+ assert len(format_subspec) > 0
1687
+ with Pfx("value=%r, format_subspec=%r", value, format_subspec):
1688
+ # promote bare str to FStr
1689
+ if value is None or type(value) is str: # pylint: disable=unidiomatic-typecheck
1690
+ value = FStr(value)
1691
+ if format_subspec[0].isalpha():
1692
+ try:
1693
+ value.convert_via_method_or_attr
1694
+ except AttributeError:
1695
+ # promote to something with convert_via_method_or_attr
1696
+ if isinstance(value, str):
1697
+ value = FStr(value)
1698
+ else:
1699
+ value = pfx_call(format, value, format_subspec)
1700
+ value, offset = value.convert_via_method_or_attr(
1701
+ value, format_subspec
1702
+ )
1703
+ if offset < len(format_subspec):
1704
+ subspec_tail = format_subspec[offset:]
1705
+ value = cls.get_subfield(value, subspec_tail)
1706
+ else:
1707
+ value = format(value, format_subspec)
1708
+ return FStr(value)
1709
+
1710
+ @has_format_attributes
1711
+ class FormatableMixin(FormatableFormatter): # pylint: disable=too-few-public-methods
1712
+ ''' A subclass of `FormatableFormatter` which provides 2 features:
1713
+ - a `__format__` method which parses the `format_spec` string
1714
+ into multiple colon separated terms whose results chain
1715
+ - a `format_as` method which formats a format string using `str.format_map`
1716
+ with a suitable mapping derived from the instance
1717
+ via its `format_kwargs` method
1718
+ (whose default is to return the instance itself)
1719
+
1720
+ The `format_as` method is like an inside out `str.format` or
1721
+ `object.__format__` method.
1722
+
1723
+ The `str.format` method is designed for formatting a string
1724
+ from a variety of other objects supplied in the keyword arguments.
1725
+
1726
+ The `object.__format__` method is for filling out a single `str.format`
1727
+ replacement field from a single object.
1728
+
1729
+ By contrast, `format_as` is designed to fill out an entire format
1730
+ string from the current object.
1731
+
1732
+ For example, the `cs.tagset.TagSetMixin` class
1733
+ uses `FormatableMixin` to provide a `format_as` method
1734
+ whose replacement fields are derived from the tags in the tag set.
1735
+
1736
+ Subclasses wanting to provide additional `format_spec` terms
1737
+ should:
1738
+ - override `FormatableFormatter.format_field1` to implement
1739
+ terms with no colons, letting `format_field` do the split into terms
1740
+ - override `FormatableFormatter.get_format_subspecs` to implement
1741
+ the parse of `format_spec` into a sequence of terms.
1742
+ This might recognise a special additional syntax
1743
+ and quietly fall back to `super().get_format_subspecs`
1744
+ if that is not present.
1745
+ '''
1746
+
1747
+ FORMAT_JSON_ENCODER = JSONEncoder(separators=(',', ':'))
1748
+
1749
+ # pylint: disable=invalid-format-returned
1750
+ def __format__(self, format_spec):
1751
+ ''' Format `self` according to `format_spec`.
1752
+
1753
+ This implementation calls `self.format_field`.
1754
+ As such, a `format_spec` is considered
1755
+ a sequence of colon separated terms.
1756
+
1757
+ Classes wanting to implement additional format string syntaxes
1758
+ should either:
1759
+ - override `FormatableFormatter.format_field1` to implement
1760
+ terms with no colons, letting `format_field1` do the split into terms
1761
+ - override `FormatableFormatter.get_format_subspecs` to implement
1762
+ the term parse.
1763
+
1764
+ The default implementation of `__format1__` just calls `super().__format__`.
1765
+ Implementations providing specialised formats
1766
+ should implement them in `__format1__`
1767
+ with fallback to `super().__format1__`.
1768
+ '''
1769
+ return self.format_field(self, format_spec)
1770
+
1771
+ @classmethod
1772
+ def get_format_attributes(cls):
1773
+ ''' Return the mapping of format attributes.
1774
+ '''
1775
+ try:
1776
+ attributes = cls.__dict__['_format_attributes']
1777
+ except KeyError:
1778
+ cls._format_attributes = attributes = {}
1779
+ return attributes
1780
+
1781
+ def get_format_attribute(self, attr):
1782
+ ''' Return a mapping of permitted methods to functions of an instance.
1783
+ This is used to whitelist allowed `:`*name* method formats
1784
+ to prevent scenarios like little Bobby Tables calling `delete()`.
1785
+ '''
1786
+ # this shuffle is because cls.__dict__ is a proxy, not a dict
1787
+ cls = type(self)
1788
+ attributes = cls.get_format_attributes()
1789
+ if attr in attributes:
1790
+ return getattr(self, attr)
1791
+ raise AttributeError(
1792
+ "disallowed attribute %r: not in %s._format_attributes" %
1793
+ (attr, cls.__name__)
1794
+ )
1795
+
1796
+ ##@staticmethod
1797
+ def convert_field(self, value, conversion):
1798
+ ''' The default converter for fields calls `Formatter.convert_field`.
1799
+ '''
1800
+ if conversion == '':
1801
+ warning(
1802
+ "%s.convert_field(%s, conversion=%r): turned conversion into None",
1803
+ type(self).__name__, typed_str(value, use_repr=True), conversion
1804
+ )
1805
+ conversion = None
1806
+ return super().convert_field(value, conversion)
1807
+
1808
+ @pfx_method
1809
+ def convert_via_method_or_attr(self, value, format_spec):
1810
+ ''' Apply a method or attribute name based conversion to `value`
1811
+ where `format_spec` starts with a method name
1812
+ applicable to `value`.
1813
+ Return `(converted,offset)`
1814
+ being the converted value and the offset after the method name.
1815
+
1816
+ Note that if there is not a leading identifier on `format_spec`
1817
+ then `value` is returned unchanged with `offset=0`.
1818
+
1819
+ The methods/attributes are looked up in the mapping
1820
+ returned by `.format_attributes()` which represents allowed methods
1821
+ (broadly, one should not allow methods which modify any state).
1822
+
1823
+ If this returns a callable, it is called to obtain the converted value
1824
+ otherwise it is used as is.
1825
+
1826
+ As a final tweak,
1827
+ if `value.get_format_attribute()` raises an `AttributeError`
1828
+ (the attribute is not an allowed attribute)
1829
+ or calling the attribute raises a `TypeError`
1830
+ (the `value` isn't suitable)
1831
+ and the `value` is not an instance of `FStr`,
1832
+ convert it to an `FStr` and try again.
1833
+ This provides the common utility methods on other types.
1834
+
1835
+ The motivating example was a `PurePosixPath`,
1836
+ which does not JSON transcribe;
1837
+ this tweak supports both
1838
+ `posixpath:basename` via the pathlib stuff
1839
+ and `posixpath:json` via `FStr`
1840
+ even though a `PurePosixPath` does not subclass `FStr`.
1841
+ '''
1842
+ try:
1843
+ attr, offset = get_identifier(format_spec)
1844
+ if not attr:
1845
+ # no leading method/attribute name, return unchanged
1846
+ return value, 0
1847
+ try:
1848
+ attribute = value.get_format_attribute(attr)
1849
+ except AttributeError as e:
1850
+ raise TypeError(
1851
+ "convert_via_method_or_attr(%s,%r): %s" %
1852
+ (typed_repr(value), format_spec, e)
1853
+ ) from e
1854
+ if callable(attribute):
1855
+ converted = attribute()
1856
+ else:
1857
+ converted = attribute
1858
+ return converted, offset
1859
+ except TypeError:
1860
+ if not isinstance(value, FStr):
1861
+ with Pfx("fall back to FStr(value=%s).convert_via_method_or_attr"):
1862
+ return self.convert_via_method_or_attr(FStr(value), format_spec)
1863
+ raise
1864
+
1865
+ def format_as(self, format_s, error_sep=None, strict=None, **control_kw):
1866
+ ''' Return the string `format_s` formatted using the mapping
1867
+ returned by `self.format_kwargs(**control_kw)`.
1868
+
1869
+ If a class using the mixin has no `format_kwargs(**control_kw)` method
1870
+ to provide a mapping for `str.format_map`
1871
+ then the instance itself is used as the mapping.
1872
+ '''
1873
+ get_format_mapping = getattr(self, 'format_kwargs', None)
1874
+ if get_format_mapping is None:
1875
+ if control_kw:
1876
+ # pylint: disable=raise-missing-from
1877
+ raise ValueError(
1878
+ "no .format_kwargs() method, but control_kw=%r" % (control_kw,)
1879
+ )
1880
+ format_mapping = self
1881
+ else:
1882
+ format_mapping = get_format_mapping(**control_kw) # pylint:disable=not-callable
1883
+ if strict is None:
1884
+ strict = self.format_mode.strict
1885
+ with self.format_mode(strict=strict):
1886
+ return pfx_call(
1887
+ _format_as,
1888
+ format_s,
1889
+ format_mapping,
1890
+ formatter=self,
1891
+ error_sep=error_sep,
1892
+ )
1893
+
1894
+ # Utility methods for formats.
1895
+ @format_attribute
1896
+ def json(self):
1897
+ ''' The value transcribed as compact JSON.
1898
+ '''
1899
+ return self.FORMAT_JSON_ENCODER.encode(self)
1900
+
1901
+ @has_format_attributes
1902
+ class FStr(FormatableMixin, str):
1903
+ ''' A `str` subclass with the `FormatableMixin` methods,
1904
+ particularly its `__format__` method
1905
+ which uses `str` method names as valid formats.
1906
+
1907
+ It also has a bunch of utility methods which are available
1908
+ as `:`*method* in format strings.
1909
+ '''
1910
+
1911
+ # str is immutable: prefill with all public class attributes
1912
+ _format_attributes = {
1913
+ attr: getattr(str, attr)
1914
+ for attr in dir(str)
1915
+ if attr[0].isalpha()
1916
+ }
1917
+
1918
+ @format_attribute
1919
+ def basename(self):
1920
+ ''' Treat as a filesystem path and return the basename.
1921
+ '''
1922
+ return Path(self).name
1923
+
1924
+ @format_attribute
1925
+ def dirname(self):
1926
+ ''' Treat as a filesystem path and return the dirname.
1927
+ '''
1928
+ return Path(self).parent
1929
+
1930
+ @format_attribute
1931
+ def f(self):
1932
+ ''' Parse `self` as a `float`.
1933
+ '''
1934
+ return float(self)
1935
+
1936
+ @format_attribute
1937
+ def i(self, base=10):
1938
+ ''' Parse `self` as an `int`.
1939
+ '''
1940
+ return int(self, base=base)
1941
+
1942
+ @format_attribute
1943
+ def lc(self):
1944
+ ''' Lowercase using `lc_()`.
1945
+ '''
1946
+ return lc_(self)
1947
+
1948
+ @format_attribute
1949
+ def path(self):
1950
+ ''' Convert to a native filesystem `pathlib.Path`.
1951
+ '''
1952
+ return Path(self)
1953
+
1954
+ @format_attribute
1955
+ def posix_path(self):
1956
+ ''' Convert to a Posix filesystem `pathlib.Path`.
1957
+ '''
1958
+ return PurePosixPath(self)
1959
+
1960
+ @format_attribute
1961
+ def windows_path(self):
1962
+ ''' Convert to a Windows filesystem `pathlib.Path`.
1963
+ '''
1964
+ return PureWindowsPath(self)
1965
+
1966
+ class FNumericMixin(FormatableMixin):
1967
+ ''' A `FormatableMixin` subclass.
1968
+ '''
1969
+
1970
+ @format_attribute
1971
+ def utctime(self):
1972
+ ''' Treat this as a UNIX timestamp and return a UTC `datetime`.
1973
+ '''
1974
+ return unixtime2datetime(self, tz=UTC)
1975
+
1976
+ @format_attribute
1977
+ def localtime(self):
1978
+ ''' Treat this as a UNIX timestamp and return a localtime `datetime`.
1979
+ '''
1980
+ return unixtime2datetime(self, tz=tzlocal())
1981
+
1982
+ @has_format_attributes
1983
+ class FFloat(FNumericMixin, float):
1984
+ ''' Formattable `float`.
1985
+ '''
1986
+
1987
+ @has_format_attributes
1988
+ class FInt(FNumericMixin, int):
1989
+ ''' Formattable `int`.
1990
+ '''
1991
+
1992
+ if __name__ == '__main__':
1993
+ import cs.lex_tests
1994
+ cs.lex_tests.selftest(sys.argv)