diff --git a/ament_pep257/ament_pep257/main.py b/ament_pep257/ament_pep257/main.py
index fb573ba8b..10eff4c22 100755
--- a/ament_pep257/ament_pep257/main.py
+++ b/ament_pep257/ament_pep257/main.py
@@ -14,32 +14,33 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+
import argparse
-import logging
+from importlib.util import find_spec
import os
+import shutil
import sys
import time
from typing import Literal
from xml.sax.saxutils import escape
from xml.sax.saxutils import quoteattr
-import pydocstyle
-from pydocstyle import check
-
-try: # as of version 1.1.0
- from pydocstyle.config import ConfigurationParser
- from pydocstyle.violations import Error
- from pydocstyle.utils import log
-except ImportError: # try version 1.0.0
- from pydocstyle import ConfigurationParser
- from pydocstyle import Error
- from pydocstyle import log
-
-log.setLevel(logging.INFO)
+ruff_installed = False
+pydocstyle_installed = False
+if shutil.which('ruff'):
+ ruff_installed = True
+else:
+ ruff_installed = False
-_conventions = set(pydocstyle.conventions.keys())
-_conventions.add('ament')
+if find_spec('pydocstyle'):
+ pydocstyle_installed = True
+ import pydocstyle
+ _conventions = set(pydocstyle.conventions.keys())
+ _conventions.add('ament')
+else:
+ pydocstyle_installed = False
+ _conventions = {'pep257', 'numpy', 'google', 'ament'}
_ament_ignore = [
'D100',
@@ -123,8 +124,18 @@ def main(argv: list[str] = sys.argv[1:]) -> Literal[0, 1]:
args.ignore = ','.join(_ament_ignore)
excludes = [os.path.abspath(e) for e in args.excludes]
- report = generate_pep257_report(args.paths, excludes, args.ignore, args.select,
- args.convention, args.add_ignore, args.add_select)
+
+ if ruff_installed:
+ from ament_pep257.ruff_impl import generate_ruff_report
+ report = generate_ruff_report(args.paths, excludes, args.ignore, args.select,
+ args.convention, args.add_ignore, args.add_select)
+ elif pydocstyle_installed:
+ from ament_pep257.pydocstyle_impl import generate_pep257_report
+ report = generate_pep257_report(args.paths, excludes, args.ignore, args.select,
+ args.convention, args.add_ignore, args.add_select)
+ else:
+ print('Neither ruff or pydocstyle installed')
+ return 1
error_count = sum(len(r[1]) for r in report)
# print summary
@@ -162,76 +173,6 @@ def _filename_in_excludes(filename, excludes):
return any(os.path.commonpath([absname, e]) == e for e in excludes)
-def generate_pep257_report(paths, excludes, ignore, select, convention, add_ignore, add_select):
- conf = ConfigurationParser()
- sys_argv = sys.argv
- sys.argv = [
- 'main',
- '--match', r'.*\.py',
- '--match-dir', r'[^\._].*',
- ]
- if ignore:
- sys.argv += ['--ignore', ignore]
- elif select:
- sys.argv += ['--select', select]
- else:
- sys.argv += ['--convention', convention]
- if add_ignore:
- sys.argv += ['--add-ignore', add_ignore]
- if add_select:
- sys.argv += ['--add-select', add_select]
- sys.argv += paths
- conf.parse()
- sys.argv = sys_argv
- files_to_check = conf.get_files_to_check()
-
- report = []
-
- files_dict = {}
- # Unpack 3 values for pydocstyle <= 6.1.1 and 4 values for pydocstyle >= 6.2.0
- for filename, checked_codes, ignore_decorators, *_ in files_to_check:
- if _filename_in_excludes(filename, excludes):
- continue
- files_dict[filename] = {
- 'select': checked_codes,
- 'ignore_decorators': ignore_decorators,
- }
-
- for filename in sorted(files_dict.keys()):
- print('checking', filename)
- errors = []
- pep257_errors = check(
- [filename],
- **files_dict[filename])
- for pep257_error in pep257_errors:
- if isinstance(pep257_error, Error):
- errors.append({
- 'category': pep257_error.code,
- 'linenumber': pep257_error.line,
- 'message': pep257_error.message,
- })
- print(
- '%s:%d %s: %s' %
- (pep257_error.filename, pep257_error.line, pep257_error.definition,
- pep257_error.message), file=sys.stderr)
- elif isinstance(pep257_error, SyntaxError):
- errors.append({
- 'category': str(type(pep257_error)),
- 'linenumber': '-',
- 'message': 'invalid syntax in file',
- })
- print('%s: invalid syntax' % filename, file=sys.stderr)
- else:
- errors.append({
- 'category': 'unknown',
- 'linenumber': '-',
- 'message': str(pep257_error),
- })
- print('%s: %s' % (filename, pep257_error), file=sys.stderr)
- report.append((filename, errors))
- return report
-
-
def get_xunit_content(report, testname, elapsed):
test_count = sum(max(len(r[1]), 1) for r in report)
error_count = sum(len(r[1]) for r in report)
diff --git a/ament_pep257/ament_pep257/pydocstyle_impl.py b/ament_pep257/ament_pep257/pydocstyle_impl.py
new file mode 100644
index 000000000..8c6815645
--- /dev/null
+++ b/ament_pep257/ament_pep257/pydocstyle_impl.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+
+# Copyright 2026 Open Source Robotics Foundation, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT 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 logging
+import sys
+
+from ament_pep257.main import _filename_in_excludes
+from pydocstyle import check
+
+try: # as of version 1.1.0
+ from pydocstyle.config import ConfigurationParser
+ from pydocstyle.violations import Error
+ from pydocstyle.utils import log
+except ImportError: # try version 1.0.0
+ from pydocstyle import ConfigurationParser
+ from pydocstyle import Error
+ from pydocstyle import log
+
+
+log.setLevel(logging.INFO)
+
+
+def generate_pep257_report(paths, excludes, ignore, select, convention, add_ignore, add_select):
+ conf = ConfigurationParser()
+ sys_argv = sys.argv
+ sys.argv = [
+ 'main',
+ '--match', r'.*\.py',
+ '--match-dir', r'[^\._].*',
+ ]
+ if ignore:
+ sys.argv += ['--ignore', ignore]
+ elif select:
+ sys.argv += ['--select', select]
+ else:
+ sys.argv += ['--convention', convention]
+ if add_ignore:
+ sys.argv += ['--add-ignore', add_ignore]
+ if add_select:
+ sys.argv += ['--add-select', add_select]
+ sys.argv += paths
+ conf.parse()
+ sys.argv = sys_argv
+ files_to_check = conf.get_files_to_check()
+
+ report = []
+
+ files_dict = {}
+ # Unpack 3 values for pydocstyle <= 6.1.1 and 4 values for pydocstyle >= 6.2.0
+ for filename, checked_codes, ignore_decorators, *_ in files_to_check:
+ if _filename_in_excludes(filename, excludes):
+ continue
+ files_dict[filename] = {
+ 'select': checked_codes,
+ 'ignore_decorators': ignore_decorators,
+ }
+
+ for filename in sorted(files_dict.keys()):
+ print('checking', filename)
+ errors = []
+ pep257_errors = check(
+ [filename],
+ **files_dict[filename])
+ for pep257_error in pep257_errors:
+ if isinstance(pep257_error, Error):
+ errors.append({
+ 'category': pep257_error.code,
+ 'linenumber': pep257_error.line,
+ 'message': pep257_error.message,
+ })
+ print(
+ '%s:%d %s: %s' %
+ (pep257_error.filename, pep257_error.line, pep257_error.definition,
+ pep257_error.message), file=sys.stderr)
+ elif isinstance(pep257_error, SyntaxError):
+ errors.append({
+ 'category': str(type(pep257_error)),
+ 'linenumber': '-',
+ 'message': 'invalid syntax in file',
+ })
+ print('%s: invalid syntax' % filename, file=sys.stderr)
+ else:
+ errors.append({
+ 'category': 'unknown',
+ 'linenumber': '-',
+ 'message': str(pep257_error),
+ })
+ print('%s: %s' % (filename, pep257_error), file=sys.stderr)
+ report.append((filename, errors))
+ return report
diff --git a/ament_pep257/ament_pep257/ruff_impl.py b/ament_pep257/ament_pep257/ruff_impl.py
new file mode 100644
index 000000000..381e25c73
--- /dev/null
+++ b/ament_pep257/ament_pep257/ruff_impl.py
@@ -0,0 +1,114 @@
+# Copyright 2026 Open Source Robotics Foundation, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT 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 json
+import os
+import subprocess
+import sys
+import re
+
+from ament_pep257.main import _filename_in_excludes
+
+
+def generate_ruff_report(paths: list[str], excludes: list[str], ignore: str, select: str,
+ convention: str, add_ignore: str, add_select: str):
+
+ cmd = ['ruff', 'check', '--output-format', 'json', '--select', 'D']
+
+ if ignore:
+ cmd += ['--ignore', ignore]
+ elif select:
+ cmd += ['--select', select]
+ else:
+ cmd += ['--convention', convention]
+
+ if add_ignore:
+ cmd += ['--ignore', add_ignore]
+
+ if add_select:
+ cmd += ['--select', add_select]
+
+ # excludes
+ for e in excludes:
+ cmd += ['--exclude', e]
+
+ # paths
+ cmd += paths
+
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ )
+
+ data = json.loads(result.stdout)
+
+ report = []
+
+ # Handles missing files
+ if result.stderr:
+ warnings = result.stderr.splitlines()
+ for line in warnings:
+ match = re.search(r"Failed to lint (.*?):", line)
+ if match:
+ filename = match.group(1)
+ report.append((filename, [
+ {'category': 'unknown',
+ 'linenumber': 'N/A',
+ 'message': line
+ }]))
+ print(
+ '%s:%s %s: %s' % (
+ filename,
+ 'N/A',
+ 'N/A',
+ line,
+ ),
+ file=sys.stderr,
+ )
+ files_dict = {}
+
+ for item in data:
+ filename = os.path.relpath(item['filename'])
+
+ if _filename_in_excludes(filename, excludes):
+ continue
+
+ files_dict.setdefault(filename, []).append(item)
+
+ for filename in sorted(files_dict.keys()):
+ print('checking', filename)
+ errors = []
+
+ for err in files_dict[filename]:
+ errors.append({
+ 'category': err.get('code', 'unknown'),
+ 'linenumber': err.get('location', {}).get('row', '-'),
+ 'message': err.get('message', ''),
+ })
+
+ print(
+ '%s:%s %s: %s' % (
+ filename,
+ err.get('location', {}).get('row', '-'),
+ err.get('code', 'unknown'),
+ err.get('message', ''),
+ ),
+ file=sys.stderr,
+ )
+
+ report.append((filename, errors))
+
+ return report
diff --git a/ament_pep257/package.xml b/ament_pep257/package.xml
index 6df74cf42..8e5e2bca5 100644
--- a/ament_pep257/package.xml
+++ b/ament_pep257/package.xml
@@ -20,6 +20,9 @@
Michel Hidalgo
ament_lint
+
+
+
pydocstyle
ament_flake8
diff --git a/ament_pep257/test/test_ament_pep257.py b/ament_pep257/test/test_ament_pep257.py
new file mode 100644
index 000000000..967b916f0
--- /dev/null
+++ b/ament_pep257/test/test_ament_pep257.py
@@ -0,0 +1,115 @@
+# Copyright 2023 Open Source Robotics Foundation, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT 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 pathlib
+import tempfile
+import typing
+
+from _pytest.fixtures import FixtureRequest
+from _pytest.monkeypatch import MonkeyPatch
+import ament_pep257.main as m
+import pytest
+
+
+if not m.ruff_installed or not m.pydocstyle_installed:
+ pytest.fail('Neither ruff or pydocstyle installed')
+
+
+class MainFunc(typing.Protocol):
+
+ def __call__(self, argv: list[str] = ...) -> typing.Literal[0, 1]: ...
+
+
+@pytest.fixture(params=['ruff', 'pydocstyle'])
+def backend(request: FixtureRequest, monkeypatch: MonkeyPatch) -> MainFunc:
+ if request.param == 'ruff':
+ if not m.ruff_installed:
+ pytest.skip('ruff not installed')
+ monkeypatch.setattr(m, 'ruff_installed', True)
+ monkeypatch.setattr(m, 'pydocstyle_installed', False)
+
+ elif request.param == 'pydocstyle':
+ if not m.pydocstyle_installed:
+ pytest.skip('pydocstyle not installed')
+ monkeypatch.setattr(m, 'ruff_installed', False)
+ monkeypatch.setattr(m, 'pydocstyle_installed', True)
+
+ return m.main
+
+
+def test_invalid_file(backend: MainFunc) -> None:
+ report = backend(['non_existent_file.py'])
+ assert report == 1
+
+
+def test_valid_file(backend: MainFunc) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_dir = pathlib.Path(temp_dir)
+ py_file = temp_dir / 'foobar.py'
+ py_file.write_text('a = 1+2\n')
+
+ report = backend([str(py_file)])
+ assert report == 0
+
+
+def test_valid_and_invalid_file(backend: MainFunc) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_dir = pathlib.Path(temp_dir)
+ py_file = temp_dir / 'foobar.py'
+ py_file2 = temp_dir / 'barfoo.py'
+ py_file.write_text('a = 1+2\n')
+
+ report = backend([str(py_file), str(py_file2)])
+ assert report == 1
+
+
+def test_valid_with_violations(backend: MainFunc) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_dir = pathlib.Path(temp_dir)
+ py_file = temp_dir / 'foobar.py'
+
+ py_file.write_text(
+ 'def foo():\n'
+ ' """bad docstring"""\n'
+ ' pass\n'
+ )
+
+ report = backend([str(py_file)])
+ assert report == 1
+
+
+def test_ignore_codes(backend: MainFunc) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_dir = pathlib.Path(temp_dir)
+ py_file = temp_dir / 'foobar.py'
+
+ # This normally triggers D100 (missing docstring in module)
+ py_file.write_text('')
+
+ report = backend([str(py_file), '--ignore', 'D100'])
+ assert report == 0
+
+
+def test_directory_with_multiple_files(backend: MainFunc) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_dir = pathlib.Path(temp_dir)
+ files = {
+ 'good.py': 'a = 1\n',
+ 'bad.py': 'def f():\n """foo"""\n pass\n',
+ }
+ for name, content in files.items():
+ (temp_dir / name).write_text(content)
+
+ report = backend([str(temp_dir)])
+ assert report == 1
diff --git a/ament_pep257/test/test_generate_pep257_report.py b/ament_pep257/test/test_generate_pep257_report.py
deleted file mode 100644
index 1174c3cad..000000000
--- a/ament_pep257/test/test_generate_pep257_report.py
+++ /dev/null
@@ -1,47 +0,0 @@
-# Copyright 2023 Open Source Robotics Foundation, Inc.
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT 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 pathlib
-import tempfile
-
-from ament_pep257.main import _ament_ignore
-from ament_pep257.main import generate_pep257_report
-
-
-def test_invalid_file():
- ignore = ','.join(_ament_ignore)
-
- report = generate_pep257_report(['non_existent_file.py'], [], ignore, [], 'ament', [], [])
- assert len(report) == 1
- filename, errors = report[0]
- assert filename == 'non_existent_file.py'
- assert len(errors) == 1
- error = errors[0]
- assert error['linenumber'] == '-'
- assert error['category'] == 'unknown'
-
-
-def test_valid_file():
- ignore = ','.join(_ament_ignore)
-
- with tempfile.TemporaryDirectory() as temp_dir:
- temp_dir = pathlib.Path(temp_dir)
- py_file = temp_dir / 'foobar.py'
- py_file.write_text('a = 1+2\n')
-
- report = generate_pep257_report([str(temp_dir)], [], ignore, [], 'ament', [], [])
-
- assert len(report) == 1
- filename, errors = report[0]
- assert errors == []
diff --git a/ament_pep257/test/test_pep257.py b/ament_pep257/test/test_pep257.py
new file mode 100644
index 000000000..6c951516f
--- /dev/null
+++ b/ament_pep257/test/test_pep257.py
@@ -0,0 +1,23 @@
+# Copyright 2026 Open Source Robotics Foundation, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from ament_pep257.main import main
+import pytest
+
+
+@pytest.mark.linter
+@pytest.mark.pep257
+def test_pep257():
+ rc = main(argv=[])
+ assert rc == 0, 'Found docblock style errors'