Skip to content

Commit a09a786

Browse files
author
milde
committed
Doctree validation: new functions to validate Element attribute values.
Attribute validate functions: * convert string representations to correct data type, * normalize values, * raise ValueError for invalid attribute names or values. The `nodes.Element.validate()` function reports a warning for validity problems if `self.document.reporter` is available and raises a ValueError if not. Testing revealed problems with the "recommonmark_wrapper" parser: * Validating should be done *after* the "clean up" operations. * One test case uses an invalid class argument (underscore not allowed by Docutils). As this sample tests an "only Sphinx" feature, we just drop it from the Docutils test suite. git-svn-id: svn://svn.code.sf.net/p/docutils/code/trunk@9691 929543f6-e4f2-0310-98a6-ba3bd3dd1d04
1 parent c8a7e18 commit a09a786

6 files changed

Lines changed: 401 additions & 31 deletions

File tree

docutils/HISTORY.txt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,24 @@ Release 0.22b.dev (unpublished)
2727
- New `SubStructural` element category class.
2828
- Fix element categories.
2929
- New method `Element.validate()` (work in progress).
30+
- New "attribute validating functions"
31+
convert string representations to correct data type,
32+
normalize values,
33+
raise ValueError for invalid attribute names or values.
34+
35+
* docutils/parsers/recommonmark_wrapper.py
36+
37+
- New method `Parser.finish_parse()` to clean up (before validating).
3038

3139
* docutils/transforms/frontmatter.py
3240

33-
- Adapt `DocInfo` to fixed element categories.
41+
- Update `DocInfo` to work with corrected element categories.
3442

3543
* docutils/writers/manpage.py
3644

3745
- Remove code for unused emdash bullets.
3846

47+
3948
Release 0.21.2 (2024-04-23)
4049
===========================
4150

docutils/docs/ref/doctree.txt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2587,10 +2587,10 @@ bibliographic data, or meta-data (data about the document).
25872587

25882588
:Parents: Only the `\<document>`_ element contains <meta>.
25892589
:Children: The <meta> element has no content.
2590-
:Attributes: The <meta> element contains the attributes *name*,
2591-
*content*, *http-equiv*, *lang*, *dir*, *media*, and
2592-
*scheme* that correspond to the respective attributes
2593-
of the `HTML <meta> element`_.
2590+
:Attributes: The <meta> element contains the attributes
2591+
*content*, *dir*, *http-equiv*, *lang*, *media*, *name*, and
2592+
*scheme* that correspond to the respective attributes of the
2593+
`HTML <meta> element`_.
25942594

25952595
See also the `\<docinfo>`_ element for displayed meta-data.
25962596
The document's `title attribute`_ stores the metadata document title.
@@ -4630,7 +4630,7 @@ It is one of the `common attributes`_, declared for all Docutils
46304630
elements but typically only used on the `root element`_.
46314631

46324632
.. note:: All ``docutils.nodes.Node`` instances also support an
4633-
**internal** ``source`` attribute that is used when reporting
4633+
*internal* ``source`` attribute that is used when reporting
46344634
processing problems.
46354635

46364636

docutils/docutils/nodes.py

Lines changed: 257 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,8 @@ def starttag(self, quoteattr=None):
567567
if value is None: # boolean attribute
568568
parts.append('%s="True"' % name)
569569
continue
570+
if isinstance(value, bool):
571+
value = str(int(value))
570572
if isinstance(value, list):
571573
values = [serial_escape('%s' % (v,)) for v in value]
572574
value = ' '.join(values)
@@ -1093,22 +1095,48 @@ def is_not_known_attribute(cls, attr):
10931095
return attr not in cls.common_attributes
10941096

10951097
def validate_attributes(self):
1096-
# check for undeclared attributes
1097-
# TODO: check attribute values
1098+
"""Normalize and validate element attributes.
1099+
1100+
Convert string values to expected datatype.
1101+
Normalize values.
1102+
1103+
Raise `ValueError` for invalid attributes or attribute values.
1104+
1105+
Provisional.
1106+
"""
1107+
messages = []
10981108
for key, value in self.attributes.items():
10991109
if key.startswith('internal:'):
11001110
continue # see docs/user/config.html#expose-internals
11011111
if key not in self.valid_attributes:
1102-
raise ValueError(
1103-
f'Element <{self.tagname}> has invalid attribute "{key}".')
1112+
va = ' '.join(self.valid_attributes)
1113+
messages.append(f'Attribute "{key}" not one of "{va}".')
1114+
continue
1115+
try:
1116+
self.attributes[key] = ATTRIBUTE_VALIDATORS[key](value)
1117+
except (ValueError, TypeError, KeyError) as e:
1118+
messages.append(
1119+
f'Attribute "{key}" has invalid value "{value}".\n'
1120+
+ e.args[0]) # message argument
1121+
if messages:
1122+
raise ValueError('\n'.join(messages))
11041123

11051124
def validate(self):
1106-
# print(f'validating', self.tagname)
1107-
self.validate_attributes()
1125+
messages = []
1126+
try:
1127+
self.validate_attributes()
1128+
except ValueError as e:
1129+
messages.append(e.args[0]) # the message argument
11081130
# TODO: check number of children
11091131
for child in self.children:
11101132
# TODO: check whether child has allowed type
11111133
child.validate()
1134+
if messages:
1135+
msg = f'Element <{self.tagname}> invalid:\n' + '\n'.join(messages)
1136+
try:
1137+
self.document.reporter.warning(msg)
1138+
except AttributeError:
1139+
raise ValueError(msg)
11121140

11131141

11141142
# ========
@@ -2443,6 +2471,229 @@ def serial_escape(value):
24432471
return value.replace('\\', r'\\').replace(' ', r'\ ')
24442472

24452473

2474+
def split_name_list(s):
2475+
r"""Split a string at non-escaped whitespace.
2476+
2477+
Backslashes escape internal whitespace (cf. `serial_escape()`).
2478+
Return list of "names" (after removing escaping backslashes).
2479+
2480+
>>> split_name_list(r'a\ n\ame two\\ n\\ames'),
2481+
['a name', 'two\\', r'n\ames']
2482+
2483+
Provisional.
2484+
"""
2485+
s = s.replace('\\', '\x00') # escape with NULL char
2486+
s = s.replace('\x00\x00', '\\') # unescape backslashes
2487+
s = s.replace('\x00 ', '\x00\x00') # escaped spaces -> NULL NULL
2488+
names = s.split(' ')
2489+
# restore internal spaces, drop other escaping characters
2490+
return [name.replace('\x00\x00', ' ').replace('\x00', '')
2491+
for name in names]
2492+
2493+
24462494
def pseudo_quoteattr(value):
24472495
"""Quote attributes for pseudo-xml"""
24482496
return '"%s"' % value
2497+
2498+
2499+
# Methods to validate `Element attribute`__ values.
2500+
2501+
# Ensure the expected Python `data type`__, normalize, and check for
2502+
# restrictions.
2503+
#
2504+
# The methods can be used to convert `str` values (eg. from an XML
2505+
# representation) or to validate an existing document tree or node.
2506+
#
2507+
# Cf. `Element.validate_attributes()`, `docutils.parsers.docutils_xml`,
2508+
# and the `attribute_validating_functions` mapping below.
2509+
#
2510+
# __ https://docutils.sourceforge.io/docs/ref/doctree.html#attribute-reference
2511+
# __ https://docutils.sourceforge.io/docs/ref/doctree.html#attribute-types
2512+
2513+
def validate_enumerated_type(*keywords):
2514+
"""
2515+
Return a function that validates a `str` against given `keywords`.
2516+
2517+
Provisional.
2518+
"""
2519+
def validate_keywords(value):
2520+
if value not in keywords:
2521+
allowed = '", \"'.join(keywords)
2522+
raise ValueError(f'"{value}" is not one of "{allowed}".')
2523+
return value
2524+
return validate_keywords
2525+
2526+
2527+
def validate_identifier(value):
2528+
"""
2529+
Validate identifier key or class name.
2530+
2531+
Used in `idref.type`__ and for the tokens in `validate_identifier_list()`.
2532+
2533+
__ https://docutils.sourceforge.io/docs/ref/doctree.html#idref-type
2534+
2535+
Provisional.
2536+
"""
2537+
if value != make_id(value):
2538+
raise ValueError(f'"{value}" is no valid id or class name.')
2539+
return value
2540+
2541+
2542+
def validate_identifier_list(value):
2543+
"""
2544+
A (space-separated) list of ids or class names.
2545+
2546+
`value` may be a `list` or a `str` with space separated
2547+
ids or class names (cf. `validate_identifier()`).
2548+
2549+
Used in `classnames.type`__, `ids.type`__, and `idrefs.type`__.
2550+
2551+
__ https://docutils.sourceforge.io/docs/ref/doctree.html#classnames-type
2552+
__ https://docutils.sourceforge.io/docs/ref/doctree.html#ids-type
2553+
__ https://docutils.sourceforge.io/docs/ref/doctree.html#idrefs-type
2554+
2555+
Provisional.
2556+
"""
2557+
if isinstance(value, str):
2558+
value = value.split()
2559+
for token in value:
2560+
validate_identifier(token)
2561+
return value
2562+
2563+
2564+
def validate_measure(value):
2565+
"""
2566+
Validate a length measure__ (number + recognized unit).
2567+
2568+
__ https://docutils.sourceforge.io/docs/ref/doctree.html#measure
2569+
2570+
Provisional.
2571+
"""
2572+
units = 'em|ex|px|in|cm|mm|pt|pc|%'
2573+
if not re.fullmatch(f'[-0-9.]+ *({units}?)', value):
2574+
raise ValueError(f'"{value}" is no valid measure. '
2575+
f'Valid units: {units.replace("|", " ")}.')
2576+
return value.replace(' ', '').strip()
2577+
2578+
2579+
def validate_NMTOKEN(value):
2580+
"""
2581+
Validate a "name token": a `str` of letters, digits, and [-._].
2582+
2583+
Provisional.
2584+
"""
2585+
if not re.fullmatch('[-._A-Za-z0-9]+', value):
2586+
raise ValueError(f'"{value}" is no NMTOKEN.')
2587+
return value
2588+
2589+
2590+
def validate_NMTOKENS(value):
2591+
"""
2592+
Validate a list of "name tokens".
2593+
2594+
Provisional.
2595+
"""
2596+
if isinstance(value, str):
2597+
value = value.split()
2598+
for token in value:
2599+
validate_NMTOKEN(token)
2600+
return value
2601+
2602+
2603+
def validate_refname_list(value):
2604+
"""
2605+
Validate a list of `reference names`__.
2606+
2607+
Reference names may contain all characters;
2608+
whitespace is normalized (cf, `whitespace_normalize_name()`).
2609+
2610+
`value` may be either a `list` of names or a `str` with
2611+
space separated names (with internal spaces backslash escaped
2612+
and literal backslashes doubled cf. `serial_escape()`).
2613+
2614+
Return a list of whitespace-normalized, unescaped reference names.
2615+
2616+
Provisional.
2617+
2618+
__ https://docutils.sourceforge.io/docs/ref/doctree.html#reference-name
2619+
"""
2620+
if isinstance(value, str):
2621+
value = split_name_list(value)
2622+
return [whitespace_normalize_name(name) for name in value]
2623+
2624+
2625+
def validate_yesorno(value):
2626+
if value == "0":
2627+
return False
2628+
return bool(value)
2629+
2630+
2631+
ATTRIBUTE_VALIDATORS = {
2632+
'alt': str, # CDATA
2633+
'align': str,
2634+
'anonymous': validate_yesorno,
2635+
'auto': str, # CDATA (only '1' or '*' are used in rST)
2636+
'backrefs': validate_identifier_list,
2637+
'bullet': str, # CDATA (only '-', '+', or '*' are used in rST)
2638+
'classes': validate_identifier_list,
2639+
'char': str, # from Exchange Table Model (CALS), currently ignored
2640+
'charoff': validate_NMTOKEN, # from CALS, currently ignored
2641+
'colname': validate_NMTOKEN, # from CALS, currently ignored
2642+
'colnum': int, # from CALS, currently ignored
2643+
'cols': int, # from CALS: "NMTOKEN, […] must be an integer > 0".
2644+
'colsep': validate_yesorno,
2645+
'colwidth': int, # sic! CALS: CDATA (measure or number+'*')
2646+
'content': str, # <meta>
2647+
'delimiter': str,
2648+
'depth': int,
2649+
'dir': validate_enumerated_type('ltr', 'rtl', 'auto'), # <meta>
2650+
'dupnames': validate_refname_list,
2651+
'enumtype': validate_enumerated_type('arabic', 'loweralpha', 'lowerroman',
2652+
'upperalpha', 'upperroman'),
2653+
'format': str, # CDATA (space separated format names)
2654+
'frame': validate_enumerated_type('top', 'bottom', 'topbot', 'all',
2655+
'sides', 'none'), # from CALS, ignored
2656+
'height': validate_measure,
2657+
'http-equiv': str, # <meta>
2658+
'ids': validate_identifier_list,
2659+
'lang': str, # <meta>
2660+
'level': int,
2661+
'line': int,
2662+
'local': validate_yesorno,
2663+
'ltrim': validate_yesorno,
2664+
'loading': validate_enumerated_type('embed', 'link', 'lazy'),
2665+
'media': str, # <meta>
2666+
'morecols': int,
2667+
'morerows': int,
2668+
'name': whitespace_normalize_name, # in <reference> (deprecated)
2669+
# 'name': node_attributes.validate_NMTOKEN, # in <meta>
2670+
'names': validate_refname_list,
2671+
'namest': validate_NMTOKEN, # start of span, from CALS, currently ignored
2672+
'nameend': validate_NMTOKEN, # end of span, from CALS, currently ignored
2673+
'pgwide': validate_yesorno, # from CALS, currently ignored
2674+
'prefix': str,
2675+
'refid': validate_identifier,
2676+
'refname': whitespace_normalize_name,
2677+
'refuri': str,
2678+
'rowsep': validate_yesorno,
2679+
'rtrim': validate_yesorno,
2680+
'scale': int,
2681+
'scheme': str,
2682+
'source': str,
2683+
'start': int,
2684+
'stub': validate_yesorno,
2685+
'suffix': str,
2686+
'title': str,
2687+
'type': validate_NMTOKEN,
2688+
'uri': str,
2689+
'valign': validate_enumerated_type('top', 'middle', 'bottom'), # from CALS
2690+
'width': validate_measure,
2691+
'xml:space': validate_enumerated_type('default', 'preserve'),
2692+
}
2693+
"""
2694+
Mapping of `attribute names`__ to validating functions.
2695+
2696+
Provisional.
2697+
2698+
__ https://docutils.sourceforge.io/docs/ref/doctree.html#attribute-reference
2699+
"""

docutils/docutils/parsers/recommonmark_wrapper.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ def get_transforms(self):
7575
return Component.get_transforms(self) # + [AutoStructify]
7676

7777
def parse(self, inputstring, document):
78-
"""Use the upstream parser and clean up afterwards.
78+
"""Wrapper of upstream method.
79+
80+
Ensure "line-length-limt". Report errors with `document.reporter`.
7981
"""
8082
# check for exorbitantly long lines
8183
for i, line in enumerate(inputstring.split('\n')):
@@ -95,8 +97,13 @@ def parse(self, inputstring, document):
9597
'returned the error:\n%s'%err)
9698
document.append(error)
9799

98-
# Post-Processing
99-
# ---------------
100+
# Post-Processing
101+
# ---------------
102+
103+
def finish_parse(self):
104+
"""Finalize parse details. Call at end of `self.parse()`."""
105+
106+
document = self.document
100107

101108
# merge adjoining Text nodes:
102109
for node in document.findall(nodes.TextElement):
@@ -142,6 +149,8 @@ def parse(self, inputstring, document):
142149
reference['name'] = nodes.fully_normalize_name(
143150
reference.astext())
144151
node.parent.replace(node, reference)
152+
# now we are ready to call the upstream function:
153+
super().finish_parse()
145154

146155
def visit_document(self, node):
147156
"""Dummy function to prevent spurious warnings.

0 commit comments

Comments
 (0)