diff --git a/.flake8 b/.flake8 deleted file mode 100644 index b5fae4e..0000000 --- a/.flake8 +++ /dev/null @@ -1,8 +0,0 @@ -[flake8] -builtins = _ -show_source = True -exclude=.venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build -# Minimal config needed to make flake8 compatible with black output: -max_line_length=160 -# See https://github.com/PyCQA/pycodestyle/issues/373 -extend_ignore = E203 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9086862..200f864 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,15 +22,13 @@ repos: rev: 1.5.0 hooks: - id: tox-ini-fmt - - - repo: https://github.com/psf/black - rev: 25.1.0 - hooks: - - id: black - - repo: https://github.com/pycqa/flake8 - rev: 7.2.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.12 hooks: - - id: flake8 + - id: ruff-format + alias: ruff + - id: ruff-check + alias: ruff - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.16.0 hooks: diff --git a/MANIFEST.in b/MANIFEST.in index 2cb309d..abaecd3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -15,7 +15,6 @@ recursive-include docs *.ico recursive-include docs *.png recursive-include docs *.py recursive-include docs *.rst -recursive-include src *.flake8 recursive-include src *.j2 recursive-include src *.json recursive-include src *.md diff --git a/pyproject.toml b/pyproject.toml index b8c9952..52f8cf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,6 +98,7 @@ py-version = "3.10.0" [tool.pylint."MESSAGES CONTROL"] disable = [ + "line-too-long", # TODO(ssbarnea): remove temporary skips adding during initial adoption: "attribute-defined-outside-init", "consider-using-f-string", @@ -126,6 +127,66 @@ filterwarnings = [ "ignore:The frontend.*:DeprecationWarning" ] +[tool.ruff] +fix = true +# Same as Black. +line-length = 88 +preview = true +target-version = "py310" + +[tool.ruff.lint] +ignore = [ + "E501", + # temporary disabled until we fix them: + "A001", + "ANN", + "B007", + "BLE001", + "C410", + "C419", + "C901", + "CPY001", + "D", + "DOC", + "EM102", + "ERA001", + "FBT002", + "FBT003", + "FLY002", + "FURB101", + "FURB103", + "INP001", + "PERF401", + "PGH003", + "PGH004", + "PLR0912", + "PLR0915", + "PLR1702", + "PLR6104", + "PLR6301", + "PLW2901", + "PT009", + "PTH", + "RUF012", + "RUF059", + "S108", + "SIM102", + "SIM103", + "SIM105", + "SIM108", + "SLF001", + "T201", + "TRY003", + "UP031" +] +select = ["ALL"] + +[tool.ruff.lint.isort] +known-first-party = ["src"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + [tool.setuptools] include-package-data = true # These are required in actual runtime: diff --git a/src/doc8/__init__.py b/src/doc8/__init__.py index f9f100a..1053bd6 100644 --- a/src/doc8/__init__.py +++ b/src/doc8/__init__.py @@ -1,8 +1,8 @@ """doc8 - A docutils linter.""" from __future__ import annotations -from doc8.version import __version__ -from doc8.main import doc8 # noqa +from doc8.main import doc8 # noqa +from doc8.version import __version__ __all__ = ("__version__",) diff --git a/src/doc8/__main__.py b/src/doc8/__main__.py index 8b49102..f12a60a 100644 --- a/src/doc8/__main__.py +++ b/src/doc8/__main__.py @@ -16,5 +16,4 @@ from doc8 import main - sys.exit(main.main()) diff --git a/src/doc8/checks.py b/src/doc8/checks.py index a43d1a7..2b6870f 100644 --- a/src/doc8/checks.py +++ b/src/doc8/checks.py @@ -21,7 +21,7 @@ from doc8 import utils -class ContentCheck(metaclass=abc.ABCMeta): +class ContentCheck(abc.ABC): def __init__(self, cfg): self._cfg = cfg @@ -30,7 +30,7 @@ def report_iter(self, parsed_file): pass -class LineCheck(metaclass=abc.ABCMeta): +class LineCheck(abc.ABC): def __init__(self, cfg): self._cfg = cfg @@ -83,7 +83,7 @@ def report_iter(self, parsed_file): class CheckValidity(ContentCheck): REPORTS = frozenset(["D000"]) - EXT_MATCHER = re.compile(r"(.*)[.]rst", re.I) + EXT_MATCHER = re.compile(r"(.*)[.]rst", re.IGNORECASE) # From docutils docs: # @@ -104,11 +104,11 @@ class CheckValidity(ContentCheck): re.MULTILINE, ), re.compile( - r'^Error in "code-block" directive:\nunknown option: "emphasize-lines"' + r'^Error in "code-block" directive:\nunknown option: "emphasize-lines"', ), re.compile(r'^Error in "code-block" directive:\nunknown option: "linenos"'), re.compile( - r'^Error in "code-block" directive:\nunknown option: "lineno-start"' + r'^Error in "code-block" directive:\nunknown option: "lineno-start"', ), re.compile(r'^Error in "code-block" directive:\nunknown option: "dedent"'), re.compile(r'^Error in "code-block" directive:\nunknown option: "force"'), @@ -119,7 +119,7 @@ class CheckValidity(ContentCheck): re.MULTILINE, ), re.compile( - r'^PEP number must be a number from 0 to 9999; "\d{1,4}#[^"]*" is invalid.' + r'^PEP number must be a number from 0 to 9999; "\d{1,4}#[^"]*" is invalid.', ), ] @@ -218,16 +218,14 @@ def find_directive_end(start, lines): # for unknown directives, so we have to do it manually). directives = [] for i, line in enumerate(lines): - if re.match(r"^\s*..\s(.*?)::\s*", line): - directives.append((i, find_directive_end(i, lines))) - elif re.match(r"^::\s*$", line): + if re.match(r"^\s*..\s(.*?)::\s*", line) or re.match(r"^::\s*$", line): directives.append((i, find_directive_end(i, lines))) # Find definition terms in definition lists # This check may match the code, which is already appended lwhitespaces = r"^\s*" listspattern = r"^\s*(\* |- |#\. |\d+\. )" - for i in range(0, len(lines) - 1): + for i in range(len(lines) - 1): line = lines[i] next_line = lines[i + 1] # if line is a blank, line is not a definition term @@ -237,7 +235,7 @@ def find_directive_end(start, lines): if re.match(listspattern, line): continue if len(re.search(lwhitespaces, line).group()) < len( - re.search(lwhitespaces, next_line).group() + re.search(lwhitespaces, next_line).group(), ): directives.append((i, i)) @@ -266,10 +264,7 @@ def find_containing_nodes(num): best_nodes = [] for n, (line_min, line_max) in contained_in: span = line_max - line_min - if smallest_span is None: - smallest_span = span - best_nodes = [n] - elif span < smallest_span: + if smallest_span is None or span < smallest_span: smallest_span = span best_nodes = [n] elif span == smallest_span: diff --git a/src/doc8/main.py b/src/doc8/main.py index dabc05f..feb8cb6 100644 --- a/src/doc8/main.py +++ b/src/doc8/main.py @@ -35,7 +35,6 @@ import os import sys - try: # py3.11+ from tomllib import load as toml_load # type: ignore @@ -45,10 +44,8 @@ from stevedore import extension -from doc8 import checks +from doc8 import checks, utils, version from doc8 import parser as file_parser -from doc8 import utils -from doc8 import version FILE_PATTERNS = [".rst", ".txt"] MAX_LINE_LENGTH = 79 @@ -158,7 +155,8 @@ def extract_config(args): if not os.path.isfile(cfg_file): if args["config"]: print( - "Configuration file %s does not exist...ignoring" % (args["config"]) + "Configuration file %s does not exist...ignoring" + % (args["config"]), ) continue if cfg_file.endswith((".ini", ".cfg")): @@ -180,7 +178,9 @@ def fetch_checks(cfg): checks.CheckNewlineEndOfFile(cfg), ] mgr = extension.ExtensionManager( - namespace="doc8.extension.check", invoke_on_load=True, invoke_args=(cfg.copy(),) + namespace="doc8.extension.check", + invoke_on_load=True, + invoke_args=(cfg.copy(),), ) addons = [] for e in mgr: @@ -194,7 +194,9 @@ def setup_logging(verbose): else: level = logging.ERROR logging.basicConfig( - level=level, format="%(levelname)s: %(message)s", stream=sys.stdout + level=level, + format="%(levelname)s: %(message)s", + stream=sys.stdout, ) @@ -205,7 +207,9 @@ def scan(cfg): ignored_paths = cfg.get("ignore_path", []) files_ignored = 0 file_iter = utils.find_files( - cfg.get("paths", []), cfg.get("extension", []), ignored_paths + cfg.get("paths", []), + cfg.get("extension", []), + ignored_paths, ) default_extension = cfg.get("default_extension") file_encoding = cfg.get("file_encoding") @@ -216,7 +220,9 @@ def scan(cfg): print(" Ignoring '%s'" % (filename)) else: f = file_parser.parse( - filename, default_extension=default_extension, encoding=file_encoding + filename, + default_extension=default_extension, + encoding=file_encoding, ) files.append(f) if cfg.get("verbose"): @@ -253,7 +259,7 @@ def validate(cfg, files, result=None): print( " Skipping check '%s' since it does not" " understand parsing a file with extension '%s'" - % (check_name, f.extension) + % (check_name, f.extension), ) continue try: @@ -266,7 +272,7 @@ def validate(cfg, files, result=None): if cfg.get("verbose"): print( " Skipping check '%s', determined to only" - " check ignoreable codes" % check_name + " check ignoreable codes" % check_name, ) continue if cfg.get("verbose"): @@ -279,13 +285,11 @@ def validate(cfg, files, result=None): line_num = "?" if cfg.get("verbose"): print( - " - {}:{}: {} {}".format( - f.filename, line_num, code, message - ) + f" - {f.filename}:{line_num}: {code} {message}", ) elif not result.capture: print( - "{}:{}: {} {}".format(f.filename, line_num, code, message) + f"{f.filename}:{line_num}: {code} {message}", ) result.error(check_name, f.filename, line_num, code, message) error_counts[check_name] += 1 @@ -297,18 +301,16 @@ def validate(cfg, files, result=None): if cfg.get("verbose"): print( " - %s:%s: %s %s" - % (f.filename, line_num, code, message) + % (f.filename, line_num, code, message), ) elif not result.capture: print( - "{}:{}: {} {}".format( - f.filename, line_num, code, message - ) + f"{f.filename}:{line_num}: {code} {message}", ) result.error(check_name, f.filename, line_num, code, message) error_counts[check_name] += 1 else: - raise TypeError("Unknown check type: {}, {}".format(type(c), c)) + raise TypeError(f"Unknown check type: {type(c)}, {c}") return error_counts @@ -358,20 +360,18 @@ def report(self): for error in self.errors: lines.append("%s:%s: %s %s" % error[1:]) - lines.extend( - [ - "=" * 8, - "Total files scanned = %s" % (self.files_selected), - "Total files ignored = %s" % (self.files_ignored), - "Total accumulated errors = %s" % (self.total_errors), - ] - ) + lines.extend([ + "=" * 8, + "Total files scanned = %s" % (self.files_selected), + "Total files ignored = %s" % (self.files_ignored), + "Total accumulated errors = %s" % (self.total_errors), + ]) if self.error_counts: lines.append("Detailed error counts:") for check_name in sorted(self.error_counts.keys()): check_errors = self.error_counts[check_name] - lines.append(" - {} = {}".format(check_name, check_errors)) + lines.append(f" - {check_name} = {check_errors}") return "\n".join(lines) diff --git a/src/doc8/parser.py b/src/doc8/parser.py index 28652f7..8b34dba 100644 --- a/src/doc8/parser.py +++ b/src/doc8/parser.py @@ -16,10 +16,9 @@ import os import threading -from docutils import frontend -from docutils import parsers as docutils_parser -from docutils import utils import restructuredtext_lint as rl +from docutils import frontend, utils +from docutils import parsers as docutils_parser class ParsedFile: @@ -68,7 +67,8 @@ def document(self): } opt = frontend.OptionParser(components=[parser], defaults=defaults) doc = utils.new_document( - source_path=self.filename, settings=opt.get_default_values() + source_path=self.filename, + settings=opt.get_default_values(), ) parser.parse(self.contents, doc) self._doc = doc @@ -90,10 +90,8 @@ def lines_iter(self, remove_trailing_newline=True): line = str(line, encoding=self.encoding) if remove_trailing_newline: # Cope with various OS new line conventions - if line.endswith("\n"): - line = line[:-1] - if line.endswith("\r"): - line = line[:-1] + line = line.removesuffix("\n") + line = line.removesuffix("\r") yield line @property @@ -127,12 +125,7 @@ def contents(self): return self._content def __str__(self): - return "{} ({}, {} chars, {} lines)".format( - self.filename, - self.encoding, - len(self.contents), - len(list(self.lines_iter())), - ) + return f"{self.filename} ({self.encoding}, {len(self.contents)} chars, {len(list(self.lines_iter()))} lines)" def parse(filename, encoding=None, default_extension=""): diff --git a/src/doc8/tests/test_checks.py b/src/doc8/tests/test_checks.py index fb19ff7..155c1ff 100644 --- a/src/doc8/tests/test_checks.py +++ b/src/doc8/tests/test_checks.py @@ -11,11 +11,10 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -import unittest import tempfile +import unittest -from doc8 import checks -from doc8 import parser +from doc8 import checks, parser class TestTrailingWhitespace(unittest.TestCase): @@ -92,7 +91,7 @@ def test_correct_length(self): fh.write( b"known exploit in the wild, for example" b" \xe2\x80\x93 the time" - b" between advance notification" + b" between advance notification", ) fh.flush() @@ -110,7 +109,7 @@ def test_ignore_code_block(self): b" .. code-block:: ini\n\n" b" this line exceeds 80 chars but should be ignored" b"this line exceeds 80 chars but should be ignored" - b"this line exceeds 80 chars but should be ignored" + b"this line exceeds 80 chars but should be ignored", ) fh.flush() @@ -155,7 +154,7 @@ def test_definition_term_length(self): b"Definition List which contains long term.\n\n" b"looooooooooooooooooooooooooooooong definition term" b"this line exceeds 80 chars but should be ignored\n" - b" this is a definition\n" + b" this is a definition\n", ) fh.flush() diff --git a/src/doc8/tests/test_main.py b/src/doc8/tests/test_main.py index 11001be..08e6b87 100644 --- a/src/doc8/tests/test_main.py +++ b/src/doc8/tests/test_main.py @@ -1,12 +1,11 @@ import os -from io import StringIO -import unittest -from unittest.mock import patch, MagicMock import shutil import sys +import unittest +from io import StringIO +from unittest.mock import MagicMock, patch -from doc8.main import main, doc8, from_toml - +from doc8.main import doc8, from_toml, main # Location to create test files TMPFS_DIR_NAME = ".tmp" @@ -78,9 +77,7 @@ class Capture: - """ - Context manager to capture output on stdout and stderr - """ + """Context manager to capture output on stdout and stderr""" def __enter__(self): self.old_out = sys.stdout @@ -99,15 +96,13 @@ def __exit__(self, *args): class TmpFs: - """ - Context manager to create and clean a temporary file area for testing - """ + """Context manager to create and clean a temporary file area for testing""" def __enter__(self): self.path = os.path.join(os.getcwd(), TMPFS_DIR_NAME) if os.path.exists(self.path): raise ValueError( - "Tmp dir found at %s - remove before running tests" % self.path + "Tmp dir found at %s - remove before running tests" % self.path, ) os.mkdir(self.path) return self @@ -120,22 +115,16 @@ def create_file(self, filename, content): file.write(content) def mock(self): - """ - Create a file which fails on a LineCheck and a ContentCheck - """ + """Create a file which fails on a LineCheck and a ContentCheck""" self.create_file("invalid.rst", "D002 D005 ") def expected(self, template): - """ - Insert the path into a template to generate an expected test value - """ + """Insert the path into a template to generate an expected test value""" return template.format(path=self.path) class FakeResult: - """ - Minimum valid result returned from doc8 - """ + """Minimum valid result returned from doc8""" total_errors = 0 @@ -144,9 +133,7 @@ def report(self): class TestCommandLine(unittest.TestCase): - """ - Test command line invocation - """ + """Test command line invocation""" def test_main__no_quiet_no_verbose__output_is_not_quiet(self): with ( @@ -186,9 +173,7 @@ def test_main__no_quiet_verbose__output_is_verbose(self): class TestApi(unittest.TestCase): - """ - Test direct code invocation - """ + """Test direct code invocation""" def test_doc8__defaults__no_output_and_report_as_expected(self): with TmpFs() as tmpfs, Capture() as (out, err): @@ -201,14 +186,14 @@ def test_doc8__defaults__no_output_and_report_as_expected(self): [ ( "doc8.checks.CheckTrailingWhitespace", - "{}/invalid.rst".format(tmpfs.path), + f"{tmpfs.path}/invalid.rst", 1, "D002", "Trailing whitespace", ), ( "doc8.checks.CheckNewlineEndOfFile", - "{}/invalid.rst".format(tmpfs.path), + f"{tmpfs.path}/invalid.rst", 1, "D005", "No newline at end of file", @@ -231,9 +216,7 @@ def test_doc8__verbose__verbose_overridden(self): class TestArguments(unittest.TestCase): - """ - Test that arguments are parsed correctly - """ + """Test that arguments are parsed correctly""" def get_args(self, **kwargs): # Defaults @@ -303,7 +286,8 @@ def test_args__config__overrides_default(self): patch("doc8.main.scan", mock_scan), patch("doc8.main.extract_config", mock_config), patch( - "argparse._sys.argv", ["doc8", "--config", "path1", "--config", "path2"] + "argparse._sys.argv", + ["doc8", "--config", "path1", "--config", "path2"], ), ): state = main() @@ -355,7 +339,7 @@ def test_args__ignore_path__overrides_default(self): state = main() self.assertEqual(state, 0) mock_scan.assert_called_once_with( - self.get_args(ignore_path=["path1", "path2"]) + self.get_args(ignore_path=["path1", "path2"]), ) def test_args__ignore_path_errors__overrides_default(self): @@ -376,7 +360,9 @@ def test_args__ignore_path_errors__overrides_default(self): state = main() self.assertEqual(state, 0) mock_scan.assert_called_once_with( - self.get_args(ignore_path_errors={"path1": {"D002"}, "path2": {"D005"}}) + self.get_args( + ignore_path_errors={"path1": {"D002"}, "path2": {"D005"}}, + ), ) def test_args__default_extension__overrides_default(self): @@ -422,7 +408,7 @@ def test_args__extension__overrides_default(self): state = main() self.assertEqual(state, 0) mock_scan.assert_called_once_with( - self.get_args(extension=[".rst", ".txt", "ext1", "ext2"]) + self.get_args(extension=[".rst", ".txt", "ext1", "ext2"]), ) def test_args__quiet__overrides_default(self): @@ -470,9 +456,7 @@ def test_args__version__overrides_default(self): class TestConfig(unittest.TestCase): - """ - Test that configuration file is loaded correctly - """ + """Test that configuration file is loaded correctly""" def test_config__from_toml(self): with TmpFs() as tmpfs: