Skip to content

Commit 8122909

Browse files
committed
rfctr: rework enums so they type-check
Replace meta-programming with `enum.Enum` features built into Python 3 to support XML attribute mapping. Also some random typing and docstring clean-up along the way.
1 parent 5b3ee80 commit 8122909

File tree

13 files changed

+1172
-950
lines changed

13 files changed

+1172
-950
lines changed

src/docx/enum/base.py

Lines changed: 62 additions & 239 deletions
Original file line numberDiff line numberDiff line change
@@ -3,39 +3,81 @@
33
from __future__ import annotations
44

55
import enum
6-
import sys
76
import textwrap
8-
from typing import Callable, Type
7+
from typing import Any, Dict, Type, TypeVar
98

10-
from docx.exceptions import InvalidXmlError
9+
from typing_extensions import Self
1110

11+
_T = TypeVar("_T", bound="BaseXmlEnum")
1212

13-
def alias(*aliases: str) -> Callable[..., Type[enum.Enum]]:
14-
"""Adds alternate name for an enumeration.
1513

16-
Decorating a class with @alias('FOO', 'BAR', ..) allows the class to be referenced
17-
by each of the names provided as arguments.
14+
class BaseEnum(int, enum.Enum):
15+
"""Base class for Enums that do not map XML attr values.
16+
17+
The enum's value will be an integer, corresponding to the integer assigned the
18+
corresponding member in the MS API enum of the same name.
1819
"""
1920

20-
def decorator(cls):
21-
# alias must be set in globals from caller's frame
22-
caller = sys._getframe(1)
23-
globals_dict = caller.f_globals
24-
for alias in aliases:
25-
globals_dict[alias] = cls
26-
return cls
21+
def __new__(cls, ms_api_value: int, docstr: str):
22+
self = int.__new__(cls, ms_api_value)
23+
self._value_ = ms_api_value
24+
self.__doc__ = docstr.strip()
25+
return self
26+
27+
def __str__(self):
28+
"""The symbolic name and string value of this member, e.g. 'MIDDLE (3)'."""
29+
return f"{self.name} ({self.value})"
30+
31+
32+
class BaseXmlEnum(int, enum.Enum):
33+
"""Base class for Enums that also map XML attr values.
34+
35+
The enum's value will be an integer, corresponding to the integer assigned the
36+
corresponding member in the MS API enum of the same name.
37+
"""
38+
39+
xml_value: str
40+
41+
def __new__(cls, ms_api_value: int, xml_value: str, docstr: str):
42+
self = int.__new__(cls, ms_api_value)
43+
self._value_ = ms_api_value
44+
self.xml_value = xml_value
45+
self.__doc__ = docstr.strip()
46+
return self
47+
48+
def __str__(self):
49+
"""The symbolic name and string value of this member, e.g. 'MIDDLE (3)'."""
50+
return f"{self.name} ({self.value})"
51+
52+
@classmethod
53+
def from_xml(cls, xml_value: str | None) -> Self:
54+
"""Enumeration member corresponding to XML attribute value `xml_value`.
55+
56+
Example::
2757
28-
return decorator
58+
>>> WD_PARAGRAPH_ALIGNMENT.from_xml("center")
59+
WD_PARAGRAPH_ALIGNMENT.CENTER
2960
61+
"""
62+
member = next((member for member in cls if member.xml_value == xml_value), None)
63+
if member is None:
64+
raise ValueError(f"{cls.__name__} has no XML mapping for '{xml_value}'")
65+
return member
3066

31-
class _DocsPageFormatter(object):
67+
@classmethod
68+
def to_xml(cls: Type[_T], value: int | _T | None) -> str | None:
69+
"""XML value of this enum member, generally an XML attribute value."""
70+
return cls(value).xml_value
71+
72+
73+
class DocsPageFormatter:
3274
"""Generate an .rst doc page for an enumeration.
3375
3476
Formats a RestructuredText documention page (string) for the enumeration class parts
3577
passed to the constructor. An immutable one-shot service object.
3678
"""
3779

38-
def __init__(self, clsname, clsdict):
80+
def __init__(self, clsname: str, clsdict: Dict[str, Any]):
3981
self._clsname = clsname
4082
self._clsdict = clsdict
4183

@@ -67,10 +109,11 @@ def _intro_text(self):
67109

68110
return textwrap.dedent(cls_docstring).strip()
69111

70-
def _member_def(self, member):
112+
def _member_def(self, member: BaseEnum | BaseXmlEnum):
71113
"""Return an individual member definition formatted as an RST glossary entry,
72114
wrapped to fit within 78 columns."""
73-
member_docstring = textwrap.dedent(member.docstring).strip()
115+
assert member.__doc__ is not None
116+
member_docstring = textwrap.dedent(member.__doc__).strip()
74117
member_docstring = textwrap.fill(
75118
member_docstring,
76119
width=78,
@@ -100,223 +143,3 @@ def _page_title(self):
100143
double-backtics) and underlined with '=' characters."""
101144
title_underscore = "=" * (len(self._clsname) + 4)
102145
return "``%s``\n%s" % (self._clsname, title_underscore)
103-
104-
105-
class MetaEnumeration(type):
106-
"""The metaclass for Enumeration and its subclasses.
107-
108-
Adds a name for each named member and compiles state needed by the enumeration class
109-
to respond to other attribute gets
110-
"""
111-
112-
def __new__(meta, clsname, bases, clsdict):
113-
meta._add_enum_members(clsdict)
114-
meta._collect_valid_settings(clsdict)
115-
meta._generate_docs_page(clsname, clsdict)
116-
return type.__new__(meta, clsname, bases, clsdict)
117-
118-
@classmethod
119-
def _add_enum_members(meta, clsdict):
120-
"""Dispatch ``.add_to_enum()`` call to each member so it can do its thing to
121-
properly add itself to the enumeration class.
122-
123-
This delegation allows member sub-classes to add specialized behaviors.
124-
"""
125-
enum_members = clsdict["__members__"]
126-
for member in enum_members:
127-
member.add_to_enum(clsdict)
128-
129-
@classmethod
130-
def _collect_valid_settings(meta, clsdict):
131-
"""Return a sequence containing the enumeration values that are valid assignment
132-
values.
133-
134-
Return-only values are excluded.
135-
"""
136-
enum_members = clsdict["__members__"]
137-
valid_settings = []
138-
for member in enum_members:
139-
valid_settings.extend(member.valid_settings)
140-
clsdict["_valid_settings"] = valid_settings
141-
142-
@classmethod
143-
def _generate_docs_page(meta, clsname, clsdict):
144-
"""Return the RST documentation page for the enumeration."""
145-
clsdict["__docs_rst__"] = _DocsPageFormatter(clsname, clsdict).page_str
146-
147-
148-
class EnumerationBase(object):
149-
"""Base class for all enumerations, used directly for enumerations requiring only
150-
basic behavior.
151-
152-
It's __dict__ is used below in the Python 2+3 compatible metaclass definition.
153-
"""
154-
155-
__members__ = ()
156-
__ms_name__ = ""
157-
158-
@classmethod
159-
def validate(cls, value):
160-
"""Raise |ValueError| if `value` is not an assignable value."""
161-
if value not in cls._valid_settings:
162-
raise ValueError(
163-
"%s not a member of %s enumeration" % (value, cls.__name__)
164-
)
165-
166-
167-
Enumeration = MetaEnumeration("Enumeration", (object,), dict(EnumerationBase.__dict__))
168-
169-
170-
class XmlEnumeration(Enumeration):
171-
"""Provides ``to_xml()`` and ``from_xml()`` methods in addition to base enumeration
172-
features."""
173-
174-
__members__ = ()
175-
__ms_name__ = ""
176-
177-
@classmethod
178-
def from_xml(cls, xml_val):
179-
"""Return the enumeration member corresponding to the XML value `xml_val`."""
180-
if xml_val not in cls._xml_to_member:
181-
raise InvalidXmlError(
182-
"attribute value '%s' not valid for this type" % xml_val
183-
)
184-
return cls._xml_to_member[xml_val]
185-
186-
@classmethod
187-
def to_xml(cls, enum_val):
188-
"""Return the XML value of the enumeration value `enum_val`."""
189-
if enum_val not in cls._member_to_xml:
190-
raise ValueError(
191-
"value '%s' not in enumeration %s" % (enum_val, cls.__name__)
192-
)
193-
return cls._member_to_xml[enum_val]
194-
195-
196-
class EnumMember(object):
197-
"""Used in the enumeration class definition to define a member value and its
198-
mappings."""
199-
200-
def __init__(self, name, value, docstring):
201-
self._name = name
202-
if isinstance(value, int):
203-
value = EnumValue(name, value, docstring)
204-
self._value = value
205-
self._docstring = docstring
206-
207-
def add_to_enum(self, clsdict):
208-
"""Add a name to `clsdict` for this member."""
209-
self.register_name(clsdict)
210-
211-
@property
212-
def docstring(self):
213-
"""The description of this member."""
214-
return self._docstring
215-
216-
@property
217-
def name(self):
218-
"""The distinguishing name of this member within the enumeration class, e.g.
219-
'MIDDLE' for MSO_VERTICAL_ANCHOR.MIDDLE, if this is a named member.
220-
221-
Otherwise the primitive value such as |None|, |True| or |False|.
222-
"""
223-
return self._name
224-
225-
def register_name(self, clsdict):
226-
"""Add a member name to the class dict `clsdict` containing the value of this
227-
member object.
228-
229-
Where the name of this object is None, do nothing; this allows out-of-band
230-
values to be defined without adding a name to the class dict.
231-
"""
232-
if self.name is None:
233-
return
234-
clsdict[self.name] = self.value
235-
236-
@property
237-
def valid_settings(self):
238-
"""A sequence containing the values valid for assignment for this member.
239-
240-
May be zero, one, or more in number.
241-
"""
242-
return (self._value,)
243-
244-
@property
245-
def value(self):
246-
"""The enumeration value for this member, often an instance of EnumValue, but
247-
may be a primitive value such as |None|."""
248-
return self._value
249-
250-
251-
class EnumValue(int):
252-
"""A named enumeration value, providing __str__ and __doc__ string values for its
253-
symbolic name and description, respectively.
254-
255-
Subclasses int, so behaves as a regular int unless the strings are asked for.
256-
"""
257-
258-
def __new__(cls, member_name, int_value, docstring):
259-
return super(EnumValue, cls).__new__(cls, int_value)
260-
261-
def __init__(self, member_name, int_value, docstring):
262-
super(EnumValue, self).__init__()
263-
self._member_name = member_name
264-
self._docstring = docstring
265-
266-
@property
267-
def __doc__(self):
268-
"""The description of this enumeration member."""
269-
return self._docstring.strip()
270-
271-
def __str__(self):
272-
"""The symbolic name and string value of this member, e.g. 'MIDDLE (3)'."""
273-
return "%s (%d)" % (self._member_name, int(self))
274-
275-
276-
class ReturnValueOnlyEnumMember(EnumMember):
277-
"""Used to define a member of an enumeration that is only valid as a query result
278-
and is not valid as a setting, e.g. MSO_VERTICAL_ANCHOR.MIXED (-2)"""
279-
280-
@property
281-
def valid_settings(self):
282-
"""No settings are valid for a return-only value."""
283-
return ()
284-
285-
286-
class XmlMappedEnumMember(EnumMember):
287-
"""Used to define a member whose value maps to an XML attribute value."""
288-
289-
def __init__(self, name, value, xml_value, docstring):
290-
super(XmlMappedEnumMember, self).__init__(name, value, docstring)
291-
self._xml_value = xml_value
292-
293-
def add_to_enum(self, clsdict):
294-
"""Compile XML mappings in addition to base add behavior."""
295-
super(XmlMappedEnumMember, self).add_to_enum(clsdict)
296-
self.register_xml_mapping(clsdict)
297-
298-
def register_xml_mapping(self, clsdict):
299-
"""Add XML mappings to the enumeration class state for this member."""
300-
member_to_xml = self._get_or_add_member_to_xml(clsdict)
301-
member_to_xml[self.value] = self.xml_value
302-
xml_to_member = self._get_or_add_xml_to_member(clsdict)
303-
xml_to_member[self.xml_value] = self.value
304-
305-
@property
306-
def xml_value(self):
307-
"""The XML attribute value that corresponds to this enumeration value."""
308-
return self._xml_value
309-
310-
@staticmethod
311-
def _get_or_add_member_to_xml(clsdict):
312-
"""Add the enum -> xml value mapping to the enumeration class state."""
313-
if "_member_to_xml" not in clsdict:
314-
clsdict["_member_to_xml"] = {}
315-
return clsdict["_member_to_xml"]
316-
317-
@staticmethod
318-
def _get_or_add_xml_to_member(clsdict):
319-
"""Add the xml -> enum value mapping to the enumeration class state."""
320-
if "_xml_to_member" not in clsdict:
321-
clsdict["_xml_to_member"] = {}
322-
return clsdict["_xml_to_member"]

0 commit comments

Comments
 (0)