forked from brechtm/rinohtype
-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathattribute.py
554 lines (444 loc) · 18.2 KB
/
attribute.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
# This file is part of rinohtype, the Python document preparation system.
#
# Copyright (c) Brecht Machiels.
#
# Use of this source code is subject to the terms of the GNU Affero General
# Public License v3. See the LICENSE file or http://www.gnu.org/licenses/.
import re
from collections import OrderedDict
from configparser import ConfigParser
from io import StringIO
from itertools import chain
from pathlib import Path
from token import NUMBER, ENDMARKER, MINUS, PLUS, NAME, NEWLINE
from tokenize import generate_tokens
from warnings import warn
from .util import (NamedDescriptor, WithNamedDescriptors,
NotImplementedAttribute, class_property, PeekIterator,
cached)
__all__ = ['AttributeType', 'AcceptNoneAttributeType', 'OptionSet', 'Attribute',
'OverrideDefault', 'AttributesDictionary', 'Configurable',
'RuleSet', 'RuleSetFile', 'Bool', 'Integer', 'ParseError', 'Var']
class AttributeType(object):
def __eq__(self, other):
return type(self) == type(other) and self.__dict__ == other.__dict__
def __ne__(self, other):
return not self == other
@classmethod
def check_type(cls, value):
return isinstance(value, cls)
@classmethod
def from_string(cls, string):
return cls.parse_string(string)
@classmethod
def parse_string(cls, string):
tokens = TokenIterator(string)
result = cls.from_tokens(tokens)
if next(tokens).type != ENDMARKER:
raise ParseError('Syntax error')
return result
@classmethod
def from_tokens(cls, tokens):
raise NotImplementedError(cls)
RE_VARIABLE = re.compile(r'^\$\(([a-z_ -]+)\)$', re.IGNORECASE)
@classmethod
def validate(cls, value, accept_variables=False, attribute_name=None):
if isinstance(value, str):
stripped = value.replace('\n', ' ').strip()
m = cls.RE_VARIABLE.match(stripped)
if m:
value = Var(m.group(1))
else:
value = cls.from_string(stripped)
if isinstance(value, Var):
if not accept_variables:
raise TypeError("The '{}' attribute does not accept variables"
.format(attribute_name))
elif not cls.check_type(value):
raise TypeError("{} ({}) is not of the correct type for the '{}' "
"attribute".format(value, type(value).__name__,
attribute_name))
return value
@classmethod
def doc_repr(cls, value):
return '``{}``'.format(value) if value else '(no value)'
@classmethod
def doc_format(cls):
warn('Missing implementation for {}.doc_format'.format(cls.__name__))
return ''
class AcceptNoneAttributeType(AttributeType):
"""Accepts 'none' (besides other values)"""
@classmethod
def check_type(cls, value):
return (isinstance(value, type(None))
or super(__class__, cls).check_type(value))
@classmethod
def from_string(cls, string):
if string.strip().lower() == 'none':
return None
return super(__class__, cls).from_string(string)
@classmethod
def doc_repr(cls, value):
return '``{}``'.format('none' if value is None else value)
class OptionSetMeta(type):
def __new__(metacls, classname, bases, cls_dict):
cls = super().__new__(metacls, classname, bases, cls_dict)
cls.__doc__ = (cls_dict['__doc__'] + '\n\n'
if '__doc__' in cls_dict else '')
cls.__doc__ += 'Accepts: {}'.format(cls.doc_format())
return cls
def __getattr__(cls, item):
if item == 'NONE' and None in cls.values:
return None
string = item.lower().replace('_', ' ')
if item.isupper() and string in cls.values:
return string
raise AttributeError(item)
def __iter__(cls):
return iter(cls.values)
class OptionSet(AttributeType, metaclass=OptionSetMeta):
"""Accepts the values listed in :attr:`values`"""
values = ()
@classmethod
def check_type(cls, value):
return value in cls.values
@class_property
def value_strings(cls):
return ['none' if value is None else value.lower()
for value in cls.values]
@classmethod
def from_tokens(cls, tokens):
if tokens.next.type != NAME:
raise ParseError('Expecting a name')
token = next(tokens)
_, start_col = token.start
while tokens.next and tokens.next.type == NAME:
token = next(tokens)
_, end_col = token.end
option_string = token.line[start_col:end_col].strip()
try:
index = cls.value_strings.index(option_string.lower())
except ValueError:
raise ValueError("'{}' is not a valid {}. Must be one of: '{}'"
.format(option_string, cls.__name__,
"', '".join(cls.value_strings)))
return cls.values[index]
@classmethod
def doc_repr(cls, value):
return '``{}``'.format(value)
@classmethod
def doc_format(cls):
return ', '.join('``{}``'.format(s) for s in cls.value_strings)
class Attribute(NamedDescriptor):
"""Descriptor used to describe a style attribute"""
def __init__(self, accepted_type, default_value, description):
self.name = None
self.accepted_type = accepted_type
self.default_value = accepted_type.validate(default_value)
self.description = description
def __get__(self, style, type=None):
try:
return style.get(self.name, self.default_value)
except AttributeError:
return self
def __set__(self, style, value):
if not self.accepted_type.check_type(value):
raise TypeError('The {} attribute only accepts {} instances'
.format(self.name, self.accepted_type.__name__))
style[self.name] = value
class OverrideDefault(Attribute):
"""Overrides the default value of an attribute defined in a superclass"""
def __init__(self, default_value):
self._default_value = default_value
@property
def overrides(self):
return self._overrides
@overrides.setter
def overrides(self, attribute):
self._overrides = attribute
self.default_value = self.accepted_type.validate(self._default_value)
@property
def accepted_type(self):
return self.overrides.accepted_type
@property
def description(self):
return self.overrides.description
class WithAttributes(WithNamedDescriptors):
def __new__(mcls, classname, bases, cls_dict):
attributes = cls_dict['_attributes'] = OrderedDict()
doc = []
for name, attr in cls_dict.items():
if not isinstance(attr, Attribute):
continue
attributes[name] = attr
if isinstance(attr, OverrideDefault):
for mro_cls in (cls for base_cls in bases
for cls in base_cls.__mro__):
try:
attr.overrides = mro_cls._attributes[name]
break
except KeyError:
pass
else:
raise NotImplementedError
doc.append('{0}: Overrides the default '
'set in :attr:`{1} <.{1}.{0}>`'
.format(name, mro_cls.__name__))
else:
doc.append('{}: {}'.format(name, attr.description))
format = attr.accepted_type.doc_format()
default = attr.accepted_type.doc_repr(attr.default_value)
doc.append('\n *Accepts* :class:`.{}`: {}\n'
.format(attr.accepted_type.__name__, format))
doc.append('\n *Default*: {}\n'.format(default))
supported_attributes = list(name for name in attributes)
documented = set(supported_attributes)
for base_class in bases:
try:
supported_attributes.extend(base_class._supported_attributes)
except AttributeError:
continue
for mro_cls in base_class.__mro__:
for name, attr in getattr(mro_cls, '_attributes', {}).items():
if name in documented:
continue
doc.append('{0}: {1} (inherited from :attr:`{2} <.{2}.{0}>`)'
.format(name, attr.description,
mro_cls.__name__))
format = attr.accepted_type.doc_format()
default = attr.accepted_type.doc_repr(attr.default_value)
doc.append('\n *Accepts* :class:`.{}`: {}\n'
.format(attr.accepted_type.__name__, format))
doc.append('\n *Default*: {}\n'.format(default))
documented.add(name)
if doc:
attr_doc = '\n '.join(chain([' Attributes:'], doc))
cls_dict['__doc__'] = (cls_dict.get('__doc__', '') + '\n\n'
+ attr_doc)
cls_dict['_supported_attributes'] = supported_attributes
return super().__new__(mcls, classname, bases, cls_dict)
@property
def _all_attributes(cls):
for mro_class in reversed(cls.__mro__):
for name in getattr(mro_class, '_attributes', ()):
yield name
@property
def supported_attributes(cls):
for mro_class in cls.__mro__:
for name in getattr(mro_class, '_supported_attributes', ()):
yield name
class AttributesDictionary(OrderedDict, metaclass=WithAttributes):
def __init__(self, base=None, **attributes):
self.base = base
for name, value in attributes.items():
attributes[name] = self.validate_attribute(name, value, True)
super().__init__(attributes)
@classmethod
def validate_attribute(cls, name, value, accept_variables):
try:
attribute_type = cls.attribute_definition(name).accepted_type
except KeyError:
raise TypeError('{} is not a supported attribute for {}'
.format(name, cls.__name__))
return attribute_type.validate(value, accept_variables, name)
@classmethod
def _get_default(cls, attribute):
"""Return the default value for `attribute`.
If no default is specified in this style, get the default from the
nearest superclass.
If `attribute` is not supported, raise a :class:`KeyError`."""
try:
for klass in cls.__mro__:
if attribute in klass._attributes:
return klass._attributes[attribute].default_value
except AttributeError:
raise KeyError("No attribute '{}' in {}".format(attribute, cls))
@classmethod
def attribute_definition(cls, name):
try:
for klass in cls.__mro__:
if name in klass._attributes:
return klass._attributes[name]
except AttributeError:
pass
raise KeyError(name)
@classmethod
def get_ruleset(self):
raise NotImplementedError
class DefaultValueException(Exception):
pass
class Configurable(object):
configuration_class = NotImplementedAttribute()
def configuration_name(self, document):
raise NotImplementedError
def get_config_value(self, attribute, document):
ruleset = self.configuration_class.get_ruleset(document)
return ruleset.get_value_for(self, attribute, document)
class BaseConfigurationException(Exception):
def __init__(self, base_name):
self.name = base_name
class RuleSet(OrderedDict):
main_section = NotImplementedAttribute()
def __init__(self, name, base=None, **kwargs):
super().__init__(**kwargs)
self.name = name
self.base = base
self.variables = OrderedDict()
def contains(self, name):
return name in self or (self.base and self.base.contains(name))
def get_configuration(self, name):
try:
return self[name]
except KeyError:
if self.base:
return self.base.get_configuration(name)
raise
def __setitem__(self, name, style):
assert name not in self
if isinstance(style, AttributesDictionary):
style.name = name
super().__setitem__(name, style)
def __call__(self, name, **kwargs):
self[name] = self.get_entry_class(name)(**kwargs)
def __repr__(self):
return '{}({})'.format(type(self).__name__, self.name)
def __str__(self):
return repr(self)
def __bool__(self):
return True
def get_variable(self, variable):
try:
return self.variables[variable.name]
except KeyError:
if self.base:
return self.base.get_variable(variable)
raise VariableNotDefined("Variable '{}' is not defined"
.format(variable.name))
def get_entry_class(self, name):
raise NotImplementedError
def _get_value_recursive(self, name, attribute):
if name in self:
entry = self[name]
if attribute in entry:
return entry[attribute]
elif isinstance(entry.base, str):
raise BaseConfigurationException(entry.base)
elif entry.base is not None:
return entry.base[attribute]
if self.base:
return self.base._get_value_recursive(name, attribute)
raise DefaultValueException
@cached
def get_value(self, name, attribute):
try:
return self._get_value_recursive(name, attribute)
except BaseConfigurationException as exc:
return self.get_value(exc.name, attribute)
def _get_value_lookup(self, configurable, attribute, document):
name = configurable.configuration_name(document)
return self.get_value(name, attribute)
def get_value_for(self, configurable, attribute, document):
try:
value = self._get_value_lookup(configurable, attribute, document)
except DefaultValueException:
value = configurable.configuration_class._get_default(attribute)
if isinstance(value, Var):
config = configurable.configuration_class
value = config.validate_attribute(attribute,
self.get_variable(value), False)
return value
class RuleSetFile(RuleSet):
def __init__(self, filename, base=None, **kwargs):
self.filename = Path(filename)
config = ConfigParser(default_section=None, delimiters=('=',),
interpolation=None)
with self.filename.open() as file:
config.read_file(file)
options = dict(config[self.main_section]
if config.has_section(self.main_section) else {})
name = options.pop('name', filename)
base = options.pop('base', base)
options.update(kwargs) # optionally override options
super().__init__(name, base=base, **options)
if config.has_section('VARIABLES'):
for name, value in config.items('VARIABLES'):
self.variables[name] = value
for section_name, section_body in config.items():
if section_name in (None, self.main_section, 'VARIABLES'):
continue
if ':' in section_name:
name, classifier = (s.strip() for s in section_name.split(':'))
else:
name, classifier = section_name.strip(), None
self.process_section(name, classifier, section_body.items())
def process_section(self, section_name, classifier, items):
raise NotImplementedError
class Bool(AttributeType):
"""Expresses a binary choice"""
@classmethod
def check_type(cls, value):
return isinstance(value, bool)
@classmethod
def from_tokens(cls, tokens):
string = next(tokens).string
lower_string = string.lower()
if lower_string not in ('true', 'false'):
raise ValueError("'{}' is not a valid {}. Must be one of 'true' "
"or 'false'".format(string, cls.__name__))
return lower_string == 'true'
@classmethod
def doc_repr(cls, value):
return '``{}``'.format(str(value).lower())
@classmethod
def doc_format(cls):
return '``true`` or ``false``'
class Integer(AttributeType):
"""Accepts natural numbers"""
@classmethod
def check_type(cls, value):
return isinstance(value, int)
@classmethod
def from_tokens(cls, tokens):
token = next(tokens)
sign = 1
if token.exact_type in (MINUS, PLUS):
sign = 1 if token.exact_type == PLUS else -1
token = next(tokens)
if token.type != NUMBER:
raise ParseError('Expecting a number')
try:
value = int(token.string)
except ValueError:
raise ParseError('Expecting an integer')
return sign * value
@classmethod
def doc_format(cls):
return 'a natural number (positive integer)'
class TokenIterator(PeekIterator):
"""Tokenizes `string` and iterates over the tokens"""
def __init__(self, string):
self.string = string
tokens = generate_tokens(StringIO(string).readline)
super().__init__(tokens)
def _advance(self):
result = super()._advance()
if self.next and self.next.type == NEWLINE and self.next.string == '':
super()._advance()
return result
class ParseError(Exception):
pass
# variables
class Var(object):
def __init__(self, name):
super().__init__()
self.name = name
def __repr__(self):
return "{}('{}')".format(type(self).__name__, self.name)
def __str__(self):
return '$({})'.format(self.name)
def __eq__(self, other):
return self.name == other.name
def get(self, accepted_type, rule_set):
return rule_set.get_variable(self.name, accepted_type)
class VariableNotDefined(Exception):
pass