text: """
  Chapter3Reader collects DICOM Information Object Definition information
  for specific Storage SOP Classes.
  The information is taken from PS3.3 in docbook format as provided by ACR NEMA.
  """
  import logging
  from itertools import groupby
  
  import sys
  
  from dcm_spec_tools.spec_reader.condition_parser import ConditionParser
  from dcm_spec_tools.spec_reader.spec_reader import (
      SpecReader, SpecReaderParseError, SpecReaderLookupError
  )
  
  
  class Part3Reader(SpecReader):
      """Reads information from PS3.3 in docbook format."""
  
      def __init__(self, spec_dir, dict_info=None):
          super(Part3Reader, self).__init__(spec_dir)
          self.part_nr = 3
          self._condition_parser = None
          self._dict_info = dict_info
          self._iod_descriptions = {}
          self._iod_nodes = {}
          self._module_descriptions = {}
          self._current_refs = []
          self.logger = logging.getLogger()
          if not self.logger.handlers:
              self.logger.addHandler(logging.StreamHandler(sys.stdout))
          if dict_info is not None:
              self._condition_parser = ConditionParser(self._dict_info)
  
      def iod_description(self, chapter):
          """Return the IOD information for the given chapter.
  
          The return value is a dict with the entries:
              'title': The display name of the IOD
              'modules': A dictionary of the contained IOD modules with the
                  module name as key.
                  A module dict value has the following entries:
                  'ref': The section in PS3.3 describing the module
                      (e.g. 'C.7.4.2')
                  'use': Usage information (e.g. 'M' for mandatory)
          Raises SpecReaderLookupError if the chapter is not found.
          """
          if chapter not in self._iod_descriptions:
              iod_node = self._get_iod_nodes().get(chapter)
              if iod_node:
                  description = self._parse_iod_node(iod_node)
                  self._iod_descriptions[chapter] = description
          try:
              return self._iod_descriptions[chapter]
          except KeyError:
              raise SpecReaderLookupError(
                  'No definition found for chapter {}'.format(chapter))
  
      def iod_descriptions(self):
          """Return the IOD information dict per chapter.
  
          The dict has the chapter (e.g. 'A.3') as key and the IOD descriptions
           as value.
          See iod_description() for the format of the IOD descriptions.
          Retired IODs (which have no module list) are omitted.
          """
          return {chapter: self.iod_description(chapter) for chapter in
                  self._get_iod_nodes()
                  if self.iod_description(chapter)['modules']}
  
      def module_description(self, section):
          """Return the module information in the given section.
  
          The return value is a dict with the entries:
              'title': The name of the module
              'attributes': A dictionary of the contained module attributes
                  with the tag as key.
                  An attribute dict value has the following entries:
                      'name': the tag name
                      'type': the type (1, 1C, 2, 2C, 3)
                      'items': only for sequence tags - contains a dictionary
                          of the module attributes contained in the sequence
          Raises SpecReaderLookupError if the section is not found.
          """
          if section not in self._module_descriptions:
              section_node = self._get_section_node(section)
              if section_node:
                  description = self._parse_module_description(section_node)
                  self._module_descriptions[section] = description
          try:
              return self._module_descriptions[section]
          except KeyError:
              raise SpecReaderLookupError(
                  'No definition found for section {}'.format(section))
  
      def module_descriptions(self):
          """Return the module attribute information for all IODs.
  
          The return value is a dict with the section name as key and
          a description dict as value.
          See module_description() for the content of the value dict.
          """
          # ensure that all module attributes are read
          self.iod_descriptions()
          return self._module_descriptions
  
      def _get_iod_nodes(self):
          if not self._iod_nodes:
              chapter_a = self._find(self._get_doc_root(),
                                     ['chapter[@label="A"]'])
              if chapter_a is None:
                  raise SpecReaderParseError('Chapter A in Part 3 not found')
              # ignore A.1
              all_iod_nodes = self._findall(chapter_a, ['section'])[1:]
              iod_def_endings = (
                  ' IOD',
                  ' Information Object Definition',
                  ' Information Objection Definition'  # account for known typo
              )
  
              iod_sub_nodes = []
              nodes_with_subnodes = []
              for iod_node in all_iod_nodes:
                  sub_nodes = self._find_sections_with_title_endings(
                      iod_node, iod_def_endings)
                  if sub_nodes:
                      nodes_with_subnodes.append(iod_node)
                      iod_sub_nodes.extend(sub_nodes)
              all_iod_nodes = [node for node in all_iod_nodes if
                               node not in nodes_with_subnodes]
              all_iod_nodes.extend(iod_sub_nodes)
              self._iod_nodes = {node.attrib['label']: node for node in
                                 all_iod_nodes}
          return self._iod_nodes
  
      def _get_section_node(self, section):
          section_parts = section.split('.')
          section_name = section_parts[0]
          search_path = ['chapter[@label="{}"]'.format(section_name)]
          for section_part in section_parts[1:]:
              section_name = section_name + '.' + section_part
              search_path.append('section[@label="{}"]'.format(section_name))
          return self._find(self._get_doc_root(), search_path)
  
      def _parse_iod_node(self, iod_node):
          return {'title': self._find(iod_node, ['title']).text,
                  'modules': self._get_iod_modules(iod_node)}
  
      def _parse_module_description(self, parent_node):
          table_node = self._find(parent_node, ['table'])
          # handle the case that the parent node is the table itself
          if table_node is None:
              table_node = parent_node
          table_body_node = self._find(table_node, ['tbody'])
          if table_body_node is None:
              return
          rows = self._findall(table_body_node, ['tr'])
          current_level = 0
          current_descriptions = [{}]
          last_tag_id = None
          for row in rows:
              columns = self._findall(row, ['td'])
              if not columns:
                  continue
              tag_name, current_level = self._get_tag_name_and_level(
                  columns[0], current_descriptions, current_level, last_tag_id)
              if len(columns) == 4:
                  last_tag_id = self._handle_regular_attribute(
                      columns, current_descriptions, last_tag_id, tag_name)
              elif tag_name.startswith('Include'):
                  self._handle_included_attributes(columns, current_descriptions)
              else:
                  # todo: other entries
                  pass
          return current_descriptions[0]
  
      def _handle_included_attributes(self, columns, current_descriptions):
          include_node = self._find(columns[0], ['para', 'emphasis', 'xref'])
          if include_node is None:
              # todo: functional group macros or similar
              return
          include_ref = include_node.attrib['linkend']
          if self._current_refs and include_ref == self._current_refs[-1]:
              self.logger.debug('Self reference in %s  - ignoring.', include_ref)
              return
          self._current_refs.append(include_ref)
          element, label = self._get_ref_element_and_label(include_ref)
          if label not in self._module_descriptions:
              ref_node = self._get_ref_node(element, label)
              if ref_node is None:
                  raise SpecReaderLookupError(
                      'Failed to lookup include reference ' + include_ref)
              # it is allowed to have no attributes (example: Raw Data)
              ref_description = self._parse_module_description(ref_node) or {}
              self._module_descriptions[label] = ref_description
          current_descriptions[-1].setdefault('include', []).append(label)
          self._current_refs.pop()
  
      def _handle_regular_attribute(self, columns, current_descriptions,
                                    last_tag_id, tag_name):
          tag_id = self._find_text(columns[1])
          tag_type = self._find_text(columns[2])
          if tag_id:
              current_descriptions[-1][tag_id] = {
                  'name': tag_name,
                  'type': tag_type,
              }
              if self._condition_parser and tag_type in ('1C', '2C'):
                  # cond = self._find_all_text(columns[3])
                  # index = cond.find('Required if ')
                  # if index >= 0:
                  #     current_descriptions[-1][tag_id]['desc'] = cond[index:]
                  current_descriptions[-1][tag_id][
                      'cond'] = self._condition_parser.parse(
                      self._find_all_text(columns[3]))
  
              last_tag_id = tag_id
          return last_tag_id
  
      def _get_ref_node(self, element, label):
          return self._get_doc_tree().find(
              './/{}{}[@label="{}"]'.format(self.docbook_ns, element, label))
  
      @staticmethod
      def _get_ref_element_and_label(ref):
          element, label = ref.split('_')
          if element == 'sect':
              element = 'section'
          return element, label
  
      def _get_tag_name_and_level(self, column, current_descriptions,
                                  current_level, last_tag_id):
          tag_name = self._find_text(column)
          if not tag_name:
              return '', 0
          start_chars = next(groupby(tag_name))
          level = len(list(start_chars[1])) if start_chars[0] == '>' else 0
          tag_name = tag_name[level:]
          if level > current_level:
              sequence_description = {}
              try:
                  current_descriptions[-1][last_tag_id][
                      'items'] = sequence_description
                  current_descriptions.append(sequence_description)
              except KeyError:
                  # silently ignore error in older specs
                  pass
          elif level < current_level:
              current_descriptions.pop()
          return tag_name, level
  
      def _get_iod_modules(self, iod_node):
          module_table_sections = self._find_sections_with_title_endings(
              iod_node, (' Module Table', ' IOD Modules'))
          if not module_table_sections:
              module_table_sections = self._find_sections_with_title_endings(
                  iod_node, ('IOD Entity-Relationship Model',))
          modules = {}
          if len(module_table_sections) == 1:
              module_rows = self._findall(module_table_sections[0],
                                          ['table', 'tbody', 'tr'])
              row_span = 0
              for row in module_rows:
                  columns = self._findall(row, ['td'])
                  name_index = 0 if row_span > 0 else 1
                  if row_span == 0:
                      if 'rowspan' in columns[0].attrib:
                          row_span = int(columns[0].attrib['rowspan'])
                      else:
                          row_span = 1
                  name = self._find_text(columns[name_index])
                  modules[name] = {}
                  try:
                      ref_section = self._find(columns[name_index + 1],
                                               ['para', 'xref']).attrib[
                          'linkend'].split('_')[1]
                  except AttributeError:
                      try:
                          ref_section = self._find(
                              columns[name_index + 1], ['xref']).attrib[
                              'linkend'].split('_')[1]
                      except AttributeError:
                          self.logger.warning(
                              'Failed to read module table for %s', name)
                          continue
                  modules[name]['ref'] = ref_section
                  # make sure the module description is loaded
                  self.module_description(ref_section)
                  modules[name]['use'] = self._find_text(columns[name_index + 2])
                  if (self._condition_parser is not None and
                          modules[name]['use'].startswith('C - ')):
                      modules[name]['cond'] = self._condition_parser.parse(
                          modules[name]['use'])
                  else:
                      modules[name]['use'] = modules[name]['use'][0]
                  row_span -= 1
          return modules
  
      def _find_sections_with_title_endings(self, node, title_endings):
          section_nodes = self._findall(node, ['section'])
          found_nodes = []
          for sections_node in section_nodes:
              title_node = self._find(sections_node, ['title'])
              if title_node is not None:
                  title = title_node.text
                  if any([title.endswith(title_ending) for title_ending in
                          title_endings]):
                      found_nodes.append(sections_node)
          return found_nodes
  