Commit da296442 by Calen Pennington

Extract a pure-XBlock version of XmlDescriptor

parent 62a90db1
...@@ -13,12 +13,16 @@ from xmodule.modulestore import EdxJSONEncoder ...@@ -13,12 +13,16 @@ from xmodule.modulestore import EdxJSONEncoder
import dogstats_wrapper as dog_stats_api import dogstats_wrapper as dog_stats_api
from lxml.etree import ( # pylint: disable=no-name-in-module
Element, ElementTree, XMLParser,
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# assume all XML files are persisted as utf-8. # assume all XML files are persisted as utf-8.
edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False, EDX_XML_PARSER = XMLParser(dtd_validation=False, load_dtd=False,
remove_comments=True, remove_blank_text=True, remove_comments=True, remove_blank_text=True,
encoding='utf-8') encoding='utf-8')
def name_to_pathname(name): def name_to_pathname(name):
...@@ -53,16 +57,6 @@ def is_pointer_tag(xml_obj): ...@@ -53,16 +57,6 @@ def is_pointer_tag(xml_obj):
return len(xml_obj) == 0 and actual_attr == expected_attr and not has_text return len(xml_obj) == 0 and actual_attr == expected_attr and not has_text
def get_metadata_from_xml(xml_object, remove=True):
meta = xml_object.find('meta')
if meta is None:
return ''
dmdata = meta.text
if remove:
xml_object.remove(meta)
return dmdata
def serialize_field(value): def serialize_field(value):
""" """
Return a string version of the value (where value is the JSON-formatted, internally stored value). Return a string version of the value (where value is the JSON-formatted, internally stored value).
...@@ -108,16 +102,30 @@ def deserialize_field(field, value): ...@@ -108,16 +102,30 @@ def deserialize_field(field, value):
return value return value
class XmlDescriptor(XModuleDescriptor): class XmlParserMixin(object):
""" """
Mixin class for standardized parsing of from xml Class containing XML parsing functionality shared between XBlock and XModuleDescriptor.
""" """
# Extension to append to filename paths
filename_extension = 'xml'
xml_attributes = Dict(help="Map of unhandled xml attributes, used only for storage between import and export", xml_attributes = Dict(help="Map of unhandled xml attributes, used only for storage between import and export",
default={}, scope=Scope.settings) default={}, scope=Scope.settings)
# Extension to append to filename paths # VS[compat]. Backwards compatibility code that can go away after
filename_extension = 'xml' # importing 2012 courses.
# A set of metadata key conversions that we want to make
metadata_translations = {
'slug': 'url_name',
'name': 'display_name',
}
@classmethod
def _translate(cls, key):
"""
VS[compat]
"""
return cls.metadata_translations.get(key, key)
# The attributes will be removed from the definition xml passed # The attributes will be removed from the definition xml passed
# to definition_from_xml, and from the xml returned by definition_to_xml # to definition_from_xml, and from the xml returned by definition_to_xml
...@@ -135,6 +143,19 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -135,6 +143,19 @@ class XmlDescriptor(XModuleDescriptor):
metadata_to_export_to_policy = ('discussion_topics', 'checklists') metadata_to_export_to_policy = ('discussion_topics', 'checklists')
@staticmethod
def _get_metadata_from_xml(xml_object, remove=True):
"""
Extract the metadata from the XML.
"""
meta = xml_object.find('meta')
if meta is None:
return ''
dmdata = meta.text
if remove:
xml_object.remove(meta)
return dmdata
@classmethod @classmethod
def definition_from_xml(cls, xml_object, system): def definition_from_xml(cls, xml_object, system):
""" """
...@@ -163,16 +184,16 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -163,16 +184,16 @@ class XmlDescriptor(XModuleDescriptor):
Returns an lxml Element Returns an lxml Element
""" """
return etree.parse(file_object, parser=edx_xml_parser).getroot() return etree.parse(file_object, parser=EDX_XML_PARSER).getroot() # pylint: disable=no-member
@classmethod @classmethod
def load_file(cls, filepath, fs, def_id): # pylint: disable=invalid-name def load_file(cls, filepath, fs, def_id): # pylint: disable=invalid-name
''' """
Open the specified file in fs, and call cls.file_to_xml on it, Open the specified file in fs, and call cls.file_to_xml on it,
returning the lxml object. returning the lxml object.
Add details and reraise on error. Add details and reraise on error.
''' """
try: try:
with fs.open(filepath) as xml_file: with fs.open(filepath) as xml_file:
return cls.file_to_xml(xml_file) return cls.file_to_xml(xml_file)
...@@ -184,7 +205,7 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -184,7 +205,7 @@ class XmlDescriptor(XModuleDescriptor):
@classmethod @classmethod
def load_definition(cls, xml_object, system, def_id, id_generator): def load_definition(cls, xml_object, system, def_id, id_generator):
''' """
Load a descriptor definition from the specified xml_object. Load a descriptor definition from the specified xml_object.
Subclasses should not need to override this except in special Subclasses should not need to override this except in special
cases (e.g. html module) cases (e.g. html module)
...@@ -194,7 +215,7 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -194,7 +215,7 @@ class XmlDescriptor(XModuleDescriptor):
system: the modulestore system (aka, runtime) which accesses data and provides access to services system: the modulestore system (aka, runtime) which accesses data and provides access to services
def_id: the definition id for the block--used to compute the usage id and asides ids def_id: the definition id for the block--used to compute the usage id and asides ids
id_generator: used to generate the usage_id id_generator: used to generate the usage_id
''' """
# VS[compat] -- the filename attr should go away once everything is # VS[compat] -- the filename attr should go away once everything is
# converted. (note: make sure html files still work once this goes away) # converted. (note: make sure html files still work once this goes away)
...@@ -234,7 +255,7 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -234,7 +255,7 @@ class XmlDescriptor(XModuleDescriptor):
# Add the attributes from the pointer node # Add the attributes from the pointer node
definition_xml.attrib.update(xml_object.attrib) definition_xml.attrib.update(xml_object.attrib)
definition_metadata = get_metadata_from_xml(definition_xml) definition_metadata = cls._get_metadata_from_xml(definition_xml)
cls.clean_metadata_from_xml(definition_xml) cls.clean_metadata_from_xml(definition_xml)
definition, children = cls.definition_from_xml(definition_xml, system) definition, children = cls.definition_from_xml(definition_xml, system)
if definition_metadata: if definition_metadata:
...@@ -289,42 +310,51 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -289,42 +310,51 @@ class XmlDescriptor(XModuleDescriptor):
metadata[attr] = value metadata[attr] = value
@classmethod @classmethod
def from_xml(cls, xml_data, system, id_generator): def parse_xml(cls, node, runtime, keys, id_generator): # pylint: disable=unused-argument
""" """
Creates an instance of this descriptor from the supplied xml_data. Use `node` to construct a new block.
This may be overridden by subclasses
xml_data: A string of xml that will be translated into data and children for Arguments:
this module node (etree.Element): The xml node to parse into an xblock.
system: A DescriptorSystem for interacting with external resources
""" runtime (:class:`.Runtime`): The runtime to use while parsing.
keys (:class:`.ScopeIds`): The keys identifying where this block
will store its data.
id_generator (:class:`.IdGenerator`): An object that will allow the
runtime to generate correct definition and usage ids for
children of this block.
Returns (XBlock): The newly parsed XBlock
xml_object = etree.fromstring(xml_data) """
# VS[compat] -- just have the url_name lookup, once translation is done # VS[compat] -- just have the url_name lookup, once translation is done
url_name = xml_object.get('url_name', xml_object.get('slug')) url_name = node.get('url_name', node.get('slug'))
def_id = id_generator.create_definition(xml_object.tag, url_name) def_id = id_generator.create_definition(node.tag, url_name)
usage_id = id_generator.create_usage(def_id) usage_id = id_generator.create_usage(def_id)
# VS[compat] -- detect new-style each-in-a-file mode # VS[compat] -- detect new-style each-in-a-file mode
if is_pointer_tag(xml_object): if is_pointer_tag(node):
# new style: # new style:
# read the actual definition file--named using url_name.replace(':','/') # read the actual definition file--named using url_name.replace(':','/')
filepath = cls._format_filepath(xml_object.tag, name_to_pathname(url_name)) filepath = cls._format_filepath(node.tag, name_to_pathname(url_name))
definition_xml = cls.load_file(filepath, system.resources_fs, def_id) definition_xml = cls.load_file(filepath, runtime.resources_fs, def_id)
system.parse_asides(definition_xml, def_id, usage_id, id_generator) runtime.parse_asides(definition_xml, def_id, usage_id, id_generator)
else: else:
filepath = None filepath = None
definition_xml = xml_object definition_xml = node
dog_stats_api.increment( dog_stats_api.increment(
DEPRECATION_VSCOMPAT_EVENT, DEPRECATION_VSCOMPAT_EVENT,
tags=["location:xmlparser_util_mixin_parse_xml"] tags=["location:xmlparser_util_mixin_parse_xml"]
) )
definition, children = cls.load_definition(definition_xml, system, def_id, id_generator) # note this removes metadata # Note: removes metadata.
definition, children = cls.load_definition(definition_xml, runtime, def_id, id_generator)
# VS[compat] -- make Ike's github preview links work in both old and # VS[compat] -- make Ike's github preview links work in both old and
# new file layouts # new file layouts
if is_pointer_tag(xml_object): if is_pointer_tag(node):
# new style -- contents actually at filepath # new style -- contents actually at filepath
definition['filename'] = [filepath, filepath] definition['filename'] = [filepath, filepath]
...@@ -341,7 +371,7 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -341,7 +371,7 @@ class XmlDescriptor(XModuleDescriptor):
metadata['definition_metadata_err'] = str(err) metadata['definition_metadata_err'] = str(err)
# Set/override any metadata specified by policy # Set/override any metadata specified by policy
cls.apply_policy(metadata, system.get_policy(usage_id)) cls.apply_policy(metadata, runtime.get_policy(usage_id))
field_data = {} field_data = {}
field_data.update(metadata) field_data.update(metadata)
...@@ -352,10 +382,10 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -352,10 +382,10 @@ class XmlDescriptor(XModuleDescriptor):
kvs = InheritanceKeyValueStore(initial_values=field_data) kvs = InheritanceKeyValueStore(initial_values=field_data)
field_data = KvsFieldData(kvs) field_data = KvsFieldData(kvs)
return system.construct_xblock_from_class( return runtime.construct_xblock_from_class(
cls, cls,
# We're loading a descriptor, so student_id is meaningless # We're loading a descriptor, so student_id is meaningless
ScopeIds(None, xml_object.tag, def_id, usage_id), ScopeIds(None, node.tag, def_id, usage_id),
field_data, field_data,
) )
...@@ -374,32 +404,17 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -374,32 +404,17 @@ class XmlDescriptor(XModuleDescriptor):
""" """
return True return True
def export_to_xml(self, resource_fs): def add_xml_to_node(self, node):
""" """
Returns an xml string representing this module, and all modules For exporting, set data on `node` from ourselves.
underneath it. May also write required resources out to resource_fs
Assumes that modules have single parentage (that no module appears twice
in the same course), and that it is thus safe to nest modules as xml
children as appropriate.
The returned XML should be able to be parsed back into an identical
XModuleDescriptor using the from_xml method with the same system, org,
and course
resource_fs is a pyfilesystem object (from the fs package)
""" """
# Set up runtime.export_fs so that it's available through future
# uses of the pure xblock add_xml_to_node api
self.runtime.export_fs = resource_fs
# Get the definition # Get the definition
xml_object = self.definition_to_xml(resource_fs) xml_object = self.definition_to_xml(self.runtime.export_fs)
self.clean_metadata_from_xml(xml_object) self.clean_metadata_from_xml(xml_object)
# Set the tag so we get the file path right # Set the tag on both nodes so we get the file path right.
xml_object.tag = self.category xml_object.tag = self.category
node.tag = self.category
# Add the non-inherited metadata # Add the non-inherited metadata
for attr in sorted(own_metadata(self)): for attr in sorted(own_metadata(self)):
...@@ -422,24 +437,25 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -422,24 +437,25 @@ class XmlDescriptor(XModuleDescriptor):
# Write the definition to a file # Write the definition to a file
url_path = name_to_pathname(self.url_name) url_path = name_to_pathname(self.url_name)
filepath = self._format_filepath(self.category, url_path) filepath = self._format_filepath(self.category, url_path)
resource_fs.makedir(os.path.dirname(filepath), recursive=True, allow_recreate=True) self.runtime.export_fs.makedir(os.path.dirname(filepath), recursive=True, allow_recreate=True)
with resource_fs.open(filepath, 'w') as fileobj: with self.runtime.export_fs.open(filepath, 'w') as fileobj:
fileobj.write(etree.tostring(xml_object, pretty_print=True, encoding='utf-8')) ElementTree(xml_object).write(fileobj, pretty_print=True, encoding='utf-8')
# And return just a pointer with the category and filename.
record_object = etree.Element(self.category)
else: else:
record_object = xml_object # Write all attributes from xml_object onto node
node.clear()
node.tag = xml_object.tag
node.text = xml_object.text
node.tail = xml_object.tail
node.attrib = xml_object.attrib
node.extend(xml_object)
record_object.set('url_name', self.url_name) node.set('url_name', self.url_name)
# Special case for course pointers: # Special case for course pointers:
if self.category == 'course': if self.category == 'course':
# add org and course attributes on the pointer tag # add org and course attributes on the pointer tag
record_object.set('org', self.location.org) node.set('org', self.location.org)
record_object.set('course', self.location.course) node.set('course', self.location.course)
return etree.tostring(record_object, pretty_print=True, encoding='utf-8')
def definition_to_xml(self, resource_fs): def definition_to_xml(self, resource_fs):
""" """
...@@ -450,6 +466,86 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -450,6 +466,86 @@ class XmlDescriptor(XModuleDescriptor):
@property @property
def non_editable_metadata_fields(self): def non_editable_metadata_fields(self):
non_editable_fields = super(XmlDescriptor, self).non_editable_metadata_fields """
non_editable_fields.append(XmlDescriptor.xml_attributes) Return a list of all metadata fields that cannot be edited.
"""
non_editable_fields = super(XmlParserMixin, self).non_editable_metadata_fields
non_editable_fields.append(XmlParserMixin.xml_attributes)
return non_editable_fields return non_editable_fields
class XmlDescriptor(XmlParserMixin, XModuleDescriptor): # pylint: disable=abstract-method
"""
Mixin class for standardized parsing of XModule xml.
"""
@classmethod
def from_xml(cls, xml_data, system, id_generator):
"""
Creates an instance of this descriptor from the supplied xml_data.
This may be overridden by subclasses.
Args:
xml_data (str): A string of xml that will be translated into data and children
for this module
system (:class:`.XMLParsingSystem):
id_generator (:class:`xblock.runtime.IdGenerator`): Used to generate the
usage_ids and definition_ids when loading this xml
"""
# Shim from from_xml to the parse_xml defined in XmlParserMixin.
# This only exists to satisfy subclasses that both:
# a) define from_xml themselves
# b) call super(..).from_xml(..)
return super(XmlDescriptor, cls).parse_xml(
etree.fromstring(xml_data), # pylint: disable=no-member
system,
None, # This is ignored by XmlParserMixin
id_generator,
)
@classmethod
def parse_xml(cls, node, runtime, keys, id_generator):
"""
Interpret the parsed XML in `node`, creating an XModuleDescriptor.
"""
if cls.from_xml != XmlDescriptor.from_xml:
# Skip the parse_xml from XmlParserMixin to get the shim parse_xml
# from XModuleDescriptor, which actually calls `from_xml`.
return super(XmlParserMixin, cls).parse_xml(node, runtime, keys, id_generator) # pylint: disable=bad-super-call
else:
return super(XmlDescriptor, cls).parse_xml(node, runtime, keys, id_generator)
def export_to_xml(self, resource_fs): # pylint: disable=unused-argument
"""
Returns an xml string representing this module, and all modules
underneath it. May also write required resources out to resource_fs.
Assumes that modules have single parentage (that no module appears twice
in the same course), and that it is thus safe to nest modules as xml
children as appropriate.
The returned XML should be able to be parsed back into an identical
XModuleDescriptor using the from_xml method with the same system, org,
and course
"""
# Shim from export_to_xml to the add_xml_to_node defined in XmlParserMixin.
# This only exists to satisfy subclasses that both:
# a) define export_to_xml themselves
# b) call super(..).export_to_xml(..)
node = Element(self.category)
super(XmlDescriptor, self).add_xml_to_node(node)
return etree.tostring(node) # pylint: disable=no-member
def add_xml_to_node(self, node):
"""
Export this :class:`XModuleDescriptor` as XML, by setting attributes on the provided
`node`.
"""
if self.export_to_xml != XmlDescriptor.export_to_xml:
# Skip the add_xml_to_node from XmlParserMixin to get the shim add_xml_to_node
# from XModuleDescriptor, which actually calls `export_to_xml`.
super(XmlParserMixin, self).add_xml_to_node(node) # pylint: disable=bad-super-call
else:
super(XmlDescriptor, self).add_xml_to_node(node)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment