import unittest
import inspect
import textwrap
import traceback
import sys
import functools
from pathlib import Path
from io import StringIO
import logging
import json
from typing import Optional, Callable, Iterator, Union, Any, Optional, Mapping, MutableMapping, Sequence, Iterable

from rich.pretty import pprint
from .templates import *

# Add at the beginning of the file
logger = logging.getLogger(__name__)

def _linenumber_of_member(m):
    """ Get the line number of a class member for sorting purposes
    
        This function extracts the line number of a class member to enable
        sorting members in their original declaration order in the source code.
    
        Args:
            m: A (name, method) tuple from class inspection

        Returns:
            int: The line number where the member was defined, or -1 if not determinable
    """
    try:
        raw_lineno = getattr(m[1], '_Unittest2Doc__raw_lineno', None)
        if raw_lineno is not None:
            return raw_lineno

        raw_lineno = getattr(m[1], '_co_number', None)
        if raw_lineno is None:
            return m[1].__code__.co_firstlineno
        else:
            return raw_lineno
    except AttributeError:
        return -1
def _mask_data(data, starts:int|None=None, ends:int|None=None):
    """ Mask sensitive data with option to show partial content
    
        Creates a masked representation of data that hides most content
        but optionally shows the beginning and/or end portions.
    
        Args:
            data: The data to mask
            starts: If provided, show this many characters from the beginning
            ends: If provided, show this many characters from the end

        Returns:
            str: A masked representation of the data
    """
    result = f'**MASKED**:({len(str(data))})'
    if starts is not None or ends is not None:
        v = str(data)
        if starts is not None:
            s = v[:starts]
        else:
            s = ''
        if ends is not None:
            e = v[ends:]
        else:
            e = ''
        v = '...'
        result += f'|{s}{v}{e}'
    return result

UNDEFINED = Exception('undefined')
def nested_get(d:dict, path:list[str]):
    """ Get a nested dictionary value using a key path
    
        Traverses a nested dictionary structure using a list of keys.
    
        Args:
            d: The dictionary to traverse
            path: A list of keys representing the path to the desired value

        Returns:
            The value at the specified path, or UNDEFINED if the path is invalid
    """
    for key in path:
        if isinstance(d, dict) and key in d:
            d = d[key]
        else:
            return UNDEFINED
    return d

def nested_set(D, path:list[str], value, create_missing:bool=False):
    """ set value for a dict with given key path like dict.A.B.C.

        From https://stackoverflow.com/questions/13687924/setting-a-value-in-a-nested-python-dictionary-given-a-list-of-indices-and-value

        Args:
          dict: the dict to update
          path:
            the dick key path like dict.<path> to modify
          value:
            set dict.<path> to this value
          create_missing:
            if dict.<path> not exist

        **Example**: ::

            # Trying to set a value of a nonexistent key DOES NOT create a new value
            >>> print(nested_set({"A": {"B": 1}}, ["A", "8"], 2, False))
            {'A': {'B': 1}}

            # Trying to set a value of an existent key DOES create a new value
            >>> print(nested_set({"A": {"B": 1}}, ["A", "8"], 2, True))
            {'A': {'B': 1, '8': 2}}

            # Set the value of an existing key
            >>> print(nested_set({"A": {"B": 1}}, ["A", "B"], 2))
            {'A': {'B': 2}}
    """
    d = D
    for key in path[:-1]:
        if key in d:
            d = d[key]
        elif create_missing:
            d = d.setdefault(key, {})
        else:
            return D
    if path[-1] in d or create_missing:
        d[path[-1]] = value
    return D

def get_caller_func_name() -> str:
    """ Return the name of the calling function
    
        Uses inspection to determine the name of the function that called
        the current function.
    
        Returns:
            str: The name of the calling function
    """
    frame = inspect.currentframe()
    caller_frame = frame.f_back
    caller_name = caller_frame.f_code.co_name
    return caller_name

def docpprint(data:any, **kwargs):
    """ Pretty print data for documentation purposes
    
        A wrapper around rich.pretty.pprint with default settings optimized
        for documentation output.
    
        Args:
            data: The data to pretty print
            **kwargs: Additional arguments passed to rich.pretty.pprint
    """
    pprint(data, indent_guides=False, expand_all=True, **kwargs)
  # pprint.pprint(data, indent=2, width=160, compact=True, sort_dicts=False, **kwargs)

class Tee(object):
    ''' Tee sys.stdout to self.file, and then you can read it out to somewhere else

        will also redirect logging things 

        from https://stackoverflow.com/questions/616645/how-to-duplicate-sys-stdout-to-a-log-file

        **Usage**: ::

            tee = Tee()
            try:
                # do something
            except:
                tee.stop()
                del tee
            else:
                tee.file.seek(0)
                _output_str = tee.file.read()
                tee.stop()
                del tee
                # output _output_str
    '''
    def __init__(self, name:Optional[str]=None, mode='a', tee:bool=True, unbuffered:bool=False, prefix="  "):
      '''
        Args:
            name: If not None, will tee to file.  Else will tee to StringIO.
            mode: The file mode if `name` is set, default is `a`
            prefix: extra prefix string to add for each record lines
            tee: default is True, still write to sys.stdout.
                 if set to false, will eat all outputs

      '''
      if name is not None:
          self.file = open(name, mode) #: store Tee content
      else:
          self.file = StringIO()
      self.stdout = sys.stdout
      self.stderr = sys.stderr
      sys.stdout = self
      sys.stderr = self
      self.tee = tee
      self.unbuffered = unbuffered
      self.prefix = prefix
      self._stopped = False
      self._update_logging_stream_handlers(on=True)
    def isatty(self):
        return False
    def stop(self):
        """ stop tee sys.stdout and do cleanups
        """
        if not self._stopped:
            import sys
            sys.stdout = self.stdout
            sys.stderr = self.stderr
            self.file.seek(0)
            self._result = self.file.read()
            # this may cause some error for multi thread tests, so we do not close it
            # self.file.close()
            self._stopped = True
            self._update_logging_stream_handlers(on=False)
    def _update_logging_stream_handlers(self, on=True):
        """ change all streamHandler of all loggers to self or sys.stderr
        """
        loggers = logging.root.manager.loggerDict
        for name,logger in loggers.items():
            if isinstance(logger, logging.Logger):
                for handler in logger.handlers:
                    if type(handler) is logging.StreamHandler:
                        if on:
                            handler.stream = self
                        else:
                            handler.stream = sys.stderr
    def __del__(self):
        if not self._stopped:
            import sys
            sys.stdout = self.stdout
            sys.stderr = self.stderr
            self.file.close()
    def write(self, data, from_logger=False):
        if self.tee:
            self.stdout.write(data)
        end_with_newline = data.endswith('\n')
        if end_with_newline:
            data = data[:-1]
        data = data.split('\n')
        data = list(map(lambda _:self.prefix+_ if _ else _, data))
        data = '\n'.join(data)
        if end_with_newline:
            data = data+'\n'
        if 'FROM_LOGGER' in globals(): # ?
            return
        try:
            self.file.write(data)
        except Exception as e:
            print(f"error in Tee.write for {self.file}, try to write {data}")
            raise
        if self.unbuffered:
            self.file.flush()
    def flush(self):
        self.file.flush()

def custom_serializer(obj:Any) -> str:
    """try to use repr() to serialize objects that are not JSON serializable"""
    try:
        return json.JSONEncoder().default(obj)
    except TypeError:
        return repr(obj)

class FLog:
    """ Record the function and its output, used to generate document for unittest functions

        *Usage*: ::

            # in Unittest2Doc.setUp
            s.flog = FLog(do_print=True, input_prefix='# call=> ', input_suffix=' ==>')
            s.func=s.flog.func

            # in test functions
            s.r = s.func(somefunc)
            s.r = s.func(somefunc, args=[], kwargs={})
            s.r = s.frun(somefunc, arg1, args2, args3, kwarg0=..., kwargs1=..., kwargs2=...)

    """
    def __init__(self,
            fargs:Optional[dict[Callable]]=None,
            fkwargs:Optional[dict]=None,
            do_print:Optional[bool]=True,
            do_print_input:Optional[bool]=None,
            do_print_output:Optional[bool]=None,
            input_prefix:Optional[str]='',
            input_suffix:Optional[str]='',
            output_suffix:Optional[str]='',
            input_max_width:int=80,
        ):
        self.fargs = fargs
        self.fkwargs = fkwargs
        self.do_print = do_print
        self.do_print_input  = do_print_input
        self.do_print_output = do_print_output
        self.input_prefix = input_prefix
        self.input_suffix = input_suffix
        self.output_suffix = output_suffix
        self.input_max_width = input_max_width
    def frun(self, func, *args, **kwargs):
        self.func(func,args=args,kwargs=kwargs)
    def func(self, func:Callable, *,
              args:Optional[Union[list,tuple]]=None,
              kwargs:Optional[dict]=None,
              fargs:Optional[dict[Callable]]=None,
              fkwargs:Optional[dict]=None,
              do_print:Optional[bool]=None,
              do_print_input:Optional[bool]=None,
              do_print_output:Optional[bool]=None,
              input_prefix:Optional[str]=None,
              input_suffix:Optional[str]=None,
              output_suffix:Optional[str]=None,
              input_max_width:Optional[int]=None,
          ) -> dict:
          """ run function with give args and kwargs, return formated result

              Args:
                  args: see below
                  kwargs:  together with ``args``, we call the function like ``func(*args, **kwargs)``
                  fargs: function to reformat args befreo we print it in the `input` retion section, see below
                  fkwargs: together with ``fargs``, we preprocess (reformat) the input 
                           and then print them in the function input part, see the ``Returns`` for details
                  print: if true, will also print the ``input`` and ``output`` results
                  input_prefix: add prefix for the `input` result
                  input_suffix: add suffix for the `input` result

              Returns:
                  dict with the keys ``output``, ``input``.

                  The ``output`` is the return value from the func call
                  The ``intput`` is like ::

                      func(args[0], args[1], ..., args[-1], key0=value0, key1=value1, ...)

                  where the ``key<n>`` and ``value<n>`` are from ``kwargs``

                  if fargs and fkwargs are not None, we preprocess the input data before we print them, like::

                      func(
                          fargs[0](args[0]), fargs[1](args[1]), ..., fargs[-1](args[-1]),
                          key0=fkwargs[key0](value0), key1=fkwargs[key1](value1), ...)

          """
          if args is None:
              args = []
          if kwargs is None:
              kwargs = {}
          if "get parameters from self config":
              if fargs is None:
                  fargs = self.fargs
              if fkwargs is None:
                  fkwargs = self.fkwargs
              if do_print_input is None:
                  if do_print is None:
                      if self.do_print_input is None:
                          do_print_input = self.do_print
                      else:
                          do_print_input = self.do_print_input
                  else:
                      do_print_input = do_print
              if do_print_output is None:
                  if do_print is None:
                      if self.do_print_output is None:
                          do_print_output = self.do_print
                      else:
                          do_print_output = self.do_print_output
                  else:
                      do_print_output = do_print
            
              if input_prefix is None:
                  input_prefix = self.input_prefix
              if input_suffix is None:
                  input_suffix = self.input_suffix
              if output_suffix is None:
                  output_suffix = self.output_suffix
              if input_max_width is None:
                  input_max_width = self.input_max_width

          output = func(*args, **kwargs)
          fname = func.__code__.co_name

          args_list = []
          for i, each in enumerate(args):
              if fargs is not None and i < len(fargs):
                  each = fargs[i](each)
              args_list.append(repr(each))
          kwargs_list = []
          for key, each in kwargs.items():
              if fkwargs is not None and key in fkwargs:
                  each = fkwargs[key](each)
              kwargs_list.append(f"{key}={repr(each)}")
          if len(args_list):
              args_str = ', '.join(args_list) + ', '
          else:
              args_str = ''
          if len(kwargs_list):
              kwargs_str = ', '.join(kwargs_list)
          else:
              kwargs_str = ''
          input = f"{input_prefix}{fname}({args_str}{kwargs_str}){input_suffix}"
          if len(input) > input_max_width: # input line too long, we make multiline print
              args_list = []
              for i, each in enumerate(args):
                  if fargs is not None and i < len(fargs):
                      each = fargs[i](each)
                  if len(str(each))>input_max_width:
                      args_list.append(f"{repr(each)}")
                  else:
                      args_list.append(f"{json.dumps(each, indent=2, default=custom_serializer)}")
              kwargs_list = []
              for key, each in kwargs.items():
                  if fkwargs is not None and key in fkwargs:
                      each = fkwargs[key](each)
                  if len(f"{key}={repr(each)}")>input_max_width:
                      kwargs_list.append(f"{key}={json.dumps(each, indent=2, default=custom_serializer)}")
                  else:
                      kwargs_list.append(f"{key}={repr(each)}")
              if len(args_list):
                  args_str = ',\n'.join(args_list)
                  args_str   = "\n".join(map(lambda _:'  '+_, args_str.split('\n')))
              else:
                  args_str = ''
              if len(kwargs_list):
                  if len(args_str):
                      args_str += ",\n"
                  kwargs_str = ',\n'.join(kwargs_list)
                  kwargs_str = "\n".join(map(lambda _:'  '+_, kwargs_str.split('\n')))
              else:
                  kwargs_str = ''
              input_str = f"{args_str}{kwargs_str}"
              if len(input_str):
                  input_str = '\n' + input_str + '\n'
              input = f"{input_prefix}{fname}({input_str}){input_suffix}"

          if do_print_input:
              print(input)
          if do_print_output:
              print(output, end=f"{output_suffix}\n")
          return {'input': input, 'output': output}

class Unittest2Doc(unittest.TestCase):
    """ wrapper unittests and make documentation pages for sphinx

        **Docstring formats in the unittest functions**:

        * the docstring of the custom Unittest2Doc class will be added to beginning of the doc page

        * the first line of the docstring can be used as config section, e.g.
          
          * ``{"open_input":false}`` to close the input by default

          * ``{"title_marker": "^"}`` to leveldown this test (change default title marker to ``^``)

          * ``{"title_marker": "-"}`` to levelup this test (change default title marker to ``-``)

          * ``{"output_highlight": "python"}``, ``{"output_highlight": "json"}``

        * functions used

          * we only run functions that name startswith ``test``

          * we only use their docstring for functions that name startswith ``rst``

          * setUp is always called at beginning of the test and tearDown is always called at end

        **Usage**: ::

          t = ThisTest(
            name='unittest2doc.unittest2doc.Unittest2Doc.basic',   # required
            ref=':class:`unittest2doc.unittest2doc.Unittest2Doc`', # optional
            doc_root=Path(__file__).absolute().parent.parent / 'sphinx-docs/source/unittests', # required
          )
        
        see :doc:`/unittests/unittest2doc/unittest2doc/Unittest2Doc/index` for more detailed examples

    """
    @classmethod
    def skip(cls, func):
        """ use @Unittest2Doc.skipped to skip some unittest
        """
        def wrapper(*args, **kwargs):
            _skipped = True
            func(*args, **kwargs)
        # return wrapper
        return unittest.skip("skipped with unittest2doc.skip")(wrapper)

    @classmethod
    def stop(cls, func):
        """ use @Unittest2Doc.stopped to stop before this function
        """
        def wrapper(*args, **kwargs):
            _stopped = True
            func(*args, **kwargs)
        wrapper.__raw_lineno = func.__code__.co_firstlineno
        wrapper.__unittest2doc_stop__ = True
        return wrapper
    
    @classmethod
    def only(cls, func):
        """ use @Unittest2Doc.only to only run some unittest
        """
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            _only = True
            func(*args, **kwargs)
        wrapper.__raw_lineno = func.__code__.co_firstlineno
        wrapper.__unittest2doc_only__ = True
        return wrapper

    @classmethod
    def stop_after(cls, func):
        """ use @Unittest2Doc.stop_after to stop unittest after this function
        """
        def wrapper(*args, **kwargs):
            _stop_after = True
            func(*args, **kwargs)
        wrapper.__raw_lineno = func.__code__.co_firstlineno
        wrapper.__unittest2doc_stop_after__ = True
        return wrapper
    
    @classmethod
    def expected_failure(cls, func):
        """ use @Unittest2Doc.expected_failure to mark a test as expected to fail """
        wrapper = unittest.expectedFailure(func)
        wrapper.__raw_lineno = func.__code__.co_firstlineno
        wrapper.__unittest2doc_expected_failure__ = True
        return wrapper

    def __init__(self, *args,
            name:str=None,
            ref:str='',
            update_index=True,
            no_test_prefix=True,
            open_input=True,
            open_output=True,
            output_highlight='',
            doc_root=None,
            **kwargs
        ):
        """
            Args:
                name: name of the package, e.g., name='lib.utils'
                ref: if set, will add `references: {ref}` at beginning. ref should be a rst link in sphinx
                doc_root:
                    root folder of the unittests docs folder.
                    Usually it should be `<package-folder>/docs/source/unittests`
                update_index: update all parent index files
                output_highlight: the highlight language for the output section
                open_input: open input section
                open_output: open output section
                no_test_prefix: remove the ``test_`` function name prefix in the doc page
        """
        super().__init__(*args, **kwargs)

        self.title_marker = '-'
        self.open_input  = open_input
        self.open_output = open_output
        self.no_test_prefix = no_test_prefix
        self.output_highlight = output_highlight
        self.extra_doc_prefix = []
        self.extra_doc_suffix = []

        if name is None:
            if doc_root is None:
                name = "unittest_run"
            else:
                raise Exception('should give name')
        doc_path = name.replace('.', '/')

        if doc_root is None:
            self.doc_root = None
            self.doc_dir  = None
            self.doc_name = None
        else:
            self.doc_root = Path(doc_root)
            self.doc_root.mkdir(parents=True, exist_ok=True)
            doc_path = self.doc_root / doc_path
            self.doc_dir = doc_path.parent
            self.doc_dir.mkdir(parents=True, exist_ok=True)

            # generate index.rst index files in parent folders
            _path_list = name.split('.')[:-1]
            _path_list.insert(0, self.doc_root.name)
            _path = ''
            for i,basename in enumerate(_path_list):
                title = '.'.join(_path_list[1:i+1])
                if not len(_path):
                    _path = f"{basename}"
                else:
                    _path = f"{_path}/{basename}"
                index_file = Path(self.doc_root.parent, _path, 'index.rst')
                if not index_file.exists() or update_index:
                    pfolder = index_file.parent

                    contents = [P_MAIN_title.format(title=title if i>0 else 'UNITTESTs')]
                    if len(list(pfolder.glob('*.rst'))) > 1 or i == len(_path_list) - 1:
                        contents.append(P_MAIN_local)
                    if len(list(pfolder.glob("*/*"))) > 0 or i != len(_path_list) - 1:
                        contents.append(P_MAIN_sub)
                    with open(index_file, 'w') as f:
                        f.write("\n".join(contents))
                    #if i == 0:
                    #  with open(index_file, 'w') as f:
                    #    f.write(P_MAIN.format(title='UNITTESTs', subs='*/index'))
                    #else:
                    #  _title = '.'.join(_path.split('/')[1:])
                    #  if i == len(_path_list) - 1:
                    #    with open(index_file, 'w') as f:
                    #      f.write(P_MAIN.format(title=_title, subs='*'))
                    #  else: # normal
                    #    with open(index_file, 'w') as f:
                    #      f.write(P_MAIN.format(title=_title, subs='*/index'))

            self.doc_name = doc_path.name

        self._name = name
        if len(ref):
            self._ref_str = f'references: {ref}\n'
        else:
            self._ref_str = ''
        self._tresults = []

    def v(self, toshow:list[str], l:dict, g:dict, mask:list=None, doprint:bool=True) -> dict:
        """ pretty print varialbes in toshow list

            Args:
                toshow: the variables to be shown
                l: should input ``locals()``
                g: should input ``globals()``
                mask: the variables that contain secret values, should be masked
                doprint: if True, do pprint

            **Usages**: ::

                # in Unittest2Doc test functions
                s.v(['var0', 'var1'], locals(), globals(), mask=[])

                # mask some secrets
                s.v(['a', 'b', 'c', 'd'], locals(), globals(), mask=[
                    'c.secret',
                    'c.subsecret.bad',
                    'c.subsecret.sub.bad',
                ])

        """
        if mask is None:
            mask = []
        result = {}
        for each in toshow:
            result[each] = eval(each, g, l)

        dmask = isinstance(mask, dict)
        for each in mask:
            if each in result:
                if dmask:
                    masked_str = _mask_data(result[each], **mask[each])
                else:
                    masked_str = _mask_data(result[each])
                result[each] = masked_str
            elif '.' in each: # search parent
                parent = each.split('.')
                sub = []
                for i in range(len(parent)-1):
                    sub.insert(0, parent.pop())
                    key_parent = '.'.join(parent)
                    if key_parent in result and isinstance(result[key_parent], dict):
                        value = nested_get(result[key_parent], sub)
                        if value is not UNDEFINED: # now the sub value is good to use
                            if dmask:
                                masked_str = _mask_data(value, **mask[each])
                            else:
                                masked_str = _mask_data(value)
                            nested_set(result[key_parent], sub, masked_str)
                            break
        if doprint:
            docpprint(result)
        return result

    def _process_docstring(self, docstring):
        """ clean white spaces """
        docstringlines = docstring.split('\n')
        if len(docstringlines) <= 1:
            docstring = docstring.strip()
        else:
            first,*latter = docstringlines
            first = first.strip()
            latter = textwrap.dedent('\n'.join(latter))
            docstring = '\n'.join([first, latter])
        return docstring

    def add_foldable_output(self, each):
      """ add extra foldabel text at end of the doc page

          params: name, output, highlight, open
          
          **usage**: ::

            add_foldable_output(
              {
                'name': 'structures and more examples',
                "highlight": 'python',
                'output':textwrap.dedent('''
                  # some code ...
                  def func(*args, **kwargs):
                    pass
                ''')
              } 
            )
      
      """
      name   = each['name']
      output_str = each['output']
      output_highlight = each.get('highlight', '')
      open = each.get('open', False)
      open_flag='  :open:\n' if open else ''
      output = output_str.split('\n')
      output = list(map(lambda _:'    '+_, output))
      output_str = '\n'.join(output)
      output_str = f"  .. code-block:: {output_highlight}\n\n" + output_str
      P_EXTRA = textwrap.dedent('''
        .. collapse:: {name}
        {open_flag}
        {output_str}

      ''')[1:].format(name=name, open_flag=open_flag, output_str=output_str)
      self.extra_doc_suffix.append(P_EXTRA)

    def generate_docs(self):
      """ do unittest and generate docs
      """
      if "select test functions":
          tests_raw = inspect.getmembers(self)
          tests_raw_names = list(map(lambda _:_[0], tests_raw))
          tests = list(
              filter(
                    lambda _:_[0].startswith('test') or 
                    _[0].startswith('rst')
                , tests_raw))
          tests.sort(key=_linenumber_of_member)
          #tests_debug = list(filter(lambda _:_[0].startswith('test'), tests))
          # for function starts with `rst`, will only use its docstring
          tests = list(map(lambda _:_[0], tests))

          # process `only` tests
          excepted_failures = []
          _tests_only = []
          have_only = False
          for i, testname in enumerate(tests):
              if testname in ['tearDown', 'setUp']:
                  _tests_only.append(testname)
              else:
                  func = getattr(self, testname)
                  if getattr(func, '__unittest2doc_only__', False):
                      _tests_only.append(testname)
                      have_only = True
                  if getattr(func, '__unittest_expecting_failure__', False):
                      excepted_failures.append(testname)
                  if getattr(func, '__unittest2doc_stop__', False):
                      tests = tests[:i]
                      break
                  if getattr(func, '__unittest2doc_stop_after__', False):
                      tests = tests[:i+1]
                      break
          if have_only:
              tests = _tests_only

          if 'setUp' in tests_raw_names:
              __ = getattr(self, 'setUp')
              if __.__doc__ is not None:
                  if not __.__doc__.startswith('Hook method for setting up the test fixture before exercising it.'):
                      tests.insert(0, 'setUp')
              else:
                  tests.insert(0, 'setUp')
          if 'tearDown' in tests_raw_names:
              __ = getattr(self, 'tearDown')
              if __.__doc__ is not None:
                  if not __.__doc__.startswith('Hook method for deconstructing the test fixture after testing it.'):
                      tests.append('tearDown')
              else:
                  tests.append('tearDown')
      
      _result_doc = [
          P_HEADER.format(
              name=self._name,
              ref_str=self._ref_str,
              target_str=f".. _unittests/{self._name}:"
          )
      ]

      if 'process class docstring':
          docstring = self.__doc__
          if docstring is not None:
              docstring = self._process_docstring(docstring)
              if len(docstring):
                  _result_doc.append(docstring+'\n')

      tests = list(
        filter(
          lambda _:not getattr(getattr(self, _), '__unittest_skip__', False), 
        tests))

      print(f'do tests for {tests}')
      for testname in tests:
          func = getattr(self, testname)
          _source = textwrap.dedent(inspect.getsource(func))
          _input_str = '\n'.join(['    '+_ for _ in _source.split('\n')])
          #if '_skipped' in func.__code__.co_varnames:
          #    continue
          print(f'=====> {testname}')
          if testname.startswith('test_'):
              test_target_name = testname[5:]
          else:
              test_target_name = testname
          # if first line is a json, we use it as config
          docstring = func.__doc__
          # you can change the open status in previous tests
          open_input  = self.open_input
          open_output = self.open_output
          output_highlight = self.output_highlight
          dconfig = {}
          saveio = True
          if docstring is not None:
              try:
                  first = docstring.split('\n')[0].strip()
                  dconfig = json.loads(first)
              except Exception as e:
                  pass
              else: # have dconfig, make online changes
                  docstring = '\n'.join(docstring.split('\n')[1:])
                  if 'title_marker' in dconfig:
                      self.title_marker = dconfig['title_marker']
                  if 'open_input' in dconfig:
                      open_input  = dconfig['open_input']
                  if 'open_output' in dconfig:
                      open_output = dconfig['open_output']
                  if 'output_highlight' in dconfig:
                      output_highlight = dconfig['output_highlight']
                  if 'saveio' in dconfig:
                      saveio = dconfig['saveio']

          if self.no_test_prefix and testname.startswith('test_'):
              testtitle = testname[5:]
          else:
              testtitle = testname
          if not testname.startswith('rst_'):
              _result_doc.append(
                  P_SUBTITLE.format(
                      title=testtitle,
                      title_marker=self.title_marker*len(testtitle),
                      target_str=f".. _unittests/{self._name}.{test_target_name}:",
                  )
              )

          # go on with doc string
          if docstring is not None:
              docstring = self._process_docstring(docstring)
              if len(docstring):
                  _result_doc.append(docstring+'\n')
          if testname.startswith('rst') or not saveio: # only use docstring for rst_* functions
              saveio = False
          else:
              saveio = True

          if self.doc_root is None:
              saveio = False

          # add INPUT
          if saveio:
              #_input_str = "  .. code-block:: python\n     :linenos:\n\n" + _input_str
              _input_str = f"  .. code-block:: python\n\n" + _input_str
              _result_doc.append( P_INPUT.format(
                  input_str=_input_str,
                  open_flag='  :open:\n' if open_input else '',
              ) )
          # prepare Tee and run function
          if saveio:
              tee = Tee(tee=self.doc_root is not None)
          try:
              func()
          except Exception as e:
              # meet error
              exp = traceback.format_exc()
              ss = exp.split('\n')
              ss = filter(lambda _:_.strip(), ss)
              output = list(map(lambda _:'    '+_, ss))
              _output_str = '\n'.join(output)
              _output_str = f"  .. code-block:: {output_highlight}\n\n" + _output_str
              # record error into docstring and exit
              #   you can compile this result and inspect it in Sphinx pages
              _result_doc.append( P_ERROR.format(
                  error_str=_output_str,
                  open_flag='  :open:\n' if open_output else '',
              ) )
              with open(Path(self.doc_dir, self.doc_name+'.rst'), 'w') as f:
                  f.write('\n'.join(_result_doc))
              if saveio:
                  tee.stop()
                  del tee
              if testname not in excepted_failures:
                  raise
          else:
            # no errors
            if saveio:
                tee.file.seek(0)
                _output_str = tee.file.read()
                tee.stop()
                del tee
                if len(_output_str) != 0 and not dconfig.get("no_output", False):
                    output = _output_str.split('\n')
                    output = list(map(lambda _:'  '+_, output))
                    _output_str = '\n'.join(output)
                    _output_str = f"  .. code-block:: {output_highlight}\n\n" + _output_str
                    _result_doc.append( P_RESULT.format(
                        output_str=_output_str,
                        open_flag='  :open:\n' if open_output else '',
                    ) )
          _result_doc[:0] = self.extra_doc_prefix
          _result_doc.extend(self.extra_doc_suffix)
          self.extra_doc_prefix = []
          self.extra_doc_suffix = []
      if saveio:
          with open(Path(self.doc_dir, self.doc_name+'.rst'), 'w') as f:
              f.write('\n'.join(_result_doc))

__all__ = [
  'Unittest2Doc',
  'FLog',
  'docpprint',
  'unittest',
]
