diff --git a/.gitignore b/.gitignore index b9bab86..f57529d 100644 --- a/.gitignore +++ b/.gitignore @@ -245,3 +245,4 @@ ENV/ # End of https://www.gitignore.io/api/node,linux,python,jetbrains,archlinuxpackages out/ +.mypy_cache diff --git a/circle.yml b/circle.yml index 9ee76d2..55fc5f3 100644 --- a/circle.yml +++ b/circle.yml @@ -18,9 +18,12 @@ dependencies: test: - post: + override: + - npm test - flake8 md_pdf/ --ignore=E128,E501 + - mypy --ignore-missing-imports md_pdf - safety check - bandit -r md_pdf/ - - mdp --update-csl + - mdp --update-csl -vvv - cd test-files/ && mdp -vvv + - scripts/run-tests.sh diff --git a/dev-requirements.txt b/dev-requirements.txt index 6364219..97fd9cd 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,3 +1,6 @@ bandit==1.4.0 +coverage==4.4.1 flake8==3.3.0 +freezegun==0.3.9 +mypy==0.511 safety==0.5.1 diff --git a/md_pdf/args.py b/md_pdf/args.py index 3b42416..a576fc5 100644 --- a/md_pdf/args.py +++ b/md_pdf/args.py @@ -2,10 +2,10 @@ import argparse from md_pdf import __version__ -def parse_args(): +def parse_args(args=None): parser = argparse.ArgumentParser() parser.add_argument("-v", "--verbose", help="Set verbosity level (repeat argument)", action="count", default=0) parser.add_argument("--update-csl", help="Update CSL files", action="store_true") parser.add_argument("--version", action="version", version="%(prog)s {}".format(__version__)) parser.add_help = True - return parser.parse_args() + return parser.parse_args(args=args) diff --git a/md_pdf/build/__init__.py b/md_pdf/build/__init__.py index 27075f1..6b94e00 100644 --- a/md_pdf/build/__init__.py +++ b/md_pdf/build/__init__.py @@ -11,11 +11,11 @@ import time logger = logging.getLogger(__file__) -def build(config): +def build(config: dict): logger.debug("Starting Build...") start_time = time.time() data = read_files(os.path.abspath(config['input'])) - doc = build_document(data, config.get('bibliography'), config.get('context')) + doc = build_document(data, config.get('bibliography')) parsed_template = parse_template(doc, config) if 'html' in config['output_formats']: output_html(parsed_template, os.path.abspath(config['output_dir'])) diff --git a/md_pdf/build/content.py b/md_pdf/build/content.py index 8433485..fe06b82 100644 --- a/md_pdf/build/content.py +++ b/md_pdf/build/content.py @@ -19,7 +19,7 @@ def fix_references_title(content, config): return soup.prettify() -def add_base_tag(doc, config): +def make_images_relative(doc, config): logger.debug("Adding Base Tag...") soup = BeautifulSoup(doc, 'html.parser') for img in soup.findAll('img'): @@ -32,6 +32,8 @@ def add_base_tag(doc, config): def add_body_class(doc, config): logger.debug("Adding Body Class...") soup = BeautifulSoup(doc, 'html.parser') + if not soup.body: + return doc soup.body['class'] = 'content' return soup.prettify() @@ -46,7 +48,7 @@ def parse_template(doc, config): parsed_doc = doc for parser in [ fix_references_title, - add_base_tag, + make_images_relative, add_body_class, ]: parsed_doc = parser(parsed_doc, config) diff --git a/md_pdf/build/context.py b/md_pdf/build/context.py index 61506ff..958caec 100644 --- a/md_pdf/build/context.py +++ b/md_pdf/build/context.py @@ -19,11 +19,14 @@ EXTRA_CONTEXT = { } -def get_context(config, content): +def get_context(config: dict, content: str) -> dict: config = config.copy() - context = config['context'].copy() - del config['context'] - context = dict( + if 'context' in config: + context = config['context'].copy() + del config['context'] + else: + context = {} + merged_context = dict( config, **EXTRA_CONTEXT, **context, @@ -32,11 +35,11 @@ def get_context(config, content): } ) if config.get('show_word_count'): - context['word_count'] = word_count(get_plain_text(content)) + merged_context['word_count'] = word_count(get_plain_text(content)) if config.get('submission_date'): if type(config['submission_date']) in [datetime.date, datetime.datetime, datetime.time]: submission_date = config['submission_date'] else: submission_date = parser.parse(config['submission_date']) - context['submission_date'] = submission_date.strftime(DATE_FORMAT) - return context + merged_context['submission_date'] = submission_date.strftime(DATE_FORMAT) + return merged_context diff --git a/md_pdf/build/jinja.py b/md_pdf/build/jinja.py index d5f0687..7d1e115 100644 --- a/md_pdf/build/jinja.py +++ b/md_pdf/build/jinja.py @@ -10,7 +10,6 @@ def render_content(content, context): 'jinja2.ext.with_', 'jinja2.ext.loopcontrols' ] - ) template = env.from_string(content) return template.render(**context) diff --git a/md_pdf/build/md.py b/md_pdf/build/md.py index 93ba6a0..d08729d 100644 --- a/md_pdf/build/md.py +++ b/md_pdf/build/md.py @@ -1,12 +1,13 @@ import glob +from typing import Generator, List -def get_files_content(filenames): +def get_files_content(filenames: List[str]) -> Generator[str, None, None]: for filename in filenames: with open(filename) as f: yield f.read() -def read_files(files_glob): +def read_files(files_glob: str) -> str: filenames = sorted(glob.glob(files_glob)) return '\n'.join(list(get_files_content(filenames))) diff --git a/md_pdf/build/pandoc.py b/md_pdf/build/pandoc.py index 7bd86a2..7fd4362 100644 --- a/md_pdf/build/pandoc.py +++ b/md_pdf/build/pandoc.py @@ -6,13 +6,13 @@ import logging logger = logging.getLogger(__file__) -def output_html(html, out_dir): +def output_html(html: str, out_dir: str): logger.info("Outputting HTML...") with open(os.path.join(out_dir, 'output.html'), 'w') as f: f.write(html) -def build_document(files_content, bibliography, context): +def build_document(files_content: str, bibliography: dict) -> str: args = [ '-s', ] diff --git a/md_pdf/build/pdf.py b/md_pdf/build/pdf.py index cd1d9a7..9fe4612 100644 --- a/md_pdf/build/pdf.py +++ b/md_pdf/build/pdf.py @@ -1,6 +1,7 @@ import pdfkit from md_pdf.consts import TEMPLATES_DIR, STATIC_DIR from md_pdf.build.templates import FILE_NAME_FORMAT +from md_pdf.exceptions import PDFRenderException import os import logging @@ -12,8 +13,8 @@ DEFAULT_MARGIN_VERTICAL = '1.5cm' DEFAULT_MARGIN_HORIZONTAL = '2.5cm' STYLE_FILE = os.path.join(STATIC_DIR, 'style.css') -HEADER_FILE = os.path.join(TEMPLATES_DIR, 'header.html') -FOOTER_FILE = os.path.join(TEMPLATES_DIR, 'footer.html') +HEADER_FILE = FILE_NAME_FORMAT.format('header') +FOOTER_FILE = FILE_NAME_FORMAT.format('footer') TOC_OPTIONS = { 'xsl-style-sheet': os.path.join(TEMPLATES_DIR, 'toc.xsl') @@ -32,23 +33,28 @@ PDF_OPTIONS = { } -def export_pdf(content, config): +def export_pdf(content: str, config: dict) -> dict: if logger.getEffectiveLevel() > logging.DEBUG: PDF_OPTIONS['quiet'] = "" PDF_OPTIONS['title'] = config.get('title', 'Output') - PDF_OPTIONS['replace'] = [(key, str(value)) for key, value in config['context'].items()] + context = config.get('context', {}) - PDF_OPTIONS['margin-top'] = config['context'].get('margin_vertical', DEFAULT_MARGIN_VERTICAL) - PDF_OPTIONS['margin-bottom'] = config['context'].get('margin_vertical', DEFAULT_MARGIN_VERTICAL) - PDF_OPTIONS['margin-left'] = config['context'].get('margin_horizontal', DEFAULT_MARGIN_HORIZONTAL) - PDF_OPTIONS['margin-right'] = config['context'].get('margin_horizontal', DEFAULT_MARGIN_HORIZONTAL) + PDF_OPTIONS['replace'] = [(key, str(value)) for key, value in context.items()] + PDF_OPTIONS['margin-top'] = context.get('margin_vertical', DEFAULT_MARGIN_VERTICAL) + PDF_OPTIONS['margin-bottom'] = context.get('margin_vertical', DEFAULT_MARGIN_VERTICAL) + PDF_OPTIONS['margin-left'] = context.get('margin_horizontal', DEFAULT_MARGIN_HORIZONTAL) + PDF_OPTIONS['margin-right'] = context.get('margin_horizontal', DEFAULT_MARGIN_HORIZONTAL) logger.info("Rendering PDF...") - return pdfkit.from_string( - content, - os.path.join(os.path.abspath(config['output_dir']), 'output.pdf'), - options=PDF_OPTIONS, - cover=FILE_NAME_FORMAT.format('cover'), - toc=TOC_OPTIONS if config.get('toc') else {}, - cover_first=True - ) + try: + pdfkit.from_string( + content, + os.path.join(os.path.abspath(config['output_dir']), 'output.pdf'), + options=PDF_OPTIONS, + cover=FILE_NAME_FORMAT.format('cover'), + toc=TOC_OPTIONS if config.get('toc') else {}, + cover_first=True + ) + except OSError as e: + raise PDFRenderException('Failed to render PDF. ' + str(e)) + return PDF_OPTIONS # mostly for testing diff --git a/md_pdf/build/templates.py b/md_pdf/build/templates.py index e0cf1f0..e62966c 100644 --- a/md_pdf/build/templates.py +++ b/md_pdf/build/templates.py @@ -11,7 +11,7 @@ FILE_NAME_FORMAT = os.path.join(TEMPLATES_DIR, "{}.html") TEMPLATE_FORMAT = os.path.join(INTERNAL_TEMPLATES_DIR, "{}-template.html") -def render_page(input_file, output_file, context): +def render_page(input_file: str, output_file: str, context: dict) -> str: logger.debug("Rendering {}...".format(os.path.splitext(os.path.basename(output_file))[0].title())) with open(input_file) as f: content = render_content(f.read(), context) @@ -20,7 +20,7 @@ def render_page(input_file, output_file, context): return content -def render_templates(config, content): +def render_templates(config: dict, content: str): context = get_context(config, content) for template in [ 'cover', diff --git a/md_pdf/config/read.py b/md_pdf/config/read.py index 89633a3..6950fe1 100644 --- a/md_pdf/config/read.py +++ b/md_pdf/config/read.py @@ -1,12 +1,11 @@ import yaml -import os from md_pdf.consts import CONFIG_FILE from md_pdf.exceptions import ConfigValidationException -def load_config(): +def load_config(location: str=CONFIG_FILE) -> str: try: - with open(os.path.join(CONFIG_FILE)) as f: + with open(location) as f: return yaml.safe_load(f) except FileNotFoundError: raise ConfigValidationException("Can't find config file at {}".format(CONFIG_FILE)) diff --git a/md_pdf/config/validate.py b/md_pdf/config/validate.py index e90f5ba..299bb1d 100644 --- a/md_pdf/config/validate.py +++ b/md_pdf/config/validate.py @@ -36,6 +36,9 @@ def test_input(config): abs_input = os.path.abspath(config['input']) if len(glob.glob(abs_input)) == 0: raise ConfigValidationException("No files found at {}".format(abs_input)) + for file in glob.iglob(abs_input): + if not os.path.isfile(file): + raise ConfigValidationException("Input must be a glob of files") def validate_bibliography(config): @@ -49,9 +52,8 @@ def validate_bibliography(config): abs_bibliography = os.path.abspath(config['bibliography']['references']) if not os.path.isfile(abs_bibliography): raise ConfigValidationException("Invalid bibliography path: '{}'".format(abs_bibliography)) - if 'csl' in config['bibliography']: - if not os.path.isfile(os.path.join(CSL_DIR, "{}.csl".format(config['bibliography']['csl']))): - raise ConfigValidationException("Could not find CSL '{}'".format(config.bibliography.csl)) + if not os.path.isfile(os.path.join(CSL_DIR, "{}.csl".format(config['bibliography']['csl']))): + raise ConfigValidationException("Could not find CSL '{}' in {}".format(config['bibliography']['csl'], CSL_DIR)) def validate_context(config): @@ -63,11 +65,11 @@ def validate_context(config): non_str_keys = [key for key in config['context'].keys() if type(key) != str] if non_str_keys: - raise ConfigValidationException("Context keys must be strings. Non-strings: {}".format(", ".join(non_str_keys))) + raise ConfigValidationException("Context keys must be strings. Non-strings: {}".format(non_str_keys)) invalid_values = [value for value in config['context'].values() if type(value) in [list, dict]] if invalid_values: - raise ConfigValidationException("Context keys must be plain. Invalid values: {}".format(", ".join(invalid_values))) + raise ConfigValidationException("Context keys must be plain. Invalid values: {}".format(invalid_values)) def validate_toc(config): @@ -92,7 +94,7 @@ def validate_submission_date(config): try: parser.parse(config['submission_date']) except ValueError: - raise ConfigValidationException("Invalid Submission Date format") + raise ConfigValidationException("Invalid Submission Date format {}".format(config['submission_date'])) def validate_config(config): diff --git a/md_pdf/csl.py b/md_pdf/csl.py index 87f739c..d922855 100644 --- a/md_pdf/csl.py +++ b/md_pdf/csl.py @@ -39,7 +39,7 @@ def download_csl(): with open(download_location, 'rb') as downloaded_file: with zipfile.ZipFile(downloaded_file) as csl_zip: member_list = csl_zip.namelist() - logger.info("Extracting CSL...") + logger.info("Extracting CSL to {}".format(ASSETS_DIR)) bar.start(max_value=len(member_list)) for i, member in enumerate(member_list): diff --git a/md_pdf/exceptions.py b/md_pdf/exceptions.py index a11a747..86dc882 100644 --- a/md_pdf/exceptions.py +++ b/md_pdf/exceptions.py @@ -8,3 +8,7 @@ class PrematureExit(BaseException): class ConfigValidationException(BaseException): pass + + +class PDFRenderException(BaseException): + pass diff --git a/md_pdf/utils.py b/md_pdf/utils.py index 101e639..33dc229 100644 --- a/md_pdf/utils.py +++ b/md_pdf/utils.py @@ -2,11 +2,13 @@ import shutil import os import logging from bs4 import BeautifulSoup +from typing import List + logger = logging.getLogger(__file__) -def remove_dir(dir): +def remove_dir(dir: str): logger.debug("Removing directory {}.".format(dir)) try: shutil.rmtree(dir) @@ -15,16 +17,18 @@ def remove_dir(dir): pass -def safe_list_get(l, idx, default): +def safe_list_get(l: List, idx: int, default): try: return l[idx] except IndexError: return default -def get_plain_text(content): +def get_plain_text(content: str) -> str: soup = BeautifulSoup(content, 'html.parser') body = soup.find('body') + if body is None: + return content try: body.find('h1', class_='references-title').extract() body.find('div', class_='references').extract() diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh new file mode 100755 index 0000000..01f5863 --- /dev/null +++ b/scripts/run-tests.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +coverage run --source=md_pdf -m unittest -v $@ + +coverage report +coverage html diff --git a/setup.py b/setup.py index f0d671a..8e4f270 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ setup( "word-count==0.1.0" ], setup_requires=['setuptools_scm'], - packages=find_packages(), + packages=find_packages(exclude=['tests']), include_package_data=True, zip_safe=False, entry_points=""" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fe1f9b2 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,64 @@ +import unittest +import os +from md_pdf.consts import TEMPLATES_DIR, STATIC_DIR +from md_pdf.build.templates import FILE_NAME_FORMAT +from bs4 import BeautifulSoup + + +class BaseTestCase(unittest.TestCase): + def setUp(self): + super().setUp() + self.BASE_VALID_CONFIG = { + 'title': 'test title', + 'input': 'test-files/*.md', + 'output_formats': [ + 'html', 'pdf' + ], + 'output_dir': 'out/', + + } + + def parse_html(self, html): + return BeautifulSoup(html, 'html.parser') + + def remove_file(self, file): + try: + os.remove(file) + except OSError: + pass + + def touch_file(self, file): + open(file, 'w').close() + + def create_fake_templates(self): + for template in [ + 'header', + 'footer', + 'cover' + ]: + self.touch_file(FILE_NAME_FORMAT.format(template)) + + def extend_config(self, *args): + base_config = self.BASE_VALID_CONFIG.copy() + for arg in args: + base_config = dict(base_config, **arg) + return base_config + + def delete_templates(self): + for template in [ + 'header.html', + 'footer.html', + 'cover.html', + 'toc.xsl', + ]: + self.remove_file(os.path.join(TEMPLATES_DIR, template)) + + def tearDown(self): + self.delete_templates() + self.remove_file(os.path.join(STATIC_DIR, 'style.css')) + + def call_to_args(self, call): + args = tuple(call.call_args)[0] + kwargs = tuple(call.call_args)[1] + return args, kwargs + diff --git a/tests/test_args.py b/tests/test_args.py new file mode 100644 index 0000000..359eba0 --- /dev/null +++ b/tests/test_args.py @@ -0,0 +1,22 @@ +from tests import BaseTestCase +from md_pdf.args import parse_args + + +class ArgParserTestCase(BaseTestCase): + def test_allows_no_args(self): + args = parse_args([]) + self.assertFalse(args.update_csl) + self.assertEqual(args.verbose, 0) + + def test_adds_verbosity(self): + args = parse_args(['-v']) + self.assertEqual(args.verbose, 1) + + def test_chains_verbosity(self): + for i in range(1, 10): + args = parse_args(['-' + ('v' * i)]) + self.assertEqual(args.verbose, i) + + def test_csl_update(self): + args = parse_args(['--update-csl']) + self.assertTrue(args.update_csl) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..15181ff --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,239 @@ +from tests import BaseTestCase +from md_pdf.config import read, validate +from md_pdf.exceptions import ConfigValidationException +from md_pdf.consts import CSL_DIR +from md_pdf.utils import remove_dir +import os +import datetime +from unittest import skipIf + + +class ReadConfigTestCase(BaseTestCase): + def test_reads_config(self): + config = read.load_config(os.path.abspath('test-files/mdp.yml')) + self.assertIsInstance(config, dict) + + def test_throws_at_missing_config(self): + with self.assertRaises(ConfigValidationException): + read.load_config(os.path.abspath('non-existant')) + + +class ConfigValidatorBaseTestCase(BaseTestCase): + def setUp(self): + super().setUp() + validate.validate_config(self.BASE_VALID_CONFIG) + + +class ValidateSubmissionDateTestCase(ConfigValidatorBaseTestCase): + def test_transparent_to_datetime(self): + self.BASE_VALID_CONFIG['submission_date'] = datetime.datetime.now() + validate.validate_config(self.BASE_VALID_CONFIG) + + def test_transparent_to_date(self): + self.BASE_VALID_CONFIG['submission_date'] = datetime.datetime.now().date() + validate.validate_config(self.BASE_VALID_CONFIG) + + def test_transparent_to_time(self): + self.BASE_VALID_CONFIG['submission_date'] = datetime.datetime.now().time() + validate.validate_config(self.BASE_VALID_CONFIG) + + def test_valid_date_format(self): + for date in [ + '2017-01-01', + '01-01-2017', + '1st jan 2017', + '1 january 2017' + ]: + self.BASE_VALID_CONFIG['submission_date'] = date + validate.validate_config(self.BASE_VALID_CONFIG) + + def test_invalid_date_format(self): + for date in [ + 'nothing', + '31-02-2017', + '01-2017-01', + '1st smarch 2017' + ]: + self.BASE_VALID_CONFIG['submission_date'] = date + with self.assertRaises(ConfigValidationException): + validate.validate_config(self.BASE_VALID_CONFIG) + + +class ValidateWordcountTestCase(ConfigValidatorBaseTestCase): + def test_boolean_values_only(self): + self.BASE_VALID_CONFIG['show_word_count'] = True + validate.validate_config(self.BASE_VALID_CONFIG) + self.BASE_VALID_CONFIG['show_word_count'] = False + validate.validate_config(self.BASE_VALID_CONFIG) + + def test_invalid_values(self): + for value in [ + 'True', + 'False', + 0, + 1 + ]: + self.BASE_VALID_CONFIG['show_word_count'] = value + with self.assertRaises(ConfigValidationException): + validate.validate_config(self.BASE_VALID_CONFIG) + + + +class ValidateTOCTestCase(ConfigValidatorBaseTestCase): + def test_boolean_values_only(self): + self.BASE_VALID_CONFIG['toc'] = True + validate.validate_config(self.BASE_VALID_CONFIG) + self.BASE_VALID_CONFIG['toc'] = False + validate.validate_config(self.BASE_VALID_CONFIG) + + def test_invalid_values(self): + for value in [ + 'True', + 'False', + 0, + 1 + ]: + self.BASE_VALID_CONFIG['toc'] = value + with self.assertRaises(ConfigValidationException): + validate.validate_config(self.BASE_VALID_CONFIG) + + +class ValidateContextTestCase(ConfigValidatorBaseTestCase): + def test_should_be_dict(self): + for value in [ + [], + 'dict', + 1 + ]: + self.BASE_VALID_CONFIG['context'] = value + with self.assertRaises(ConfigValidationException): + validate.validate_config(self.BASE_VALID_CONFIG) + self.BASE_VALID_CONFIG['context'] = {} + validate.validate_config(self.BASE_VALID_CONFIG) + + def test_non_string_keys(self): + self.BASE_VALID_CONFIG['context'] = { + 1: 'test' + } + with self.assertRaises(ConfigValidationException): + validate.validate_config(self.BASE_VALID_CONFIG) + + def test_string_keys(self): + self.BASE_VALID_CONFIG['context'] = { + '1': 'test' + } + validate.validate_config(self.BASE_VALID_CONFIG) + + def test_valid_values(self): + for value in [ + 'test', + 1 + ]: + self.BASE_VALID_CONFIG['context'] = { + 'test': value + } + validate.validate_config(self.BASE_VALID_CONFIG) + + def test_invalid_values(self): + for value in [ + [], + {} + ]: + self.BASE_VALID_CONFIG['context'] = { + 'test': value + } + with self.assertRaises(ConfigValidationException): + validate.validate_config(self.BASE_VALID_CONFIG) + + +class ValidateBibliographyTestCase(ConfigValidatorBaseTestCase): + def test_contains_all_keys(self): + self.BASE_VALID_CONFIG['bibliography'] = {} + with self.assertRaises(ConfigValidationException): + validate.validate_config(self.BASE_VALID_CONFIG) + self.BASE_VALID_CONFIG['bibliography'] = { + 'references': 'test' + } + with self.assertRaises(ConfigValidationException): + validate.validate_config(self.BASE_VALID_CONFIG) + self.BASE_VALID_CONFIG['bibliography'] = { + 'csl': 'test' + } + with self.assertRaises(ConfigValidationException): + validate.validate_config(self.BASE_VALID_CONFIG) + + def test_valid_references(self): + self.BASE_VALID_CONFIG['bibliography'] = { + 'references': 'non-existant', + 'csl': 'chicago-author-date' + } + with self.assertRaises(ConfigValidationException): + validate.validate_config(self.BASE_VALID_CONFIG) + self.BASE_VALID_CONFIG['bibliography']['references'] = 'test-files/bib.yaml' + validate.validate_config(self.BASE_VALID_CONFIG) + + @skipIf(not os.path.isdir(CSL_DIR), 'Missing CSL Files') + def test_valid_csl(self): + self.BASE_VALID_CONFIG['bibliography'] = { + 'references': 'test-files/bib.yaml', + 'csl': 'nothing' + } + with self.assertRaises(ConfigValidationException): + validate.validate_config(self.BASE_VALID_CONFIG) + self.BASE_VALID_CONFIG['bibliography']['csl'] = 'chicago-author-date' + validate.validate_config(self.BASE_VALID_CONFIG) + + +class ValidateInputTestCase(ConfigValidatorBaseTestCase): + def test_no_matches(self): + self.BASE_VALID_CONFIG['input'] = 'test-files/*.mp4' + with self.assertRaises(ConfigValidationException): + validate.validate_config(self.BASE_VALID_CONFIG) + + def test_invalid_glob(self): + self.BASE_VALID_CONFIG['input'] = 'test-files/' + with self.assertRaises(ConfigValidationException): + validate.validate_config(self.BASE_VALID_CONFIG) + + +class ValidateOutputTestCase(ConfigValidatorBaseTestCase): + def tearDown(self): + super().tearDown() + remove_dir('test-files/test') + + def test_creates_output_dir(self): + self.assertFalse(os.path.isdir('test-files/test')) + self.BASE_VALID_CONFIG['output_dir'] = 'test-files/test' + validate.validate_config(self.BASE_VALID_CONFIG) + self.assertTrue(os.path.isdir('test-files/test')) + + def test_valid_output_formats(self): + for format in [ + 'html', + 'pdf' + ]: + self.BASE_VALID_CONFIG['output_formats'] = [format] + validate.validate_config(self.BASE_VALID_CONFIG) + + def test_invalid_output_formats(self): + for format in [ + 'text', + 'foo' + ]: + self.BASE_VALID_CONFIG['output_formats'] = [format] + with self.assertRaises(ConfigValidationException): + validate.validate_config(self.BASE_VALID_CONFIG) + + def test_part_invalid_format(self): + self.BASE_VALID_CONFIG['output_formats'] = ['html', 'foo'] + with self.assertRaises(ConfigValidationException): + validate.validate_config(self.BASE_VALID_CONFIG) + + +class ValidateRequiredKeysTestCase(ConfigValidatorBaseTestCase): + def test_required_keys(self): + for key in validate.REQUIRED_KEYS: + base_config = self.BASE_VALID_CONFIG.copy() + del base_config[key] + with self.assertRaises(ConfigValidationException): + validate.validate_config(base_config) diff --git a/tests/test_consts.py b/tests/test_consts.py new file mode 100644 index 0000000..f31f696 --- /dev/null +++ b/tests/test_consts.py @@ -0,0 +1,98 @@ +from tests import BaseTestCase +from md_pdf import consts +from unittest import skipIf +import os +from urllib.parse import urlparse +import datetime +from freezegun import freeze_time + + +class ConstsTestCase(BaseTestCase): + def setUp(self): + super().setUp() + self.this_dir = os.path.dirname(__file__) + self.project_root = os.path.normpath(os.path.join(self.this_dir, '..')) + + def test_project_dir(self): + self.assertEqual( + consts.PROJECT_DIR, + os.path.normpath(os.path.join(self.this_dir, '..', 'md_pdf')) + ) + self.assertIn(consts.WORKING_DIR, consts.PROJECT_DIR) + + def test_working_dir(self): + self.assertEqual(consts.WORKING_DIR, self.project_root) + + @skipIf('APPDATA' not in os.environ, 'not on windows') + def test_windows_asset_dir(self): + self.assertIn(os.environ['APPDATA'], consts.ASSETS_DIR) + + @skipIf('HOME' not in os.environ, 'not on windows') + def test_asset_dir(self): + self.assertEqual(consts.ASSETS_DIR, os.path.expanduser('~/.mdp')) + + def test_csl_dir(self): + self.assertIn(consts.ASSETS_DIR, consts.CSL_DIR) + self.assertIn('csl', consts.CSL_DIR) + + def test_templates_dir(self): + self.assertIn(consts.ASSETS_DIR, consts.TEMPLATES_DIR) + self.assertIn('templates', consts.TEMPLATES_DIR) + + def test_static_dir(self): + self.assertIn(consts.ASSETS_DIR, consts.STATIC_DIR) + self.assertIn('static', consts.STATIC_DIR) + + def test_internal_asset_dir(self): + self.assertIn(consts.PROJECT_DIR, consts.INTERNAL_ASSETS_DIR) + self.assertIn('assets', consts.INTERNAL_ASSETS_DIR) + + def test_internal_static_dir(self): + self.assertIn(consts.PROJECT_DIR, consts.INTERNAL_STATIC_DIR) + self.assertIn('static', consts.INTERNAL_STATIC_DIR) + + def test_internal_templates_dir(self): + self.assertIn(consts.PROJECT_DIR, consts.INTERNAL_TEMPLATES_DIR) + self.assertIn('templates', consts.INTERNAL_TEMPLATES_DIR) + + def test_config_file(self): + self.assertIn(consts.WORKING_DIR, consts.CONFIG_FILE) + self.assertIn('mdp.yml', consts.CONFIG_FILE) + + def test_csl_download_link(self): + url = urlparse(consts.CSL_DOWNLOAD_LINK) + self.assertEqual(url.netloc, 'github.com') + self.assertTrue(url.path.endswith('master.zip')) + self.assertIn('citation-style-language/styles', url.path) + + @freeze_time('2017-01-01') + def test_date_format(self): + now = datetime.datetime.now() + self.assertEqual( + now.strftime(consts.DATE_FORMAT), + '01 January 2017' + ) + + @freeze_time('2017-01-01T12:34') + def test_time_format(self): + now = datetime.datetime.now() + self.assertEqual( + now.strftime(consts.TIME_FORMAT), + '12:34' + ) + + @freeze_time('2017-01-01T12:34') + def test_time_format(self): + now = datetime.datetime.now() + self.assertEqual( + now.strftime(consts.DATETIME_FORMAT), + '01 January 2017 12:34' + ) + + def test_dirs_exist(self): + self.assertTrue(os.path.isdir(consts.ASSETS_DIR)) + self.assertTrue(os.path.isdir(consts.TEMPLATES_DIR)) + self.assertTrue(os.path.isdir(consts.STATIC_DIR)) + + + diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 0000000..b5eb9ad --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,77 @@ +from tests import BaseTestCase +from md_pdf.build.context import get_context, EXTRA_CONTEXT +from md_pdf import consts +from datetime import datetime +from md_pdf import __version__ +import os + + +class ExtraContextTestCase(BaseTestCase): + def test_directories(self): + self.assertEqual(EXTRA_CONTEXT['templates_dir'], consts.TEMPLATES_DIR) + self.assertEqual(EXTRA_CONTEXT['static_dir'], consts.STATIC_DIR) + self.assertEqual(EXTRA_CONTEXT['internal_templates_dir'], consts.INTERNAL_TEMPLATES_DIR) + self.assertEqual(EXTRA_CONTEXT['internal_static_dir'], consts.INTERNAL_STATIC_DIR) + + def test_datetimes(self): + now = datetime.now() + self.assertEqual(EXTRA_CONTEXT['date'], now.strftime(consts.DATE_FORMAT)) + self.assertEqual(EXTRA_CONTEXT['time'], now.strftime(consts.TIME_FORMAT)) + self.assertEqual(EXTRA_CONTEXT['datetime'], now.strftime(consts.DATETIME_FORMAT)) + + def test_version(self): + self.assertEqual(EXTRA_CONTEXT['mdp_version'], __version__) + + +class ContextTestCase(BaseTestCase): + def test_context_contains_extra(self): + context = get_context(self.BASE_VALID_CONFIG, 'test') + for key in EXTRA_CONTEXT.keys(): + self.assertIn(key, context) + + def test_context_contains_context(self): + config = self.extend_config({ + 'context': { + '1': '2' + } + }) + context = get_context(config, 'test') + self.assertEqual(context['1'], '2') + self.assertNotIn('context', context) + + def test_context_contains_config(self): + context = get_context(self.BASE_VALID_CONFIG, 'test') + for key in self.BASE_VALID_CONFIG.keys(): + self.assertIn(key, context) + + def test_has_output_dir(self): + context = get_context(self.BASE_VALID_CONFIG, 'test') + self.assertEqual(context['output_dir'], os.path.abspath(self.BASE_VALID_CONFIG['output_dir'])) + + def test_word_count(self): + config = self.extend_config({ + 'show_word_count': True + }) + context = get_context(config, 'testy test test') + self.assertEqual(context['word_count'], 3) + + def test_transparent_datetime_for_submission_date(self): + for value in [ + datetime.now().date(), + datetime.now().time(), + datetime.now() + ]: + config = self.extend_config({ + 'submission_date': value + }) + context = get_context(config, 'test') + self.assertEqual(context['submission_date'], value.strftime(consts.DATE_FORMAT)) + + def test_date_format(self): + config = self.extend_config({ + 'submission_date': '2017-01-01' + }) + context = get_context(config, 'test') + self.assertEqual(context['submission_date'], '01 January 2017') + + diff --git a/tests/test_jinja.py b/tests/test_jinja.py new file mode 100644 index 0000000..2c4af8f --- /dev/null +++ b/tests/test_jinja.py @@ -0,0 +1,27 @@ +from tests import BaseTestCase +from md_pdf.build.jinja import render_content + + +class ContentRendererTestCase(BaseTestCase): + def test_renders_template(self): + html = 'test {{ test }}' + output = render_content(html, self.extend_config({ + 'test': 'content' + })) + self.assertEqual(output, 'test content') + + def test_changes_nothing(self): + html = 'test test' + output = render_content(html, self.extend_config({ + 'test': 'content' + })) + self.assertEqual(output, html) + + def test_with_block(self): + html = """ + {% with test = 'test' %} + {{ test }} thing + {% endwith %} + """ + output = render_content(html, self.BASE_VALID_CONFIG) + self.assertIn('test thing', output) diff --git a/tests/test_pandoc.py b/tests/test_pandoc.py new file mode 100644 index 0000000..df933a7 --- /dev/null +++ b/tests/test_pandoc.py @@ -0,0 +1,67 @@ +from tests import BaseTestCase +from md_pdf.build.pandoc import output_html, build_document +from md_pdf.build.md import read_files +from md_pdf.utils import remove_dir +import os +import glob + + +class ReadFileTestCase(BaseTestCase): + file_glob = 'test-files/*.md' + + def test_reads_files(self): + files = read_files(self.file_glob) + self.assertNotEqual(files, '') + + def test_contains_all_files(self): + files = read_files(self.file_glob) + for file in glob.iglob(self.file_glob): + with open(file) as f: + self.assertIn(f.read(), files) + + +class OutputHTMLTestCase(BaseTestCase): + output_dir = 'test-output' + + def setUp(self): + super().setUp() + os.makedirs(self.output_dir) + + def tearDown(self): + super().tearDown() + remove_dir(self.output_dir) + + def test_outputs_file(self): + self.assertFalse(os.path.isfile(os.path.join(self.output_dir, 'output.html'))) + output_html('test', self.output_dir) + self.assertTrue(os.path.isfile(os.path.join(self.output_dir, 'output.html'))) + + def test_outputs_correct_data(self): + output_html('test', self.output_dir) + with open(os.path.join(self.output_dir, 'output.html')) as f: + self.assertEqual(f.read(), 'test') + + +class BuildDocumentTestCase(BaseTestCase): + def test_parses_markdown(self): + doc = build_document('# test', None) + self.assertIn('

test

', doc) + + def converts_nothing_if_plain(self): + doc = build_document('test', None) + self.assertIn('test', doc) + + def test_bibliography(self): + bibliography = { + 'references': 'test-files/bib.yaml', + 'csl': 'chicago-author-date' + } + with open('test-files/2-pandoc.md') as f: + test_content = f.read() + doc = build_document(test_content, bibliography) + self.assertIn( + 'Doe (2005, 2006, 30; see also Doe and Roe 2007) says blah.', + doc + ) + self.assertIn('Doe, John. 2005.', doc) + diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..41acc67 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,50 @@ +from tests import BaseTestCase +from md_pdf.build import content +import os + + +class FixReferencesTitleTestCase(BaseTestCase): + def test_adds_reference_title(self): + html = '
' + output = content.fix_references_title(html, self.BASE_VALID_CONFIG) + self.assertIn('references-title', output) + self.assertIn('References', output) + + def test_doesnt_modify_if_no_references(self): + html = 'test text' + output = content.fix_references_title(html, self.BASE_VALID_CONFIG) + self.assertNotIn('references-title', output) + self.assertNotIn('References', output) + + +class RelativeImageTestCase(BaseTestCase): + def test_makes_image_relative(self): + html = '' + output = self.parse_html(content.make_images_relative(html, self.BASE_VALID_CONFIG)) + self.assertEqual(output.find('img').attrs['src'], os.path.abspath('test-files/test-image.png')) + + def test_leaves_remote_images(self): + html = '' + output = self.parse_html(content.make_images_relative(html, self.BASE_VALID_CONFIG)) + self.assertEqual(output.find('img').attrs['src'], 'http://example.com/image.png') + + +class AddBodyClassTestCase(BaseTestCase): + def test_adds_class(self): + html = '' + output = self.parse_html(content.add_body_class(html, self.BASE_VALID_CONFIG)) + self.assertEqual(output.body.attrs['class'], ['content']) + + def test_doesnt_change(self): + html = 'test content' + output = content.add_body_class(html, self.BASE_VALID_CONFIG) + self.assertEqual(output, html) + + +class RenderTemplateTestCase(BaseTestCase): + def test_renders_template(self): + html = 'test {{ test }}' + output = content.render_template(html, self.extend_config({ + 'test': 'content' + })) + self.assertEqual(output, 'test content') diff --git a/tests/test_pdf.py b/tests/test_pdf.py new file mode 100644 index 0000000..597d4f7 --- /dev/null +++ b/tests/test_pdf.py @@ -0,0 +1,88 @@ +from tests import BaseTestCase +import os +from md_pdf.build.pdf import export_pdf, TOC_OPTIONS, DEFAULT_MARGIN_VERTICAL, DEFAULT_MARGIN_HORIZONTAL +from unittest.mock import patch +from md_pdf.build.templates import FILE_NAME_FORMAT +from md_pdf.exceptions import PDFRenderException +import pdfkit + + +class PDFRendererTestCase(BaseTestCase): + def setUp(self): + super().setUp() + self.content = 'test content' + self.output_file_path = os.path.join(self.BASE_VALID_CONFIG['output_dir'], 'output.pdf') + self.assertFalse(os.path.isfile(self.output_file_path)) + self.create_fake_templates() + + def tearDown(self): + super().tearDown() + self.remove_file(self.output_file_path) + + def test_renders(self): + export_pdf(self.content, self.BASE_VALID_CONFIG) + self.assertTrue(os.path.isfile(self.output_file_path)) + + def test_title(self): + context = export_pdf(self.content, self.BASE_VALID_CONFIG) + self.assertEqual(context['title'], self.BASE_VALID_CONFIG['title']) + + def test_replace_context(self): + self.BASE_VALID_CONFIG['context'] = { + '1': 2, + '2': '1' + } + context = export_pdf(self.content, self.BASE_VALID_CONFIG) + self.assertEqual(context['replace'], [ + ('1', '2'), + ('2', '1'), + ]) + + def test_default_margins(self): + context = export_pdf(self.content, self.BASE_VALID_CONFIG) + self.assertEqual(context['margin-top'], DEFAULT_MARGIN_VERTICAL) + self.assertEqual(context['margin-bottom'], DEFAULT_MARGIN_VERTICAL) + self.assertEqual(context['margin-left'], DEFAULT_MARGIN_HORIZONTAL) + self.assertEqual(context['margin-right'], DEFAULT_MARGIN_HORIZONTAL) + + def test_override_margin(self): + self.BASE_VALID_CONFIG['context'] = { + 'margin_vertical': '1cm', + 'margin_horizontal': '2cm' + } + context = export_pdf(self.content, self.BASE_VALID_CONFIG) + self.assertEqual(context['margin-top'], '1cm') + self.assertEqual(context['margin-bottom'], '1cm') + self.assertEqual(context['margin-left'], '2cm') + self.assertEqual(context['margin-right'], '2cm') + + @patch.object(pdfkit, 'from_string') + def test_kit_call(self, pdf_render): + context = export_pdf(self.content, self.BASE_VALID_CONFIG) + self.assertTrue(pdf_render.called) + args, kwargs = self.call_to_args(pdf_render) + self.assertEqual(args[0], self.content) + self.assertIn(self.output_file_path, args[1]) + self.assertEqual(kwargs['options'], context) + self.assertTrue(kwargs['cover_first']) + self.assertEqual(kwargs['cover'], FILE_NAME_FORMAT.format('cover')) + self.assertEqual(kwargs['toc'], {}) + + @patch.object(pdfkit, 'from_string') + def test_toc(self, pdf_render): + self.BASE_VALID_CONFIG['toc'] = True + export_pdf(self.content, self.BASE_VALID_CONFIG) + args, kwargs = self.call_to_args(pdf_render) + self.assertEqual(kwargs['toc'], TOC_OPTIONS) + + def test_fails_if_missing_templates(self): + self.remove_file(FILE_NAME_FORMAT.format('cover')) + with self.assertRaises(PDFRenderException): + export_pdf(self.content, self.BASE_VALID_CONFIG) + + def test_files_exist(self): + context = export_pdf(self.content, self.BASE_VALID_CONFIG) + self.assertTrue(os.path.isfile(context['header-html'])) + self.assertTrue(os.path.isfile(context['footer-html'])) + self.assertEqual(context['header-html'], FILE_NAME_FORMAT.format('header')) + self.assertEqual(context['footer-html'], FILE_NAME_FORMAT.format('footer'))