├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── Vagrantfile ├── examples ├── .markdownlint ├── example1.md └── example2.md ├── pymarkdownlint ├── __init__.py ├── cli.py ├── config.py ├── filefinder.py ├── lint.py ├── options.py ├── rules.py └── tests │ ├── __init__.py │ ├── base.py │ ├── samples │ ├── badmarkdownlint │ ├── good.md │ ├── ignored-sample1.txt │ ├── ignored-sample2.txt │ ├── markdownlint │ ├── sample1.md │ └── sample2.md │ ├── test_cli.py │ ├── test_config.py │ ├── test_filefinder.py │ ├── test_lint.py │ ├── test_options.py │ └── test_rules.py ├── requirements.txt ├── run_tests.sh ├── setup.py └── test-requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Virtualenv 2 | .venv 3 | 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | AUTHORS 14 | ChangeLog 15 | .Python 16 | env/ 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *,cover 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | 59 | # Sphinx documentation 60 | docs/_build/ 61 | 62 | # PyBuilder 63 | target/ 64 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | install: 5 | - "pip install -r requirements.txt" 6 | - "pip install -r test-requirements.txt" 7 | script: "./run_tests.sh && ./run_tests.sh --pep8" 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Joris Roovers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | exclude Vagrantfile 4 | exclude *.yml *.sh *.txt 5 | recursive-exclude examples * 6 | recursive-exclude pymarkdownlint/tests * -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pymarkdownlint (inactive) 2 | 3 | **NOTE: PyMarkDownLint is no longer under active development.** 4 | 5 | [![Build Status](https://travis-ci.org/jorisroovers/pymarkdownlint.svg?branch=master)] 6 | (https://travis-ci.org/jorisroovers/pymarkdownlint) 7 | [![PyPi Package](https://img.shields.io/pypi/v/pymarkdownlint.png)] 8 | (https://pypi.python.org/pypi/pymarkdownlint) 9 | 10 | Markdown linter written in python. Inspired by [mivok/markdownlint](https://github.com/mivok/markdownlint). 11 | 12 | Get started by running: 13 | ```bash 14 | markdownlint examples/ # lint all files in a directory 15 | markdownlint examples/example1.md # lint a single file 16 | markdownlint examples/example1.md # lint a single file 17 | ``` 18 | NOTE: The returned exit code equals the number of errors found. 19 | 20 | Other commands and variations: 21 | 22 | ```bash 23 | Usage: markdownlint [OPTIONS] PATH 24 | 25 | Markdown lint tool, checks your markdown for styling issues 26 | 27 | Options: 28 | --config PATH Config file location (default: .markdownlint). 29 | --list-files List markdown files in given path and exit. 30 | --ignore TEXT Ignore rules (comma-separated by id or name). 31 | --version Show the version and exit. 32 | --help Show this message and exit. 33 | ``` 34 | 35 | You can modify pymarkdownlint's behavior by specifying a config file like so: 36 | ```bash 37 | markdownlint --config myconfigfile 38 | ``` 39 | By default, markdownlint will look for an **optional** ```.markdownlint``` file for configuration. 40 | 41 | ## Config file ## 42 | 43 | ``` 44 | [general] 45 | # rules can be ignored by name or by id 46 | ignore=max-line-length, R3 47 | ``` 48 | 49 | ## Supported Rules ## 50 | 51 | ID | Name | Description 52 | ------|---------------------|---------------------------------------------------- 53 | R1 | max-line-length | Line length must be < 80 chars. 54 | R2 | trailing-whitespace | Line cannot have trailing whitespace (space or tab) 55 | R3 | hard-tabs | Line contains hard tab characters (\t) 56 | 57 | 58 | ## Development ## 59 | 60 | To run tests: 61 | ```bash 62 | ./run_tests.sh # run unit tests and print test coverage 63 | ./run_tests.sh --no-coverage # run unit tests without test coverage 64 | ./run_tests.sh --pep8 # pep8 checks 65 | ./run_tests.sh --stats # print some code stats 66 | ``` 67 | 68 | There is a Vagrantfile in this repository that can be used for development. 69 | ```bash 70 | vagrant up 71 | vagrant ssh 72 | ``` 73 | 74 | ## Wishlist ## 75 | - More rules! 76 | - Better output handling with verbosity levels 77 | - Ignore/exclude files CLI options 78 | - Rule specific configuration in config files 79 | - Auto doc generation based on rules 80 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | VAGRANTFILE_API_VERSION = "2" 5 | 6 | INSTALL_DEPS=<> /home/vagrant/.bashrc && #{INSTALL_DEPS}" 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /examples/.markdownlint: -------------------------------------------------------------------------------- 1 | [general] 2 | # rules can be ignored by name or by id 3 | ignore=max-line-length,R3 4 | -------------------------------------------------------------------------------- /examples/example1.md: -------------------------------------------------------------------------------- 1 | # Welcome 2 | 3 | This is an example file to showcase pymarkdownlint. When running pymarkdownlint against this file, you will 4 | notice that it produces a number of errors. 5 | 6 | To do so, do the following: 7 | ``` 8 | pymarkdownlint examples/example.md 9 | ``` -------------------------------------------------------------------------------- /examples/example2.md: -------------------------------------------------------------------------------- 1 | ## Does a duck's quack echo? 2 | 3 | From the [wikipedia article on echos](https://en.wikipedia.org/wiki/Echo#Duck.27s_quack): 4 | 5 | "A duck's quack doesn't echo" is a much-quoted scientific myth. The truth is that a duck's quack does, in fact, echo; 6 | however, it may be difficult to hear. 7 | 8 | This myth was first debunked by the Acoustics Research Centre at the University of Salford in 2003 as part of the 9 | British Association's Festival of Science. It was also featured in one of the earlier episodes of the popular 10 | Discovery Channel television show MythBusters. The actual reason for the myth is that a quack is a quiet 11 | and fading sound which produces faint echoes. The myth inspired the title of the British Sky1 TV 12 | show "Duck Quacks Don't Echo". -------------------------------------------------------------------------------- /pymarkdownlint/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.3.1" 2 | -------------------------------------------------------------------------------- /pymarkdownlint/cli.py: -------------------------------------------------------------------------------- 1 | import pymarkdownlint 2 | from pymarkdownlint.filefinder import MarkdownFileFinder 3 | from pymarkdownlint.lint import MarkdownLinter 4 | from pymarkdownlint.config import LintConfig 5 | import os 6 | import click 7 | 8 | DEFAULT_CONFIG_FILE = ".markdownlint" 9 | 10 | 11 | def echo_files(files): 12 | for f in files: 13 | click.echo(f) 14 | exit(0) 15 | 16 | 17 | def get_lint_config(config_path=None): 18 | """ Tries loading the config from the given path. If no path is specified, the default config path 19 | is tried, and if that is not specified, we the default config is returned. """ 20 | # config path specified 21 | if config_path: 22 | config = LintConfig.load_from_file(config_path) 23 | click.echo("Using config from {0}".format(config_path)) 24 | # default config path 25 | elif os.path.exists(DEFAULT_CONFIG_FILE): 26 | config = LintConfig.load_from_file(DEFAULT_CONFIG_FILE) 27 | click.echo("Using config from {0}".format(DEFAULT_CONFIG_FILE)) 28 | # no config file 29 | else: 30 | config = LintConfig() 31 | 32 | return config 33 | 34 | 35 | @click.command() 36 | @click.option('--config', type=click.Path(exists=True), 37 | help="Config file location (default: {0}).".format(DEFAULT_CONFIG_FILE)) 38 | @click.option('--list-files', is_flag=True, help="List markdown files in given path and exit.") 39 | @click.option('--ignore', default="", help="Ignore rules (comma-separated by id or name).") 40 | @click.argument('path', type=click.Path(exists=True)) 41 | @click.version_option(version=pymarkdownlint.__version__) 42 | def cli(list_files, config, ignore, path): 43 | """ Markdown lint tool, checks your markdown for styling issues """ 44 | files = MarkdownFileFinder.find_files(path) 45 | if list_files: 46 | echo_files(files) 47 | 48 | lint_config = get_lint_config(config) 49 | lint_config.apply_on_csv_string(ignore, lint_config.disable_rule) 50 | 51 | linter = MarkdownLinter(lint_config) 52 | error_count = linter.lint_files(files) 53 | exit(error_count) 54 | 55 | 56 | if __name__ == "__main__": 57 | cli() 58 | -------------------------------------------------------------------------------- /pymarkdownlint/config.py: -------------------------------------------------------------------------------- 1 | from pymarkdownlint import rules 2 | import ConfigParser 3 | from collections import OrderedDict 4 | 5 | import os 6 | 7 | 8 | class LintConfigError(Exception): 9 | pass 10 | 11 | 12 | class LintConfig(object): 13 | """ Class representing markdownlint configuration """ 14 | default_rule_classes = [rules.MaxLineLengthRule, rules.TrailingWhiteSpace, rules.HardTab] 15 | 16 | def __init__(self): 17 | # Use an ordered dict so that the order in which rules are applied is always the same 18 | self._rules = OrderedDict([(rule_cls.id, rule_cls()) for rule_cls in self.default_rule_classes]) 19 | 20 | @property 21 | def rules(self): 22 | return self._rules.values() 23 | 24 | def disable_rule_by_id(self, rule_id): 25 | del self._rules[rule_id] 26 | 27 | def get_rule_by_name_or_id(self, rule_id_or_name): 28 | # try finding rule by id 29 | rule = self._rules.get(rule_id_or_name) 30 | # if not found, try finding rule by name 31 | if not rule: 32 | rule = next((rule for rule in self.rules if rule.name == rule_id_or_name), None) 33 | return rule 34 | 35 | def disable_rule(self, rule_id_or_name): 36 | rule = self.get_rule_by_name_or_id(rule_id_or_name) 37 | if rule: 38 | self.disable_rule_by_id(rule.id) 39 | 40 | @staticmethod 41 | def apply_on_csv_string(rules_str, func): 42 | """ Splits a given string by comma, trims whitespace on the resulting strings and applies a given ```func``` to 43 | each item. """ 44 | splitted = rules_str.split(",") 45 | for str in splitted: 46 | func(str.strip()) 47 | 48 | @staticmethod 49 | def load_from_file(filename): 50 | if not os.path.exists(filename): 51 | raise LintConfigError("Invalid file path: {0}".format(filename)) 52 | config = LintConfig() 53 | try: 54 | parser = ConfigParser.ConfigParser() 55 | parser.read(filename) 56 | LintConfig._parse_general_section(parser, config) 57 | except ConfigParser.Error as e: 58 | raise LintConfigError("Error during config file parsing: {0}".format(e.message)) 59 | 60 | return config 61 | 62 | @staticmethod 63 | def _parse_general_section(parser, config): 64 | if parser.has_section('general'): 65 | ignore = parser.get('general', 'ignore', "") 66 | LintConfig.apply_on_csv_string(ignore, config.disable_rule) 67 | -------------------------------------------------------------------------------- /pymarkdownlint/filefinder.py: -------------------------------------------------------------------------------- 1 | import fnmatch 2 | import os 3 | 4 | 5 | class MarkdownFileFinder(object): 6 | @staticmethod 7 | def find_files(path, filter="*.md"): 8 | """ Finds files with an (optional) given extension in a given path. """ 9 | if os.path.isfile(path): 10 | return [path] 11 | 12 | if os.path.isdir(path): 13 | matches = [] 14 | for root, dirnames, filenames in os.walk(path): 15 | for filename in fnmatch.filter(filenames, filter): 16 | matches.append(os.path.join(root, filename)) 17 | return matches 18 | -------------------------------------------------------------------------------- /pymarkdownlint/lint.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from pymarkdownlint import rules 3 | 4 | 5 | class MarkdownLinter(object): 6 | def __init__(self, config): 7 | self.config = config 8 | 9 | @property 10 | def line_rules(self): 11 | return [rule for rule in self.config.rules if isinstance(rule, rules.LineRule)] 12 | 13 | def _apply_line_rules(self, markdown_string): 14 | """ Iterates over the lines in a given markdown string and applies all the enabled line rules to each line """ 15 | all_violations = [] 16 | lines = markdown_string.split("\n") 17 | line_rules = self.line_rules 18 | line_nr = 1 19 | ignoring = False 20 | for line in lines: 21 | if ignoring: 22 | if line.strip() == '': 23 | ignoring = False 24 | else: 25 | if line.strip() == '': 26 | ignoring = True 27 | continue 28 | 29 | for rule in line_rules: 30 | violation = rule.validate(line) 31 | if violation: 32 | violation.line_nr = line_nr 33 | all_violations.append(violation) 34 | line_nr += 1 35 | return all_violations 36 | 37 | def lint(self, markdown_string): 38 | all_violations = [] 39 | all_violations.extend(self._apply_line_rules(markdown_string)) 40 | return all_violations 41 | 42 | def lint_files(self, files): 43 | """ Lints a list of files. 44 | :param files: list of files to lint 45 | :return: a list of violations found in the files 46 | """ 47 | all_violations = [] 48 | for filename in files: 49 | with open(filename, 'r') as f: 50 | content = f.read() 51 | violations = self.lint(content) 52 | all_violations.extend(violations) 53 | for e in violations: 54 | print("{0}:{1}: {2} {3}".format(filename, e.line_nr, e.rule_id, e.message)) 55 | return len(all_violations) 56 | -------------------------------------------------------------------------------- /pymarkdownlint/options.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | 3 | 4 | class RuleOptionError(Exception): 5 | pass 6 | 7 | 8 | class RuleOption(object): 9 | def __init__(self, name, value, description): 10 | self.name = name 11 | self.value = value 12 | self.description = description 13 | 14 | @abstractmethod 15 | def set(self, value): 16 | """ Validates and sets the option's value """ 17 | pass 18 | 19 | 20 | class IntOption(RuleOption): 21 | def __init__(self, name, value, description, allow_negative=False): 22 | super(IntOption, self).__init__(name, value, description) 23 | self.allow_negative = allow_negative 24 | 25 | def raise_exception(self, value): 26 | if self.allow_negative: 27 | error_msg = "Option '{0}' must be an integer (current value: {1})".format(self.name, value) 28 | else: 29 | error_msg = "Option '{0}' must be a positive integer (current value: {1})".format(self.name, value) 30 | raise RuleOptionError(error_msg) 31 | 32 | def set(self, value): 33 | try: 34 | self.value = int(value) 35 | except ValueError: 36 | self.raise_exception(value) 37 | 38 | if not self.allow_negative and self.value < 0: 39 | self.raise_exception(value) 40 | -------------------------------------------------------------------------------- /pymarkdownlint/rules.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABCMeta 2 | from pymarkdownlint.options import IntOption 3 | 4 | import re 5 | 6 | 7 | class Rule(object): 8 | """ Class representing markdown rules. """ 9 | options_spec = [] 10 | id = [] 11 | name = "" 12 | __metaclass__ = ABCMeta 13 | 14 | def __init__(self, opts={}): 15 | self.options = {} 16 | for op_spec in self.options_spec: 17 | self.options[op_spec.name] = op_spec 18 | actual_option = opts.get(op_spec.name) 19 | if actual_option: 20 | self.options[op_spec.name].set(actual_option) 21 | 22 | def __eq__(self, other): 23 | return self.id == other.id and self.name == other.name 24 | 25 | @abstractmethod 26 | def validate(self): 27 | pass 28 | 29 | 30 | class FileRule(Rule): 31 | """ Class representing rules that act on an entire file """ 32 | pass 33 | 34 | 35 | class LineRule(Rule): 36 | """ Class representing rules that act on a line by line basis """ 37 | pass 38 | 39 | 40 | class RuleViolation(object): 41 | def __init__(self, rule_id, message, line_nr=None): 42 | self.rule_id = rule_id 43 | self.line_nr = line_nr 44 | self.message = message 45 | 46 | def __eq__(self, other): 47 | return self.rule_id == other.rule_id and self.message == other.message and self.line_nr == other.line_nr 48 | 49 | def __str__(self): 50 | return "{0}: {1} {2}".format(self.line_nr, self.rule_id, self.message) 51 | 52 | def __repr__(self): 53 | return self.__str__() 54 | 55 | 56 | class MaxLineLengthRule(LineRule): 57 | name = "max-line-length" 58 | id = "R1" 59 | options_spec = [IntOption('line-length', 80, "Max line length")] 60 | 61 | def validate(self, line): 62 | max_length = self.options['line-length'].value 63 | if len(line) > max_length: 64 | return RuleViolation(self.id, "Line exceeds max length ({0}>{1})".format(len(line), max_length)) 65 | 66 | 67 | class TrailingWhiteSpace(LineRule): 68 | name = "trailing-whitespace" 69 | id = "R2" 70 | 71 | def validate(self, line): 72 | pattern = re.compile(r"\s$") 73 | if pattern.search(line): 74 | return RuleViolation(self.id, "Line has trailing whitespace") 75 | 76 | 77 | class HardTab(LineRule): 78 | name = "hard-tab" 79 | id = "R3" 80 | 81 | def validate(self, line): 82 | if "\t" in line: 83 | return RuleViolation(self.id, "Line contains hard tab characters (\\t)") 84 | -------------------------------------------------------------------------------- /pymarkdownlint/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorisroovers/pymarkdownlint/c1044e25e18afd78b3fda8fd9b00a4f67cfbbc65/pymarkdownlint/tests/__init__.py -------------------------------------------------------------------------------- /pymarkdownlint/tests/base.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import os 4 | 5 | 6 | class BaseTestCase(TestCase): 7 | @staticmethod 8 | def get_sample_path(filename=""): 9 | samples_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "samples") 10 | return os.path.join(samples_dir, filename) 11 | -------------------------------------------------------------------------------- /pymarkdownlint/tests/samples/badmarkdownlint: -------------------------------------------------------------------------------- 1 | ignore=max-line-length, R3 2 | -------------------------------------------------------------------------------- /pymarkdownlint/tests/samples/good.md: -------------------------------------------------------------------------------- 1 | # Good markdown 2 | 3 | This is a markdownfile without issues. 4 | 5 | -------------------------------------------------------------------------------- /pymarkdownlint/tests/samples/ignored-sample1.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorisroovers/pymarkdownlint/c1044e25e18afd78b3fda8fd9b00a4f67cfbbc65/pymarkdownlint/tests/samples/ignored-sample1.txt -------------------------------------------------------------------------------- /pymarkdownlint/tests/samples/ignored-sample2.txt: -------------------------------------------------------------------------------- 1 | ] 2 | 3 | -------------------------------------------------------------------------------- /pymarkdownlint/tests/samples/markdownlint: -------------------------------------------------------------------------------- 1 | [general] 2 | ignore=max-line-length, R3 3 | -------------------------------------------------------------------------------- /pymarkdownlint/tests/samples/sample1.md: -------------------------------------------------------------------------------- 1 | # Header 2 | 3 | This is the first line of the file and it is meant to test a line that exeeds the maximum line length of 80 characters. 4 | This line has a trailing space. 5 | This line has a trailing tab. 6 | -------------------------------------------------------------------------------- /pymarkdownlint/tests/samples/sample2.md: -------------------------------------------------------------------------------- 1 | ## Header level 2 without a header 1 2 | -------------------------------------------------------------------------------- /pymarkdownlint/tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from pymarkdownlint.tests.base import BaseTestCase 2 | from pymarkdownlint import cli 3 | from pymarkdownlint import __version__ 4 | 5 | from click.testing import CliRunner 6 | 7 | 8 | class CLITests(BaseTestCase): 9 | def setUp(self): 10 | self.cli = CliRunner() 11 | 12 | def assert_output_line(self, output, index, sample_filename, error_line, expected_error): 13 | expected_output = "{0}:{1}: {2}".format(self.get_sample_path(sample_filename), error_line, expected_error) 14 | self.assertEqual(output.split("\n")[index], expected_output) 15 | 16 | def test_no_errors(self): 17 | result = self.cli.invoke(cli.cli, [self.get_sample_path("good.md")]) 18 | self.assertEqual(result.output, "") 19 | self.assertEqual(result.exit_code, 0) 20 | 21 | def test_version(self): 22 | result = self.cli.invoke(cli.cli, ["--version"]) 23 | self.assertEqual(result.output.split("\n")[0], "cli, version {0}".format(__version__)) 24 | 25 | def test_config_file(self): 26 | args = ["--config", self.get_sample_path("markdownlint"), self.get_sample_path("sample1.md")] 27 | result = self.cli.invoke(cli.cli, args) 28 | expected_string = "Using config from {0}".format(self.get_sample_path("markdownlint")) 29 | self.assertEqual(result.output.split("\n")[0], expected_string) 30 | self.assert_output_line(result.output, 1, "sample1.md", 4, "R2 Line has trailing whitespace") 31 | self.assert_output_line(result.output, 2, "sample1.md", 5, "R2 Line has trailing whitespace") 32 | self.assertEqual(result.exit_code, 2) 33 | 34 | def test_config_file_negative(self): 35 | args = ["--config", self.get_sample_path("foo"), self.get_sample_path("sample1.md")] 36 | result = self.cli.invoke(cli.cli, args) 37 | expected_string = "Error: Invalid value for \"--config\": Path \"{0}\" does not exist.".format( 38 | self.get_sample_path("foo")) 39 | self.assertEqual(result.output.split("\n")[2], expected_string) 40 | 41 | def test_violations(self): 42 | result = self.cli.invoke(cli.cli, [self.get_sample_path("sample1.md")]) 43 | self.assert_output_line(result.output, 0, "sample1.md", 3, "R1 Line exceeds max length (119>80)") 44 | self.assert_output_line(result.output, 1, "sample1.md", 4, "R2 Line has trailing whitespace") 45 | self.assert_output_line(result.output, 2, "sample1.md", 5, "R2 Line has trailing whitespace") 46 | self.assert_output_line(result.output, 3, "sample1.md", 5, "R3 Line contains hard tab characters (\\t)") 47 | self.assertEqual(result.exit_code, 4) 48 | 49 | def test_violations_with_ignored_rules(self): 50 | args = ["--ignore", "trailing-whitespace,R3", self.get_sample_path("sample1.md")] 51 | result = self.cli.invoke(cli.cli, args) 52 | self.assert_output_line(result.output, 0, "sample1.md", 3, "R1 Line exceeds max length (119>80)") 53 | self.assertEqual(result.exit_code, 1) 54 | 55 | def test_cli_list_files(self): 56 | result = self.cli.invoke(cli.cli, ["--list-files", self.get_sample_path()]) 57 | expected_string = "" 58 | expected_files = ["good.md", "sample1.md", "sample2.md"] 59 | for f in expected_files: 60 | expected_string += self.get_sample_path(f) + "\n" 61 | self.assertEqual(result.output, expected_string) 62 | self.assertEqual(result.exit_code, 0) 63 | -------------------------------------------------------------------------------- /pymarkdownlint/tests/test_config.py: -------------------------------------------------------------------------------- 1 | from pymarkdownlint.tests.base import BaseTestCase 2 | from pymarkdownlint.config import LintConfig, LintConfigError 3 | 4 | from pymarkdownlint import rules 5 | 6 | 7 | class LintConfigTests(BaseTestCase): 8 | def test_get_rule_by_name_or_id(self): 9 | config = LintConfig() 10 | 11 | # get by id 12 | expected = rules.MaxLineLengthRule() 13 | rule = config.get_rule_by_name_or_id('R1') 14 | self.assertEqual(rule, expected) 15 | 16 | # get by name 17 | expected = rules.TrailingWhiteSpace() 18 | rule = config.get_rule_by_name_or_id('trailing-whitespace') 19 | self.assertEqual(rule, expected) 20 | 21 | # get non-existing 22 | rule = config.get_rule_by_name_or_id('foo') 23 | self.assertIsNone(rule) 24 | 25 | def test_default_rules(self): 26 | config = LintConfig() 27 | expected_rule_classes = [rules.MaxLineLengthRule, rules.TrailingWhiteSpace, rules.HardTab] 28 | expected_rules = [rule_cls() for rule_cls in expected_rule_classes] 29 | self.assertEqual(config.default_rule_classes, expected_rule_classes) 30 | self.assertEqual(config.rules, expected_rules) 31 | 32 | def test_load_config_from_file(self): 33 | # regular config file load, no problems 34 | LintConfig.load_from_file(self.get_sample_path("markdownlint")) 35 | 36 | # bad config file load 37 | foo_path = self.get_sample_path("foo") 38 | with self.assertRaisesRegexp(LintConfigError, "Invalid file path: {0}".format(foo_path)): 39 | LintConfig.load_from_file(foo_path) 40 | 41 | # error during file parsing 42 | bad_markdowlint_path = self.get_sample_path("badmarkdownlint") 43 | expected_error_msg = "Error during config file parsing: File contains no section headers." 44 | with self.assertRaisesRegexp(LintConfigError, expected_error_msg): 45 | LintConfig.load_from_file(bad_markdowlint_path) 46 | -------------------------------------------------------------------------------- /pymarkdownlint/tests/test_filefinder.py: -------------------------------------------------------------------------------- 1 | from pymarkdownlint.tests.base import BaseTestCase 2 | 3 | from pymarkdownlint.filefinder import MarkdownFileFinder 4 | 5 | import os 6 | 7 | 8 | class FileFinderTests(BaseTestCase): 9 | def test_find_files(self): 10 | sample_dir = self.get_sample_path() 11 | good = os.path.join(sample_dir, "good.md") 12 | sample1 = os.path.join(sample_dir, "sample1.md") 13 | sample2 = os.path.join(sample_dir, "sample2.md") 14 | files = MarkdownFileFinder.find_files(sample_dir) 15 | self.assertListEqual(files, [good, sample1, sample2]) 16 | 17 | files = MarkdownFileFinder.find_files(sample_dir, filter="*.txt") 18 | txt1 = os.path.join(sample_dir, "ignored-sample1.txt") 19 | txt2 = os.path.join(sample_dir, "ignored-sample2.txt") 20 | self.assertListEqual(files, [txt1, txt2]) 21 | -------------------------------------------------------------------------------- /pymarkdownlint/tests/test_lint.py: -------------------------------------------------------------------------------- 1 | from pymarkdownlint.tests.base import BaseTestCase 2 | 3 | from pymarkdownlint.lint import MarkdownLinter 4 | from pymarkdownlint.rules import RuleViolation 5 | from pymarkdownlint.config import LintConfig 6 | 7 | 8 | class RuleOptionTests(BaseTestCase): 9 | def test_lint(self): 10 | linter = MarkdownLinter(LintConfig()) 11 | sample = self.get_sample_path("sample1.md") 12 | with open(sample) as f: 13 | errors = linter.lint(f.read()) 14 | expected_errors = [RuleViolation("R1", "Line exceeds max length (119>80)", 3), 15 | RuleViolation("R2", "Line has trailing whitespace", 4), 16 | RuleViolation("R2", "Line has trailing whitespace", 5), 17 | RuleViolation("R3", "Line contains hard tab characters (\\t)", 5)] 18 | self.assertListEqual(errors, expected_errors) 19 | -------------------------------------------------------------------------------- /pymarkdownlint/tests/test_options.py: -------------------------------------------------------------------------------- 1 | from pymarkdownlint.tests.base import BaseTestCase 2 | 3 | from pymarkdownlint.options import IntOption, RuleOptionError 4 | 5 | 6 | class RuleOptionTests(BaseTestCase): 7 | def test_int_option(self): 8 | # normal behavior 9 | option = IntOption("test-name", 123, "Test Description") 10 | option.set(456) 11 | self.assertEqual(option.value, 456) 12 | 13 | # error on negative int when not allowed 14 | expected_error = "Option 'test-name' must be a positive integer \(current value: -123\)" 15 | with self.assertRaisesRegexp(RuleOptionError, expected_error): 16 | option.set(-123) 17 | 18 | # error on non-int value 19 | expected_error = "Option 'test-name' must be a positive integer \(current value: foo\)" 20 | with self.assertRaisesRegexp(RuleOptionError, expected_error): 21 | option.set("foo") 22 | 23 | # no error on negative value when allowed and negative int is passed 24 | option = IntOption("test-name", 123, "Test Description", allow_negative=True) 25 | option.set(-456) 26 | self.assertEqual(option.value, -456) 27 | 28 | # error on non-int value when negative int is allowed 29 | expected_error = "Option 'test-name' must be an integer \(current value: foo\)" 30 | with self.assertRaisesRegexp(RuleOptionError, expected_error): 31 | option.set("foo") 32 | -------------------------------------------------------------------------------- /pymarkdownlint/tests/test_rules.py: -------------------------------------------------------------------------------- 1 | from pymarkdownlint.tests.base import BaseTestCase 2 | from pymarkdownlint.rules import MaxLineLengthRule, TrailingWhiteSpace, RuleViolation, HardTab 3 | 4 | 5 | class RuleTests(BaseTestCase): 6 | def test_max_line_length(self): 7 | rule = MaxLineLengthRule() 8 | 9 | # assert no error 10 | violation = rule.validate("a" * 80) 11 | self.assertIsNone(violation) 12 | 13 | # assert error on line length > 81 14 | expected_violation = RuleViolation("R1", "Line exceeds max length (81>80)") 15 | violation = rule.validate("a" * 81) 16 | self.assertEqual(violation, expected_violation) 17 | 18 | # set line length to 120, and check no violation on length 81 19 | rule = MaxLineLengthRule({'line-length': 120}) 20 | violation = rule.validate("a" * 81) 21 | self.assertIsNone(violation) 22 | 23 | # assert raise on 121 24 | expected_violation = RuleViolation("R1", "Line exceeds max length (121>120)") 25 | violation = rule.validate("a" * 121) 26 | self.assertEqual(violation, expected_violation) 27 | 28 | def test_trailing_whitespace(self): 29 | rule = TrailingWhiteSpace() 30 | 31 | # assert no error 32 | violation = rule.validate("a") 33 | self.assertIsNone(violation) 34 | 35 | # trailing space 36 | expected_violation = RuleViolation("R2", "Line has trailing whitespace") 37 | violation = rule.validate("a ") 38 | self.assertEqual(violation, expected_violation) 39 | 40 | # trailing tab 41 | violation = rule.validate("a\t") 42 | self.assertEqual(violation, expected_violation) 43 | 44 | def test_hard_tabs(self): 45 | rule = HardTab() 46 | 47 | # assert no error 48 | violation = rule.validate("This is a test") 49 | self.assertIsNone(violation) 50 | 51 | # contains hard tab 52 | expected_violation = RuleViolation("R3", "Line contains hard tab characters (\\t)") 53 | violation = rule.validate("This is a\ttest") 54 | self.assertEqual(violation, expected_violation) 55 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pip==7.1.0 2 | setuptools 3 | Click==4.1 -------------------------------------------------------------------------------- /run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | 4 | help(){ 5 | echo "Usage: $0 [OPTION]..." 6 | echo "Run pymarkdownlint's test suite(s) or some convience commands" 7 | echo " -h, --help Show this help output" 8 | echo " -p, --pep8 Run pep8 checks" 9 | echo " -l, --lint Run pylint checks" 10 | echo " -s, --stats Show some project stats" 11 | echo " --no-coverage Don't make a unit test coverage report" 12 | echo "" 13 | exit 0; 14 | } 15 | 16 | run_pep8_check(){ 17 | # FLAKE 8 18 | # H307: like imports should be grouped together 19 | # H405: multi line docstring summary not separated with an empty line 20 | # H803: git title must end with a period 21 | # H904: Wrap long lines in parentheses instead of a backslash 22 | # H802: git commit title should be under 50 chars 23 | # H701: empty localization string 24 | FLAKE8_IGNORE="H307,H405,H803,H904,H802,H701" 25 | # exclude settings files and virtualenvs 26 | FLAKE8_EXCLUDE="*settings.py,*.venv/*.py" 27 | echo "Running flake8..." 28 | flake8 --ignore=$FLAKE8_IGNORE --max-line-length=120 --exclude=$FLAKE8_EXCLUDE pymarkdownlint 29 | } 30 | 31 | run_unit_tests(){ 32 | OMIT=".venv/*" 33 | coverage run --omit=$OMIT -m unittest discover -v 34 | if [ $include_coverage -eq 1 ]; then 35 | COVERAGE_REPORT=$(coverage report -m) 36 | echo "$COVERAGE_REPORT" 37 | fi 38 | } 39 | 40 | run_stats(){ 41 | echo "*** Code ***" 42 | radon raw -s pymarkdownlint | tail -n 6 43 | } 44 | 45 | # default behavior 46 | just_pep8=0 47 | just_lint=0 48 | just_stats=0 49 | include_coverage=1 50 | 51 | while [ "$#" -gt 0 ]; do 52 | case "$1" in 53 | -h|--help) shift; help;; 54 | -p|--pep8) shift; just_pep8=1;; 55 | -l|--lint) shift; just_lint=1;; 56 | -s|--stats) shift; just_stats=1;; 57 | --no-coverage)shift; include_coverage=0;; 58 | esac 59 | done 60 | 61 | if [ $just_pep8 -eq 1 ]; then 62 | run_pep8_check 63 | exit $? 64 | fi 65 | 66 | if [ $just_stats -eq 1 ]; then 67 | run_stats 68 | exit $? 69 | fi 70 | 71 | run_unit_tests || exit 72 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup, find_packages 3 | import re 4 | import os 5 | 6 | # There is an issue with building python packages in a shared vagrant directory because of how setuptools works 7 | # in python < 2.7.9. We solve this by deleting the filesystem hardlinking capability during build. 8 | # See: http://stackoverflow.com/a/22147112/381010 9 | del os.link 10 | 11 | long_description = ( 12 | "Markdown linter written in python. Under active development." 13 | "Source code: https://github.com/jorisroovers/pymarkdownlint" 14 | ) 15 | 16 | # shamelessly stolen from mkdocs' setup.py: https://github.com/mkdocs/mkdocs/blob/master/setup.py 17 | def get_version(package): 18 | """Return package version as listed in `__version__` in `init.py`.""" 19 | init_py = open(os.path.join(package, '__init__.py')).read() 20 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 21 | 22 | 23 | setup( 24 | name="pymarkdownlint", 25 | version=get_version("pymarkdownlint"), 26 | description="Markdown linter written in python. Under active development.", 27 | long_description=long_description, 28 | classifiers=[ 29 | "Development Status :: 3 - Alpha", 30 | "Operating System :: OS Independent", 31 | "Programming Language :: Python", 32 | "Environment :: Console", 33 | "Intended Audience :: Developers", 34 | "License :: OSI Approved :: MIT License" 35 | ], 36 | install_requires=[ 37 | 'Click==4.1', 38 | ], 39 | keywords='markdown markdownlint pymarkdownlint', 40 | author='Joris Roovers', 41 | url='https://github.com/jorisroovers/pymarkdownlint', 42 | license='MIT', 43 | packages=find_packages(exclude=["examples"]), 44 | entry_points={ 45 | "console_scripts": [ 46 | "markdownlint = pymarkdownlint.cli:cli", 47 | ], 48 | }, 49 | ) 50 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | flake8==2.4.0 2 | coverage==3.7.1 3 | radon==1.2.1 4 | --------------------------------------------------------------------------------