PK!>)wfoliant/__init__.py__version__ = '1.0.7' PK!foliant/backends/__init__.pyPK!;ϔ_ foliant/backends/base.pyfrom importlib import import_module from shutil import copytree from datetime import date from logging import Logger from foliant.utils import spinner class BaseBackend(object): '''Base backend. All backends must inherit from this one.''' targets = () required_preprocessors_before = () required_preprocessors_after = () def __init__(self, context: dict, logger: Logger, debug=False): self.project_path = context['project_path'] self.config = context['config'] self.context = context self.logger = logger self.debug = debug self.working_dir = self.project_path / self.config['tmp_dir'] def get_slug(self) -> str: '''Generate a slug from the project title and version and the current date. Spaces in title are replaced with underscores, then the version and the current date are appended. ''' if 'slug' in self.config: return self.config['slug'] components = [] components.append(self.config['title'].replace(' ', '_')) version = self.config.get('version') if version: components.append(str(version)) components.append(str(date.today())) return '-'.join(components) def apply_preprocessor(self, preprocessor: str or dict): '''Apply preprocessor. :param preprocessor: Preprocessor name or a dict of the preprocessor name and its options ''' if isinstance(preprocessor, str): preprocessor_name, preprocessor_options = preprocessor, {} elif isinstance(preprocessor, dict): (preprocessor_name, preprocessor_options), = (*preprocessor.items(),) with spinner( f'Applying preprocessor {preprocessor_name}', self.logger, self.debug ): try: preprocessor_module = import_module(f'foliant.preprocessors.{preprocessor_name}') preprocessor_module.Preprocessor( self.context, self.logger, preprocessor_options ).apply() except ModuleNotFoundError: raise ModuleNotFoundError(f'Preprocessor {preprocessor_name} is not installed') except Exception as exception: raise type(exception)( f'Failed to apply preprocessor {preprocessor_name}: {exception}' ) def preprocess_and_make(self, target: str) -> str: '''Apply preprocessors required by the selected backend and defined in the config file, then run the ``make`` method. :param target: Output format: pdf, docx, html, etc. :returns: Result as returned by the ``make`` method ''' src_path = self.project_path / self.config['src_dir'] copytree(src_path, self.working_dir) preprocessors = ( *self.required_preprocessors_before, *self.config.get('preprocessors', ()), *self.required_preprocessors_after, '_unescape' ) for preprocessor in preprocessors: self.apply_preprocessor(preprocessor) return self.make(target) def make(self, target: str) -> str: '''Make the output from the source. Must be implemented by every backend. :param target: Output format: pdf, docx, html, etc. :returns: Typically, the path to the output file, but in general any string ''' raise NotImplementedError PK!SUUfoliant/backends/pre.pyfrom shutil import copytree, rmtree from foliant.backends.base import BaseBackend class Backend(BaseBackend): '''Backend that just applies its preprocessors and returns a project that doesn't need any further preprocessing. ''' targets = ('pre',) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._pre_config = self.config.get('backend_config', {}).get('pre', {}) self._preprocessed_dir_name = f'{self._pre_config.get("slug", self.get_slug())}.pre' self.logger = self.logger.getChild('pre') self.logger.debug(f'Backend inited: {self.__dict__}') def make(self, target: str) -> str: rmtree(self._preprocessed_dir_name, ignore_errors=True) copytree(self.working_dir, self._preprocessed_dir_name) return self._preprocessed_dir_name PK!B]Diifoliant/cli/__init__.pyfrom time import time from logging import getLogger, FileHandler, Formatter from cliar import set_help from foliant import __version__ as foliant_version from foliant.utils import get_available_clis class Foliant(*get_available_clis().values()): '''Foliant is an all-in-one modular documentation authoring tool.''' @set_help({'version': 'show version and exit'}) def _root(self, version=False): # pylint: disable=no-self-use if version: print(f'Foliant v.{foliant_version}') else: self._parser.print_help() def entry_point(): Foliant().parse() PK!_5efoliant/cli/base.pyfrom time import time from logging import getLogger, FileHandler, Formatter from cliar import Cliar class BaseCli(Cliar): '''Base CLI. All CLI extensions must inherit from this one.''' def __init__(self): super().__init__() self.logger = getLogger('flt') handler = FileHandler(f'{int(time())}.log', delay=True) handler.setFormatter(Formatter('%(asctime)s | %(name)20s | %(levelname)8s | %(message)s')) self.logger.addHandler(handler) PK!Іfoliant/cli/make.py'''Main CLI repsonsible for the ``make`` command.''' from pathlib import Path from importlib import import_module from logging import DEBUG, WARNING from typing import List, Dict, Tuple from cliar import set_arg_map, set_metavars, set_help, ignore from prompt_toolkit import prompt from prompt_toolkit.completion import WordCompleter from prompt_toolkit.validation import Validator, ValidationError from foliant.config import Parser from foliant.utils import spinner, get_available_backends, tmp from foliant.cli.base import BaseCli class ConfigError(Exception): pass class BackendError(Exception): pass class BackendValidator(Validator): '''Validator for the interactive backend selection prompt.''' def __init__(self, available_backends: List[str]): super().__init__() self.available_backends = available_backends def validate(self, document): '''Check if the selected backend in installed.''' backend = document.text if backend not in self.available_backends: raise ValidationError( message=f'Backend {backend} not found. ' + f'Available backends are: {", ".join(self.available_backends)}.', cursor_position=0 ) class Cli(BaseCli): @staticmethod def validate_backend(backend: str, target: str) -> bool: '''Check that the specified backend exists and can build the specified target.''' available_backends = get_available_backends() if backend not in available_backends: raise BackendError( f'Backend {backend} not found. ' + f'Available backends are {", ".join(available_backends.keys())}.' ) if target not in available_backends[backend]: raise BackendError(f'Backend {backend} cannot make {target}.') return True @staticmethod def get_matching_backend(target: str, available_backends: Dict[str, Tuple[str]]) -> str: '''Get a matching backend for the specified target. If multiple backends match the specified target, prompt user.''' matching_backends = [ backend for backend, targets in available_backends.items() if target in targets ] if not matching_backends: raise BackendError(f'No backend available for {target}.') if len(matching_backends) == 1: return matching_backends[0] try: return prompt( f'Please pick a backend from {matching_backends}: ', completer=WordCompleter(matching_backends), validator=BackendValidator(matching_backends) ) except KeyboardInterrupt: raise BackendError('No backend specified') @ignore def get_config(self, project_path: Path, config_file_name: str, debug=False) -> dict: with spinner('Parsing config', self.logger, debug): try: config = Parser(project_path, config_file_name, self.logger).parse() except FileNotFoundError as exception: config = None raise FileNotFoundError(f'{exception} not found') except Exception as exception: config = None raise type(exception)(f'Invalid config: {exception}') if config is None: raise ConfigError('Config parsing failed.') return config @set_arg_map({'backend': 'with', 'project_path': 'path', 'config_file_name': 'config'}) @set_metavars({'target': 'TARGET', 'backend': 'BACKEND'}) @set_help( { 'target': 'Target format: pdf, docx, html, etc.', 'backend': 'Backend to make the target with: Pandoc, MkDocs, etc.', 'project_path': 'Path to the Foliant project', 'config_file_name': 'Name of config file of the Foliant project', 'keep_tmp': 'Keep the tmp directory after the build.', 'debug': 'Log all events during build. If not set, only warnings and errors are logged.' } ) def make( self, target, backend='', project_path=Path('.'), config_file_name='foliant.yml', keep_tmp=False, debug=False ): '''Make TARGET with BACKEND.''' # pylint: disable=too-many-arguments self.logger.setLevel(DEBUG if debug else WARNING) self.logger.info('Build started.') available_backends = get_available_backends() try: if backend: self.validate_backend(backend, target) else: backend = self.get_matching_backend(target, available_backends) config = self.get_config(project_path, config_file_name, debug) except (BackendError, ConfigError) as exception: self.logger.critical(str(exception)) exit(str(exception)) context = { 'project_path': project_path, 'config': config, 'target': target, 'backend': backend } backend_module = import_module(f'foliant.backends.{backend}') self.logger.debug(f'Imported backend {backend_module}.') with tmp(project_path/config['tmp_dir'], keep_tmp): result = backend_module.Backend( context, self.logger, debug ).preprocess_and_make(target) if result: self.logger.info(f'Result: {result}') print('─' * 20) print(f'Result: {result}') else: self.logger.critical('No result returned by backend') exit('No result returned by backend') return None return result PK!foliant/config/__init__.pyfrom foliant.utils import get_available_config_parsers from foliant.config import include, path class Parser(*get_available_config_parsers().values()): pass PK!0OTfoliant/config/base.pyfrom pathlib import Path from logging import Logger from yaml import load class BaseParser(object): _defaults = { 'src_dir': Path('./src'), 'tmp_dir': Path('./__folianttmp__') } def __init__(self, project_path: Path, config_file_name: str, logger: Logger): self.project_path = project_path self.config_path = project_path / config_file_name self.logger = logger.getChild('cfg') def parse(self) -> dict: '''Parse the config file into a Python dict. Missing values are populated with defaults, paths are converted to ``pathlib.Paths``. :param project_path: Project path :param config_file_name: Config file name (almost certainly ``foliant.yml``) :returns: Dictionary representing the YAML tree ''' self.logger.info('Parsing started.') with open(self.config_path, encoding='utf8') as config_file: config = {**self._defaults, **load(config_file)} config['src_dir'] = Path(config['src_dir']).expanduser() config['tmp_dir'] = Path(config['tmp_dir']).expanduser() self.logger.info(f'Parsing completed.') self.logger.debug(f'Config: {config}') return config PK!Mfoliant/config/include.pyfrom pathlib import Path from yaml import load, add_constructor from foliant.config.base import BaseParser class Parser(BaseParser): def _resolve_include_tag(self, _, node) -> str: '''Replace value after ``!include`` with the content of the referenced file.''' parts = node.value.split('#') if len(parts) == 1: path = Path(parts[0]).expanduser() with open(self.project_path/path) as include_file: return load(include_file) elif len(parts) == 2: path, section = Path(parts[0]).expanduser(), parts[1] with open(self.project_path/path) as include_file: return load(include_file)[section] else: raise ValueError('Invalid include syntax') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) add_constructor('!include', self._resolve_include_tag) PK!^KKfoliant/config/path.pyfrom pathlib import Path from yaml import add_constructor from foliant.config.base import BaseParser class Parser(BaseParser): def _resolve_path_tag(self, _, node) -> str: '''Convert value after ``!path`` to an existing, absolute Posix path. Relative paths are relative to the project path. ''' path = Path(node.value).expanduser() return (self.project_path/path).resolve(strict=True).as_posix() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) add_constructor('!path', self._resolve_path_tag) PK!!foliant/preprocessors/__init__.pyPK!Zu"foliant/preprocessors/_unescape.pyimport re from foliant.preprocessors.base import BasePreprocessor class Preprocessor(BasePreprocessor): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.pattern = re.compile( rf'\<\<(?P[^\<\>\s]+)' + rf'(\s(?P[^\<\>]*))?\>' + rf'(?P.*?)\<\/(?P=tag)\>', flags=re.DOTALL ) self.logger = self.logger.getChild('_unescape') self.logger.debug(f'Preprocessor inited: {self.__dict__}') def process_escaped_tags(self, content: str) -> str: '''Unescape escaped tags, i.e. remove leading ``<`` from each tag definition. :param content: Markdown content :returns: Markdown content without escaped tags ''' def _sub(escaped_tag): tag_group = escaped_tag.group(0) result = tag_group[1:] self.logger.debug( f'Replacing {tag_group} with {result}' ) return result return self.pattern.sub(_sub, content) def apply(self): for markdown_file_path in self.working_dir.rglob('*.md'): self.logger.debug(f'Processing the file: {markdown_file_path}') with open(markdown_file_path, encoding='utf8') as markdown_file: content = markdown_file.read() with open(markdown_file_path, 'w', encoding='utf8') as markdown_file: markdown_file.write(self.process_escaped_tags(content)) PK!_Mǒ foliant/preprocessors/base.pyimport re from logging import Logger from distutils.util import strtobool from typing import Dict OptionValue = int or float or bool or str class BasePreprocessor(object): '''Base preprocessor. All preprocessors must inherit from this one.''' defaults = {} tags = () @staticmethod def get_options(options_string: str) -> Dict[str, OptionValue]: '''Get a dictionary of typed options from a string with XML attributes. :param options_string: String of XML attributes :returns: Dictionary with options ''' if not options_string: return {} def _cast_value(value: str) -> OptionValue: '''Attempt to convert a string to integer, float, or boolean. If nothing matches, return the original string. :param value: String to convert :returns: Converted value or original string ''' value = value.strip('"') try: return int(value) except ValueError: try: return float(value) except ValueError: try: return bool(strtobool(value)) except ValueError: return value option_pattern = re.compile( r'(?P[A-Za-z_:][0-9A-Za-z_:\-\.]*)="(?P.+?)"', flags=re.DOTALL ) return { option.group('key'): _cast_value(option.group('value')) for option in option_pattern.finditer(options_string) } def __init__(self, context: dict, logger: Logger, options={}): # pylint: disable=dangerous-default-value self.project_path = context['project_path'] self.config = context['config'] self.context = context self.logger = logger self.options = {**self.defaults, **options} self.working_dir = self.project_path / self.config['tmp_dir'] if self.tags: self.pattern = re.compile( rf'(?{"|".join(self.tags)})' + rf'(\s(?P[^\<\>]*))?\>' + rf'(?P.*?)\<\/(?P=tag)\>', flags=re.DOTALL ) def apply(self): '''Run the preprocessor against the project directory. Must be implemented by every preprocessor. ''' raise NotImplementedError PK!cfoliant/utils.py'''Various utilities used here and there in the Foliant code.''' from contextlib import contextmanager from pkgutil import iter_modules from importlib import import_module from shutil import rmtree from traceback import format_exc from pathlib import Path from logging import Logger from typing import Dict, Tuple, Type, Set def get_available_tags() -> Set[str]: '''Extract ``tags`` attribute values from installed ``foliant.preprocessors.*.Preprocessor`` classes. :returns: Set of tags ''' preprocessors_module = import_module('foliant.preprocessors') result = set() for importer, modname, _ in iter_modules(preprocessors_module.__path__): if modname == 'base': continue result.update(importer.find_module(modname).load_module(modname).Preprocessor.tags) return result def get_available_config_parsers() -> Dict[str, Type]: '''Get the names of the installed ``foliant.config`` submodules and the corresponding ``Parser`` classes. Used for construction of the Foliant config parser, which is a class that inherits from all ``foliant.config.*.Parser`` classes. :returns: Dictionary with submodule names as keys as classes as values ''' config_module = import_module('foliant.config') result = {} for importer, modname, _ in iter_modules(config_module.__path__): if modname == 'base': continue result[modname] = importer.find_module(modname).load_module(modname).Parser return result def get_available_clis() -> Dict[str, Type]: '''Get the names of the installed ``foliant.cli`` submodules and the corresponding ``Cli`` classes. Used for construction of the Foliant CLI, which is a class that inherits from all ``foliant.cli.*.Cli`` classes. :returns: Dictionary with submodule names as keys as classes as values ''' cli_module = import_module('foliant.cli') result = {} for importer, modname, _ in iter_modules(cli_module.__path__): if modname == 'base': continue result[modname] = importer.find_module(modname).load_module(modname).Cli return result def get_available_backends() -> Dict[str, Tuple[str]]: '''Get the names of the installed ``foliant.backends`` submodules and the corresponding ``Backend.targets`` tuples. Used in the interactive backend selection prompt to list the available backends and to check if the selected target can be made with the selected backend. :returns: Dictionary of submodule names as keys and target tuples as values ''' backends_module = import_module('foliant.backends') result = {} for importer, modname, _ in iter_modules(backends_module.__path__): if modname == 'base': continue result[modname] = importer.find_module(modname).load_module(modname).Backend.targets return result @contextmanager def spinner(text: str, logger: Logger, debug=False): '''Decoration for long running processes. :param text: Message to output :param logger: Logger to capture the error if it occurs ''' # pylint: disable=broad-except try: logger.info(text) print(text) yield print('Done\n') except Exception as exception: exception_traceback = format_exc() logger.error(exception_traceback) if debug: print(exception_traceback) else: print(str(exception)) @contextmanager def tmp(tmp_path: Path, keep_tmp=False): '''Clean up tmp directory before and after running a code block. :param tmp_path: Path to the tmp directory :param keep_tmp: If ``True``, skip the cleanup ''' rmtree(tmp_path, ignore_errors=True) yield if not keep_tmp: rmtree(tmp_path, ignore_errors=True) PK!H ;/3(foliant-1.0.7.dist-info/entry_points.txtN+I/N.,()JL+z9Vy%Ey%\\PK!H STTfoliant-1.0.7.dist-info/WHEEL 1 0 нR. \I$ơ7.ZON `h6oi14m,b4>4ɛpK>X;baP>PK!H%k foliant-1.0.7.dist-info/METADATAWn7}WL -W6FƵ `1K2]nId|]3np)Ep!u.HѯX1$J9.[o ?bWMY ùNB> 3OR4pNppPP>!weQPj#9{K"Csq $B3fʕcT"+Rڤv _>Oڌ᥮ CI.*h7#Y UDwZH]?e.Vh9~r?vRkLI ^]Ӻ6ze{˅љe D5X诣ϵ?ϕucH  GȇAo WӺ+Gh|tj%ʂLpKcL\R*j:`wusijJahcN޺#c_]_.N_tUq+YԫZŋ..MMog(Ĉ; Qr%:^lJwS#$Sc$TSfrc?g,E*ҕSL\pꠐJ7D´I$Xvc5'hE~O.a< V81 .\e4#͡ӇeJ)*(~ڑm&4M octJ}[{:9N],K^xF"DZB:*)TB- Գ*`ssNIXZYD82߇S)(a)VhZeP0 tSaVc״d{&9BFu`.j?1lڊՂ ,7A{m`r>Dת~n GS_Xitum4,0o 'g$ 36pbpGKhp7< B*/V u0ܻvރğ%%bl!vR>P %PUYv՚۬([^Xs=8* nРm9S7 &X[ Uz :*mc8t0oHFy^NZȖb.ལ DD=_'Z0G\iGu)mSoZ.Dh4>hе# J :4 d!Uc8glʐKܭa~" Q_Y<`W*b2>Àq`0fjwƴs`Âv68?,*iI%ECv+(j|3+|7X -;8El7KLC.$gb"GY\[ߎc0B> ڭ' FУ?Z;ځ^exxPCɝ=p\u斱PK!HC1Afoliant-1.0.7.dist-info/RECORD˖Jy: p(AE ROrJ:{m]&8ߺ@E[˛;(,2Yس-[oY&F|I\e0ER*!!cw$0`gNՀݶ3AxJP] m`ƫ#Vu)czPE?,G럽D@/ۢJ*L-aЅCE}uYXZISuu[5<_댜g:_KR]"Q:Cj*y&8E5ILY.SSD*jlN+\+lxI*& Y@d峠YM.h8PUtLJhAͳKV tVQ9t 3ztm ! BR#h|` >elQ9 -1BP# (X0+%xfUUwACzy%5ĽcX[?ck]ߞ3 ; 9]k!}r48QZ 5cnpNЀ~UGקbC2U$=8>r}{Á?=AE [q"ޠ8urٛ(]ɜ8  %7MxKK ~ ~+{#ʵ6+-w ,VL{Q_e0"%芲y_./{V[tLndtD#S"U\i%oVf_č$LJוG#`n7sװ,-gEPK!>)wfoliant/__init__.pyPK!Gfoliant/backends/__init__.pyPK!;ϔ_ foliant/backends/base.pyPK!SUUfoliant/backends/pre.pyPK!B]Dii%foliant/cli/__init__.pyPK!_5efoliant/cli/base.pyPK!Іfoliant/cli/make.pyPK!-foliant/config/__init__.pyPK!0OT.foliant/config/base.pyPK!M3foliant/config/include.pyPK!^KK7foliant/config/path.pyPK!!:foliant/preprocessors/__init__.pyPK!Zu"Y:foliant/preprocessors/_unescape.pyPK!_Mǒ @foliant/preprocessors/base.pyPK!cLJfoliant/utils.pyPK!H ;/3(Yfoliant-1.0.7.dist-info/entry_points.txtPK!H STTZfoliant-1.0.7.dist-info/WHEELPK!H%k Zfoliant-1.0.7.dist-info/METADATAPK!HC1A`foliant-1.0.7.dist-info/RECORDPKWbd