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 +1994 -0
- cs.lex-20240519.dist-info/METADATA +1062 -0
- cs.lex-20240519.dist-info/RECORD +5 -0
- cs.lex-20240519.dist-info/WHEEL +5 -0
- cs.lex-20240519.dist-info/top_level.txt +1 -0
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 `" "` to prevent word folding,
|
|
205
|
+
default `False`.
|
|
206
|
+
'''
|
|
207
|
+
s = s.replace("&", "&")
|
|
208
|
+
s = s.replace("<", "<")
|
|
209
|
+
s = s.replace(">", ">")
|
|
210
|
+
if nbsp:
|
|
211
|
+
s = s.replace(" ", " ")
|
|
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)
|