#!/usr/bin/env python3 """ asciidocapi - AsciiDoc API wrapper class. The AsciiDocAPI class provides an API for executing asciidoc. Minimal example compiles `mydoc.txt` to `mydoc.html`: import asciidocapi asciidoc = asciidocapi.AsciiDocAPI() asciidoc.execute('mydoc.txt') - Full documentation in asciidocapi.txt. - See the doctests below for more examples. Doctests: 1. Check execution: >>> import io >>> infile = io.StringIO('Hello *{author}*') >>> outfile = io.StringIO() >>> asciidoc = AsciiDocAPI() >>> asciidoc.options('--no-header-footer') >>> asciidoc.attributes['author'] = 'Joe Bloggs' >>> asciidoc.execute(infile, outfile, backend='html4') >>> print(outfile.getvalue())

Hello Joe Bloggs

>>> asciidoc.attributes['author'] = 'Bill Smith' >>> infile = io.StringIO('Hello _{author}_') >>> outfile = io.StringIO() >>> asciidoc.execute(infile, outfile, backend='docbook') >>> print(outfile.getvalue()) Hello Bill Smith 2. Check error handling: >>> import io >>> asciidoc = AsciiDocAPI() >>> infile = io.StringIO('---------') >>> outfile = io.StringIO() >>> asciidoc.execute(infile, outfile) Traceback (most recent call last): File "", line 1, in File "asciidocapi.py", line 189, in execute raise AsciiDocError(self.messages[-1]) AsciiDocError: ERROR: : line 1: [blockdef-listing] missing closing delimiter Copyright (C) 2002-2013 Stuart Rackham. Copyright (C) 2013-2020 AsciiDoc Contributors. Free use of this software is granted under the terms of the GNU General Public License version 2 (GPLv2). """ import sys import os import re API_VERSION = '0.1.2' MIN_ASCIIDOC_VERSION = '8.4.1' # Minimum acceptable AsciiDoc version. def find_in_path(fname, path=None): """ Find file fname in paths. Return None if not found. """ if path is None: path = os.environ.get('PATH', '') for dir in path.split(os.pathsep): fpath = os.path.join(dir, fname) if os.path.isfile(fpath): return fpath else: return None class AsciiDocError(Exception): pass class Options(object): """ Stores asciidoc(1) command options. """ def __init__(self, values=[]): self.values = values[:] def __call__(self, name, value=None): """Shortcut for append method.""" self.append(name, value) def append(self, name, value=None): if type(value) in (int, float): value = str(value) self.values.append((name, value)) class Version(object): """ Parse and compare AsciiDoc version numbers. Instance attributes: string: String version number '.[.][suffix]'. major: Integer major version number. minor: Integer minor version number. micro: Integer micro version number. suffix: Suffix (begins with non-numeric character) is ignored when comparing. Doctest examples: >>> Version('8.2.5') < Version('8.3 beta 1') True >>> Version('8.3.0') == Version('8.3. beta 1') True >>> Version('8.2.0') < Version('8.20') True >>> Version('8.20').major 8 >>> Version('8.20').minor 20 >>> Version('8.20').micro 0 >>> Version('8.20').suffix '' >>> Version('8.20 beta 1').suffix 'beta 1' """ def __init__(self, version): self.string = version reo = re.match(r'^(\d+)\.(\d+)(\.(\d+))?\s*(.*?)\s*$', self.string) if not reo: raise ValueError('invalid version number: %s' % self.string) groups = reo.groups() self.major = int(groups[0]) self.minor = int(groups[1]) self.micro = int(groups[3] or '0') self.suffix = groups[4] or '' def __lt__(self, other): if self.major < other.major: return True elif self.major == other.major: if self.minor < other.minor: return True elif self.minor == other.minor: if self.micro < other.micro: return True return False # (sigh). Copy-paste def __le__(self, other): if self.major > other.major: return False elif self.major <= other.major: if self.minor > other.minor: return False elif self.minor <= other.minor: if self.micro > other.micro: return False return True def __eq__(self, other): if self.major == other.major \ and self.minor == other.minor \ and self.micro == other.micro: return True return False class AsciiDocAPI(object): """ AsciiDoc API class. """ def __init__(self, asciidoc_py=None): """ Locate and import asciidoc.py. Initialize instance attributes. """ self.options = Options() self.attributes = {} self.messages = [] # Search for the asciidoc command file in that order : # - ASCIIDOC_PY environment variable # - asciidoc_py function argument # - sibling (preferred to shell search paths, to ensure version matching) # - shell search paths cmd = os.environ.get('ASCIIDOC_PY') if cmd: if not os.path.isfile(cmd): raise AsciiDocError('missing ASCIIDOC_PY file: %s' % cmd) elif asciidoc_py: # Next try path specified by caller. cmd = asciidoc_py if not os.path.isfile(cmd): raise AsciiDocError('missing file: %s' % cmd) else: # try to find sibling paths this_path = os.path.dirname(os.path.realpath(__file__)) for fname in ['asciidoc.py', 'asciidoc.pyc', 'asciidoc']: cmd = find_in_path(fname, path=this_path) if cmd: break else: # Try shell search paths. for fname in ['asciidoc.py', 'asciidoc.pyc', 'asciidoc']: cmd = find_in_path(fname) if cmd: break else: # Finally try current working directory. for cmd in ['asciidoc.py', 'asciidoc.pyc', 'asciidoc']: if os.path.isfile(cmd): break else: raise AsciiDocError('failed to locate asciidoc') self.cmd = os.path.realpath(cmd) self.__import_asciidoc() def __import_asciidoc(self, reload=False): ''' Import asciidoc module (script or compiled .pyc). See http://groups.google.com/group/asciidoc/browse_frm/thread/66e7b59d12cd2f91 for an explanation of why a seemingly straight-forward job turned out quite complicated. ''' if os.path.splitext(self.cmd)[1] in ['.py', '.pyc']: sys.path.insert(0, os.path.dirname(self.cmd)) try: try: if reload: import importlib # Because reload() is shadowed. importlib.reload(self.asciidoc) else: import asciidoc self.asciidoc = asciidoc except ImportError: raise AsciiDocError('failed to import ' + self.cmd) finally: del sys.path[0] else: # The import statement can only handle .py or .pyc files, have to # use importlib for scripts with other names. try: from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader loader = SourceFileLoader('asciidoc', self.cmd) spec = spec_from_loader('asciidoc', loader) module = module_from_spec(spec) spec.loader.exec_module(module) self.asciidoc = module except ImportError: raise AsciiDocError('failed to import ' + self.cmd) if Version(self.asciidoc.VERSION) < Version(MIN_ASCIIDOC_VERSION): raise AsciiDocError( 'asciidocapi %s requires asciidoc %s or better' % (API_VERSION, MIN_ASCIIDOC_VERSION)) def execute(self, infile, outfile=None, backend=None): """ Compile infile to outfile using backend format. infile can outfile can be file path strings or file like objects. """ self.messages = [] opts = Options(self.options.values) if outfile is not None: opts('--out-file', outfile) if backend is not None: opts('--backend', backend) for k, v in self.attributes.items(): if v == '' or k[-1] in '!@': s = k elif v is None: # A None value undefines the attribute. s = k + '!' else: s = '%s=%s' % (k, v) opts('--attribute', s) args = [infile] # The AsciiDoc command was designed to process source text then # exit, there are globals and statics in asciidoc.py that have # to be reinitialized before each run -- hence the reload. self.__import_asciidoc(reload=True) try: try: self.asciidoc.execute(self.cmd, opts.values, args) finally: self.messages = self.asciidoc.messages[:] except SystemExit as e: if e.code: raise AsciiDocError(self.messages[-1]) if __name__ == "__main__": """ Run module doctests. """ import doctest options = doctest.NORMALIZE_WHITESPACE + doctest.ELLIPSIS test_result = doctest.testmod(optionflags=options) print(test_result) sys.exit(test_result.failed > 0)