Tox and github actions. (#24)
This commit is contained in:
parent
f7b61e04d0
commit
3e4bb50687
|
@ -0,0 +1,42 @@
|
|||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run tox
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
tox:
|
||||
- py_version: '3.6'
|
||||
env: py36
|
||||
- py_version: '3.7'
|
||||
env: py37
|
||||
- py_version: '3.8'
|
||||
env: py38,flake8,mypy,black,pylint,pydocstyle,coverage
|
||||
- py_version: '3.9'
|
||||
env: py39
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.tox.py_version }}
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: .tox
|
||||
key: ${{ matrix.tox.python-version }}-${{ hashFiles('tox.ini') }}-${{ hashFiles('requirements-dev.txt') }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y hunspell
|
||||
- name: Install tox
|
||||
run: python3 -m pip install tox
|
||||
- name: Run tox
|
||||
run: tox -q -p all -e ${{ matrix.tox.env }}
|
148
pospell.py
148
pospell.py
|
@ -1,11 +1,11 @@
|
|||
"""pospell is a spellcheckers for po files containing reStructuedText.
|
||||
"""
|
||||
"""pospell is a spellcheckers for po files containing reStructuedText."""
|
||||
import io
|
||||
from string import digits
|
||||
from unicodedata import category
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Dict
|
||||
from contextlib import redirect_stderr
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
|
@ -26,7 +26,11 @@ DEFAULT_DROP_CAPITALIZED = {"fr": True, "fr_FR": True}
|
|||
|
||||
|
||||
class POSpellException(Exception):
|
||||
pass
|
||||
"""All exceptions from this module inherit from this one."""
|
||||
|
||||
|
||||
class Unreachable(POSpellException):
|
||||
"""The code encontered a state that should be unreachable."""
|
||||
|
||||
|
||||
try:
|
||||
|
@ -39,10 +43,15 @@ except FileNotFoundError:
|
|||
|
||||
|
||||
class DummyNodeClass(docutils.nodes.Inline, docutils.nodes.TextElement):
|
||||
pass
|
||||
"""Used to represent any unknown roles, so we can parse any rst blindly."""
|
||||
|
||||
|
||||
def monkey_patch_role(role):
|
||||
"""Patch docutils.parsers.rst.roles.role so it always match.
|
||||
|
||||
Giving a DummyNodeClass for unknown roles.
|
||||
"""
|
||||
|
||||
def role_or_generic(role_name, language_module, lineno, reporter):
|
||||
base_role, message = role(role_name, language_module, lineno, reporter)
|
||||
if base_role is None:
|
||||
|
@ -57,56 +66,65 @@ roles.role = monkey_patch_role(roles.role)
|
|||
|
||||
|
||||
class NodeToTextVisitor(docutils.nodes.NodeVisitor):
|
||||
"""Recursively convert a docutils node to a Python string.
|
||||
|
||||
Usage:
|
||||
|
||||
>>> visitor = NodeToTextVisitor(document)
|
||||
>>> document.walk(visitor)
|
||||
>>> print(str(visitor))
|
||||
|
||||
It ignores (see IGNORE_LIST) some nodes, which we don't want in
|
||||
hunspell (enphasis typically contain proper names that are unknown
|
||||
to dictionaires).
|
||||
"""
|
||||
|
||||
IGNORE_LIST = (
|
||||
"emphasis",
|
||||
"superscript",
|
||||
"title_reference",
|
||||
"strong",
|
||||
"DummyNodeClass",
|
||||
"reference",
|
||||
"literal",
|
||||
"Text",
|
||||
)
|
||||
|
||||
def __init__(self, document):
|
||||
"""Initialize visitor for the given node/document."""
|
||||
self.output = []
|
||||
self.depth = 0
|
||||
super().__init__(document)
|
||||
|
||||
def dispatch_visit(self, node):
|
||||
self.depth += 1
|
||||
super().dispatch_visit(node)
|
||||
|
||||
def dispatch_departure(self, node):
|
||||
self.depth -= 1
|
||||
super().dispatch_departure(node)
|
||||
|
||||
def unknown_visit(self, node):
|
||||
"""Mandatory implementation to visit unknwon nodes."""
|
||||
# print(" " * self.depth * 4, node.__class__.__name__, ":", node)
|
||||
|
||||
def unknown_departure(self, node):
|
||||
"""To help debugging tree."""
|
||||
# print(node, repr(node), node.__class__.__name__)
|
||||
@staticmethod
|
||||
def ignore(node):
|
||||
"""Just raise SkipChildren.
|
||||
|
||||
def visit_emphasis(self, node):
|
||||
Used for all visit_* in the IGNORE_LIST.
|
||||
|
||||
See __getattr__.
|
||||
"""
|
||||
raise docutils.nodes.SkipChildren
|
||||
|
||||
def visit_superscript(self, node):
|
||||
raise docutils.nodes.SkipChildren
|
||||
|
||||
def visit_title_reference(self, node):
|
||||
raise docutils.nodes.SkipChildren
|
||||
|
||||
def visit_strong(self, node):
|
||||
raise docutils.nodes.SkipChildren
|
||||
|
||||
def visit_DummyNodeClass(self, node):
|
||||
raise docutils.nodes.SkipChildren
|
||||
|
||||
def visit_reference(self, node):
|
||||
raise docutils.nodes.SkipChildren
|
||||
|
||||
def visit_literal(self, node):
|
||||
raise docutils.nodes.SkipChildren
|
||||
def __getattr__(self, name):
|
||||
"""Skip childrens from the IGNORE_LIST."""
|
||||
if name.startswith("visit_") and name[6:] in self.IGNORE_LIST:
|
||||
return self.ignore
|
||||
raise AttributeError(name)
|
||||
|
||||
def visit_Text(self, node):
|
||||
"""Keep this node text, this is typically what we want to spell check."""
|
||||
self.output.append(node.rawsource)
|
||||
|
||||
def __str__(self):
|
||||
"""Give the accumulated strings."""
|
||||
return " ".join(self.output)
|
||||
|
||||
|
||||
def strip_rst(line):
|
||||
"""Transform reStructuredText to plain text."""
|
||||
if line.endswith("::"):
|
||||
# Drop :: at the end, it would cause Literal block expected
|
||||
line = line[:-2]
|
||||
|
@ -175,11 +193,13 @@ def clear(line, drop_capitalized=False, po_path=""):
|
|||
|
||||
|
||||
def quote_for_hunspell(text):
|
||||
"""
|
||||
"""Quote a paragraph so hunspell don't misinterpret it.
|
||||
|
||||
Quoting the manpage:
|
||||
It is recommended that programmatic interfaces prefix
|
||||
every data line with an uparrow to protect themselves
|
||||
against future changes in hunspell."""
|
||||
against future changes in hunspell.
|
||||
"""
|
||||
out = []
|
||||
for line in text.split("\n"):
|
||||
out.append("^" + line if line else "")
|
||||
|
@ -187,9 +207,10 @@ def quote_for_hunspell(text):
|
|||
|
||||
|
||||
def po_to_text(po_path, drop_capitalized=False):
|
||||
"""Converts a po file to a text file, by stripping the msgids and all
|
||||
po syntax, but by keeping the kept lines at their same position /
|
||||
line number.
|
||||
"""Convert a po file to a text file.
|
||||
|
||||
This strips the msgids and all po syntax while keeping lines at
|
||||
their same position / line number.
|
||||
"""
|
||||
buffer = []
|
||||
lines = 0
|
||||
|
@ -232,12 +253,14 @@ def parse_args():
|
|||
parser.add_argument(
|
||||
"--drop-capitalized",
|
||||
action="store_true",
|
||||
help="Always drop capitalized words in sentences (defaults according to the language).",
|
||||
help="Always drop capitalized words in sentences"
|
||||
" (defaults according to the language).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-drop-capitalized",
|
||||
action="store_true",
|
||||
help="Never drop capitalized words in sentences (defaults according to the language).",
|
||||
help="Never drop capitalized words in sentences"
|
||||
" (defaults according to the language).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"po_file",
|
||||
|
@ -275,7 +298,9 @@ def parse_args():
|
|||
|
||||
|
||||
def look_like_a_word(word):
|
||||
"""Used to filter out non-words like `---` or `-0700` so they don't
|
||||
"""Return True if the given str looks like a word.
|
||||
|
||||
Used to filter out non-words like `---` or `-0700` so they don't
|
||||
get reported. They typically are not errors.
|
||||
"""
|
||||
if not word:
|
||||
|
@ -296,13 +321,13 @@ def spell_check(
|
|||
drop_capitalized=False,
|
||||
debug_only=False,
|
||||
):
|
||||
"""Check for spelling mistakes in the files po_files (po format,
|
||||
containing restructuredtext), for the given language.
|
||||
"""Check for spelling mistakes in the given po_files.
|
||||
|
||||
(po format, containing restructuredtext), for the given language.
|
||||
personal_dict allow to pass a personal dict (-p) option, to hunspell.
|
||||
|
||||
Debug only will show what's passed to Hunspell instead of passing it.
|
||||
"""
|
||||
errors = []
|
||||
personal_dict_arg = ["-p", personal_dict] if personal_dict else []
|
||||
texts_for_hunspell = {}
|
||||
for po_file in po_files:
|
||||
|
@ -310,32 +335,48 @@ def spell_check(
|
|||
print(po_to_text(str(po_file), drop_capitalized))
|
||||
continue
|
||||
texts_for_hunspell[po_file] = po_to_text(str(po_file), drop_capitalized)
|
||||
if debug_only:
|
||||
return 0
|
||||
try:
|
||||
output = subprocess.run(
|
||||
["hunspell", "-d", language, "-a"] + personal_dict_arg,
|
||||
universal_newlines=True,
|
||||
input=quote_for_hunspell("\n".join(texts_for_hunspell.values())),
|
||||
stdout=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
return -1
|
||||
return parse_hunspell_output(texts_for_hunspell, output)
|
||||
|
||||
|
||||
def parse_hunspell_output(hunspell_input: Dict[str, str], hunspell_output) -> int:
|
||||
"""Parse `hunspell -a` output.
|
||||
|
||||
Print one line per error on stderr, of the following format:
|
||||
|
||||
FILE:LINE:ERROR
|
||||
|
||||
Returns the number of errors.
|
||||
|
||||
hunspell_input contains a dict of files: all_lines_for_this_file.
|
||||
"""
|
||||
errors = 0
|
||||
checked_files = iter(texts_for_hunspell.items())
|
||||
checked_files = iter(hunspell_input.items())
|
||||
checked_file_name, checked_text = next(checked_files)
|
||||
checked_lines = iter(checked_text.split("\n"))
|
||||
currently_checked_line = next(checked_lines)
|
||||
next(checked_lines)
|
||||
current_line_number = 1
|
||||
for line in output.stdout.split("\n")[1:]:
|
||||
for line in hunspell_output.stdout.split("\n")[1:]:
|
||||
if not line:
|
||||
try:
|
||||
currently_checked_line = next(checked_lines)
|
||||
next(checked_lines)
|
||||
current_line_number += 1
|
||||
except StopIteration:
|
||||
try:
|
||||
checked_file_name, checked_text = next(checked_files)
|
||||
checked_lines = iter(checked_text.split("\n"))
|
||||
currently_checked_line = next(checked_lines)
|
||||
next(checked_lines)
|
||||
current_line_number = 1
|
||||
except StopIteration:
|
||||
return errors
|
||||
|
@ -343,10 +384,11 @@ def spell_check(
|
|||
if line == "*": # OK
|
||||
continue
|
||||
if line[0] == "&":
|
||||
_, original, count, offset, *miss = line.split()
|
||||
_, original, *_ = line.split()
|
||||
if look_like_a_word(original):
|
||||
print(checked_file_name, current_line_number, original, sep=":")
|
||||
errors += 1
|
||||
raise Unreachable("Got this one? I'm sorry, read XKCD 2200, then open an issue.")
|
||||
|
||||
|
||||
def gracefull_handling_of_missing_dicts(language):
|
||||
|
@ -384,7 +426,7 @@ https://github.com/JulienPalard/pospell/) so I can enhance this error message.
|
|||
|
||||
|
||||
def main():
|
||||
"""Module entry point."""
|
||||
"""Entry point (for command-line)."""
|
||||
args = parse_args()
|
||||
logging.basicConfig(level=50 - 10 * args.verbose)
|
||||
default_drop_capitalized = DEFAULT_DROP_CAPITALIZED.get(args.language, False)
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
|
@ -1,6 +1,8 @@
|
|||
bandit
|
||||
black
|
||||
coverage
|
||||
flake8
|
||||
isort
|
||||
mypy
|
||||
pylint
|
||||
pytest
|
|
@ -6,28 +6,36 @@
|
|||
#
|
||||
appdirs==1.4.4 # via black
|
||||
astroid==2.4.2 # via pylint
|
||||
attrs==20.3.0 # via pytest
|
||||
bandit==1.6.2 # via -r requirements-dev.in
|
||||
black==20.8b1 # via -r requirements-dev.in
|
||||
click==7.1.2 # via black
|
||||
coverage==5.3 # via -r requirements-dev.in
|
||||
flake8==3.8.4 # via -r requirements-dev.in
|
||||
gitdb==4.0.5 # via gitpython
|
||||
gitpython==3.1.11 # via bandit
|
||||
iniconfig==1.1.1 # via pytest
|
||||
isort==5.6.4 # via -r requirements-dev.in, pylint
|
||||
lazy-object-proxy==1.4.3 # via astroid
|
||||
mccabe==0.6.1 # via flake8, pylint
|
||||
mypy-extensions==0.4.3 # via black, mypy
|
||||
mypy==0.790 # via -r requirements-dev.in
|
||||
packaging==20.4 # via pytest
|
||||
pathspec==0.8.1 # via black
|
||||
pbr==5.5.1 # via stevedore
|
||||
pluggy==0.13.1 # via pytest
|
||||
py==1.9.0 # via pytest
|
||||
pycodestyle==2.6.0 # via flake8
|
||||
pyflakes==2.2.0 # via flake8
|
||||
pylint==2.6.0 # via -r requirements-dev.in
|
||||
pyparsing==2.4.7 # via packaging
|
||||
pytest==6.1.2 # via -r requirements-dev.in
|
||||
pyyaml==5.3.1 # via bandit
|
||||
regex==2020.11.13 # via black
|
||||
six==1.15.0 # via astroid, bandit
|
||||
six==1.15.0 # via astroid, bandit, packaging
|
||||
smmap==3.0.4 # via gitdb
|
||||
stevedore==3.2.2 # via bandit
|
||||
toml==0.10.2 # via black, pylint
|
||||
toml==0.10.2 # via black, pylint, pytest
|
||||
typed-ast==1.4.1 # via black, mypy
|
||||
typing-extensions==3.7.4.3 # via black, mypy
|
||||
wrapt==1.12.1 # via astroid
|
||||
|
|
|
@ -35,8 +35,6 @@ classifiers =
|
|||
License :: OSI Approved :: MIT License
|
||||
Natural Language :: English
|
||||
Programming Language :: Python :: 3
|
||||
Programming Language :: Python :: 3.5
|
||||
Programming Language :: Python :: 3.6
|
||||
|
||||
[options]
|
||||
py_modules = pospell
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
msgid "Rendez-vous à 10h chez Murex"
|
||||
msgstr "See your at 10h at Murex"
|
||||
msgid "Rendez-vous à 10h à la fête"
|
||||
msgstr "See your at 10h at the party"
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import os
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
[flake8]
|
||||
;E203 for black (whitespace before : in slices), and F811 for @overload
|
||||
ignore = E203, F811
|
||||
max-line-length = 88
|
||||
|
||||
[coverage:run]
|
||||
; branch = true: would need a lot of pragma: no branch on infinite loops.
|
||||
parallel = true
|
||||
omit =
|
||||
.tox/*
|
||||
|
||||
[coverage:report]
|
||||
skip_covered = True
|
||||
show_missing = True
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
def __repr__
|
||||
if self\.debug
|
||||
raise AssertionError
|
||||
raise NotImplementedError
|
||||
if __name__ == .__main__.:
|
||||
|
||||
|
||||
[tox]
|
||||
envlist = py36, py37, py38, py39, flake8, mypy, black, pylint, pydocstyle, coverage
|
||||
isolated_build = True
|
||||
skip_missing_interpreters = True
|
||||
|
||||
[testenv]
|
||||
deps = -r requirements-dev.txt
|
||||
commands = coverage run -m pytest
|
||||
setenv =
|
||||
COVERAGE_FILE={toxworkdir}/.coverage.{envname}
|
||||
|
||||
[testenv:coverage]
|
||||
depends = py36, py37, py38, py39
|
||||
parallel_show_output = True
|
||||
deps = coverage
|
||||
skip_install = True
|
||||
setenv = COVERAGE_FILE={toxworkdir}/.coverage
|
||||
commands =
|
||||
coverage combine
|
||||
coverage report --fail-under 65
|
||||
|
||||
|
||||
[testenv:flake8]
|
||||
deps = flake8
|
||||
skip_install = True
|
||||
commands = flake8 tests/ pospell.py
|
||||
|
||||
[testenv:black]
|
||||
deps = black
|
||||
skip_install = True
|
||||
commands = black --check --diff tests/ pospell.py
|
||||
|
||||
[testenv:mypy]
|
||||
deps = mypy
|
||||
skip_install = True
|
||||
commands = mypy --ignore-missing-imports pospell.py
|
||||
|
||||
[testenv:pylint]
|
||||
deps = pylint
|
||||
commands = pylint --disable import-outside-toplevel,invalid-name pospell.py
|
||||
|
||||
[testenv:pydocstyle]
|
||||
deps = pydocstyle
|
||||
skip_install = True
|
||||
commands = pydocstyle pospell.py
|
Loading…
Reference in New Issue