├── tests ├── __init__.py ├── test_vcs.py ├── test_config.py ├── test_core.py ├── conftest.py ├── test_config_toml.py ├── test_shell.py ├── test_context.py └── test_linters.py ├── pylama ├── libs │ ├── __init__.py │ └── inirama.py ├── __main__.py ├── __init__.py ├── lint │ ├── pylama_fake.py │ ├── pylama_eradicate.py │ ├── pylama_mccabe.py │ ├── pylama_pycodestyle.py │ ├── pylama_pydocstyle.py │ ├── pylama_mypy.py │ ├── __init__.py │ ├── pylama_pyflakes.py │ ├── pylama_radon.py │ ├── pylama_vulture.py │ └── pylama_pylint.py ├── utils.py ├── config_toml.py ├── check_async.py ├── core.py ├── pytest.py ├── hook.py ├── main.py ├── errors.py ├── context.py └── config.py ├── docs ├── _static │ └── logo.png ├── requirements.txt ├── index.rst └── conf.py ├── .bumpversion.cfg ├── requirements ├── requirements.txt └── requirements-tests.txt ├── Dockerfile ├── .pre-commit-hooks.yaml ├── MANIFEST.in ├── .github ├── dependabot.yml └── workflows │ ├── docs.yml │ ├── tests.yml │ └── release.yml ├── .gitignore ├── setup.py ├── LICENSE ├── Makefile ├── setup.cfg ├── dummy.py ├── Changelog └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pylama/libs/__init__.py: -------------------------------------------------------------------------------- 1 | """ Support libs. """ 2 | -------------------------------------------------------------------------------- /docs/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klen/pylama/HEAD/docs/_static/logo.png -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx-rtd-theme 2 | sphinx-copybutton 3 | git+https://github.com/klen/pylama 4 | 5 | -------------------------------------------------------------------------------- /pylama/__main__.py: -------------------------------------------------------------------------------- 1 | """Support the module execution.""" 2 | 3 | from pylama.main import shell 4 | 5 | if __name__ == "__main__": 6 | shell() 7 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | commit = True 3 | current_version = 8.4.1 4 | files = pylama/__init__.py 5 | tag = True 6 | tag_name = {new_version} 7 | -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | # Test requirements 2 | 3 | mccabe >= 0.7.0 4 | pycodestyle >= 2.9.1 5 | pydocstyle >= 6.1.1 6 | pyflakes >= 2.5.0 7 | -------------------------------------------------------------------------------- /pylama/__init__.py: -------------------------------------------------------------------------------- 1 | """Code audit tool for python. 2 | 3 | :copyright: 2013 by Kirill Klenov. 4 | """ 5 | 6 | import logging 7 | 8 | __version__ = "8.4.1" 9 | 10 | LOGGER = logging.getLogger("pylama") 11 | 12 | 13 | # pylama:ignore=D 14 | -------------------------------------------------------------------------------- /requirements/requirements-tests.txt: -------------------------------------------------------------------------------- 1 | pytest >= 7.1.2 2 | pytest-mypy 3 | eradicate >= 2.0.0 4 | radon >= 5.1.0 5 | mypy 6 | pylint >= 2.11.1 7 | pylama-quotes 8 | toml 9 | vulture 10 | 11 | types-setuptools 12 | types-toml 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY requirements ./requirements 6 | RUN pip install -r requirements/requirements.txt 7 | RUN pip install -r requirements/requirements-tests.txt 8 | 9 | COPY . . 10 | RUN pip install .[tests] 11 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # For use with pre-commit. 3 | # See usage instructions at https://pre-commit.com 4 | - id: pylama 5 | name: pylama 6 | description: This hook runs pylama. 7 | entry: pylama 8 | language: python 9 | types: [file, python] 10 | args: [] 11 | additional_dependencies: [] 12 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include Changelog 3 | include LICENSE 4 | include MANIFEST.in 5 | include README.rst 6 | include dummy.py 7 | 8 | recursive-include requirements *.txt 9 | recursive-include pylama * 10 | 11 | recursive-exclude * __pycache__ 12 | recursive-exclude * *.py[co] 13 | recursive-exclude * *.orig 14 | -------------------------------------------------------------------------------- /tests/test_vcs.py: -------------------------------------------------------------------------------- 1 | def test_git_hook(): 2 | from pylama.hook import git_hook 3 | 4 | try: 5 | assert not git_hook(False) 6 | except SystemExit as exc: 7 | assert exc.code == 0 8 | 9 | 10 | def test_hg_hook(): 11 | from pylama.hook import hg_hook 12 | 13 | assert not hg_hook(None, {}) 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | # Maintain dependencies for GitHub Actions 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | 10 | # Maintain dependencies for Python 11 | - package-ecosystem: "pip" 12 | directory: "/requirements" 13 | schedule: 14 | interval: "weekly" 15 | open-pull-requests-limit: 10 16 | -------------------------------------------------------------------------------- /pylama/lint/pylama_fake.py: -------------------------------------------------------------------------------- 1 | """A fake linter which one never be loaded.""" 2 | 3 | from typing import Any, Dict, List 4 | 5 | import unknown_module # noqa 6 | 7 | from pylama.lint import Linter as Abstract 8 | 9 | 10 | class Linter(Abstract): 11 | """Just a fake.""" 12 | 13 | name = "fake" 14 | 15 | def run(self, _path: str, **_) -> List[Dict[str, Any]]: 16 | """Run the unknown module.""" 17 | return unknown_module.run(_path) 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg 2 | *.egg-info 3 | *.mo 4 | *.py[cod] 5 | *.so 6 | .coverage 7 | .idea/ 8 | .installed.cfg 9 | .mr.developer.cfg 10 | .project 11 | .pydevproject 12 | .tox 13 | .vimrc 14 | /.cache 15 | /.mypy_cache 16 | /.ropeproject 17 | /_ 18 | /libs 19 | /todo.rst 20 | /todo.txt 21 | bin 22 | build 23 | develop-eggs 24 | dist 25 | docs/_build 26 | eggs 27 | env 28 | lib 29 | lib64 30 | nosetests.xml 31 | parts 32 | pip-log.txt 33 | plugins 34 | sdist 35 | var 36 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | 3 | on: 4 | workflow_run: 5 | workflows: [tests] 6 | branches: [master] 7 | types: [completed] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | if: github.event.workflow_run.conclusion == 'success' 13 | steps: 14 | - uses: seanzhengw/sphinx-pages@master 15 | with: 16 | github_token: ${{ secrets.GITHUB_TOKEN }} 17 | create_readme: true 18 | source_dir: docs 19 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | Welcome to Pylama 4 | ================= 5 | 6 | .. image:: _static/logo.png 7 | 8 | Welcome to Pylama's documentation. 9 | 10 | .. === description === 11 | .. include:: ../README.rst 12 | :start-after: .. _description: 13 | :end-before: .. _documentation: 14 | 15 | :copyright: 2013 by Kirill Klenov. 16 | :license: MIT, see LICENSE for more details. 17 | 18 | .. contents:: 19 | 20 | .. include:: ../README.rst 21 | :start-after: .. _requirements: 22 | 23 | -------------------------------------------------------------------------------- /pylama/utils.py: -------------------------------------------------------------------------------- 1 | """Pylama utils.""" 2 | 3 | from io import StringIO 4 | from sys import stdin 5 | from typing import List 6 | 7 | 8 | def get_lines(value: str) -> List[str]: 9 | """Return lines from the given string.""" 10 | return StringIO(value).readlines() 11 | 12 | 13 | def read(filename: str) -> str: 14 | """Read the given filename.""" 15 | with open(filename, encoding="utf-8") as file: 16 | return file.read() 17 | 18 | 19 | def read_stdin() -> str: 20 | """Get value from stdin.""" 21 | value = stdin.buffer.read() 22 | return value.decode("utf-8") 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Setup pylama installation.""" 4 | 5 | import pathlib 6 | 7 | import pkg_resources 8 | from setuptools import setup 9 | 10 | 11 | def parse_requirements(path: str) -> "list[str]": 12 | with pathlib.Path(path).open(encoding='utf-8') as requirements: 13 | return [str(req) for req in pkg_resources.parse_requirements(requirements)] 14 | 15 | 16 | OPTIONAL_LINTERS = ['pylint', 'eradicate', 'radon', 'mypy', 'vulture'] 17 | 18 | 19 | setup( 20 | install_requires=parse_requirements("requirements/requirements.txt"), 21 | extras_require=dict( 22 | tests=parse_requirements("requirements/requirements-tests.txt"), 23 | all=OPTIONAL_LINTERS, **{linter: [linter] for linter in OPTIONAL_LINTERS}, 24 | toml="toml>=0.10.2", 25 | ), 26 | ) 27 | -------------------------------------------------------------------------------- /pylama/lint/pylama_eradicate.py: -------------------------------------------------------------------------------- 1 | """Commented-out code checking.""" 2 | 3 | from eradicate import Eradicator 4 | 5 | from pylama.context import RunContext 6 | from pylama.lint import LinterV2 as Abstract 7 | 8 | 9 | class Linter(Abstract): 10 | """Run commented-out code checking.""" 11 | 12 | name = "eradicate" 13 | 14 | def run_check(self, ctx: RunContext): 15 | """Eradicate code checking. 16 | 17 | TODO: Support params 18 | """ 19 | eradicator = Eradicator() 20 | line_numbers = eradicator.commented_out_code_line_numbers(ctx.source) 21 | for line_number in line_numbers: 22 | ctx.push( 23 | lnum=line_number, 24 | source="eradicate", 25 | text=str("Found commented out code"), 26 | number="E800", 27 | type="E", 28 | ) 29 | -------------------------------------------------------------------------------- /pylama/config_toml.py: -------------------------------------------------------------------------------- 1 | """Pylama TOML configuration.""" 2 | 3 | import toml 4 | 5 | from pylama.libs.inirama import Namespace as _Namespace 6 | 7 | 8 | class Namespace(_Namespace): 9 | """Inirama-style wrapper for TOML config.""" 10 | 11 | def parse(self, source: str, update: bool = True, **params): 12 | """Parse TOML source as string.""" 13 | content = toml.loads(source) 14 | tool = content.get("tool", {}) 15 | pylama = tool.get("pylama", {}) 16 | linters = pylama.pop("linter", {}) 17 | files = pylama.pop("files", []) 18 | 19 | for name, value in pylama.items(): 20 | self["pylama"][name] = value 21 | 22 | for linter, options in linters.items(): 23 | for name, value in options.items(): 24 | self[f"pylama:{linter}"][name] = value 25 | 26 | for file in files: 27 | path = file.pop("path", None) 28 | if path is None: 29 | continue 30 | for name, value in file.items(): 31 | self[f"pylama:{path}"][name] = value 32 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | def test_config(parse_options): 2 | from pylama.config import get_config 3 | 4 | config = get_config() 5 | assert config 6 | 7 | options = parse_options() 8 | assert options 9 | assert options.skip 10 | assert options.max_line_length 11 | assert not options.verbose 12 | assert options.paths 13 | assert "pylama" in options.paths[0] 14 | 15 | options = parse_options(["-l", "pydocstyle,pycodestyle,unknown", "-i", "E"]) 16 | assert set(options.linters) == set(["pydocstyle", "pycodestyle"]) 17 | assert options.ignore == {"E"} 18 | 19 | options = parse_options("-o dummy dummy.py".split()) 20 | assert set(options.linters) == set(["pycodestyle", "mccabe", "pyflakes"]) 21 | assert options.skip == [] 22 | 23 | 24 | def test_parse_options(parse_options): 25 | options = parse_options() 26 | assert not options.select 27 | 28 | 29 | def test_from_stdin(parse_options): 30 | options = parse_options("--from-stdin dummy.py".split()) 31 | assert options 32 | assert options.from_stdin is True 33 | assert options.paths 34 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: tests 5 | 6 | on: 7 | pull_request: 8 | branches: [master, develop] 9 | 10 | push: 11 | branches: [master, develop] 12 | 13 | jobs: 14 | tests: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ['3.7', '3.8', '3.9', '3.10'] 19 | 20 | steps: 21 | - name: Checkout changes 22 | uses: actions/checkout@main 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@main 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | cache: pip 29 | cache-dependency-path: 'requirements/*.txt' 30 | 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install .[tests] 34 | 35 | - name: Test pylama 36 | run: pytest --pylama pylama 37 | 38 | - name: Test with pytest 39 | run: pytest tests 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright (c) 2021 Pylama Developers 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining 7 | a copy of this software and associated documentation files (the 8 | "Software"), to deal in the Software without restriction, including 9 | without limitation the rights to use, copy, modify, merge, publish, 10 | distribute, sublicense, and/or sell copies of the Software, and to 11 | permit persons to whom the Software is furnished to do so, subject to 12 | the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 20 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 21 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 22 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 23 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_run: 5 | workflows: [tests] 6 | branches: [master] 7 | types: [completed] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | if: github.event.workflow_run.conclusion == 'success' 14 | steps: 15 | 16 | - uses: actions/checkout@main 17 | with: 18 | fetch-depth: 5 19 | 20 | - uses: actions/setup-python@main 21 | with: 22 | python-version: 3.9 23 | 24 | - name: Build package 25 | run: | 26 | pip install wheel 27 | python setup.py bdist_wheel 28 | python setup.py sdist 29 | 30 | - uses: actions/upload-artifact@main 31 | with: 32 | name: dist 33 | path: dist 34 | 35 | release: 36 | runs-on: ubuntu-latest 37 | needs: [build] 38 | steps: 39 | 40 | - name: Download a distribution artifact 41 | uses: actions/download-artifact@main 42 | with: 43 | name: dist 44 | path: dist 45 | 46 | - name: Publish distribution 📦 to PyPI 47 | uses: pypa/gh-action-pypi-publish@master 48 | with: 49 | user: __token__ 50 | password: ${{ secrets.PYPI }} 51 | -------------------------------------------------------------------------------- /pylama/check_async.py: -------------------------------------------------------------------------------- 1 | """Support for checking code asynchronously.""" 2 | 3 | import logging 4 | from concurrent.futures import ProcessPoolExecutor 5 | from pathlib import Path 6 | from typing import List 7 | 8 | from pylama.config import Namespace 9 | from pylama.errors import Error 10 | 11 | try: 12 | import multiprocessing 13 | 14 | CPU_COUNT = multiprocessing.cpu_count() 15 | 16 | except (ImportError, NotImplementedError): 17 | CPU_COUNT = 1 18 | 19 | from pylama.core import run 20 | 21 | LOGGER = logging.getLogger("pylama") 22 | 23 | 24 | def worker(params): 25 | """Do work.""" 26 | path, code, options, rootdir = params 27 | return run(path, code=code, rootdir=rootdir, options=options) 28 | 29 | 30 | def check_async( 31 | paths: List[str], code: str = None, options: Namespace = None, rootdir: Path = None 32 | ) -> List[Error]: 33 | """Check given paths asynchronously.""" 34 | with ProcessPoolExecutor(CPU_COUNT) as pool: 35 | return [ 36 | err 37 | for res in pool.map( 38 | worker, [(path, code, options, rootdir) for path in paths] 39 | ) 40 | for err in res 41 | ] 42 | 43 | 44 | # pylama:ignore=W0212,D210,F0001 45 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import os.path as op 2 | 3 | 4 | def test_remove_duplicates(): 5 | from pylama.errors import Error, remove_duplicates 6 | 7 | errors = [ 8 | Error(source="pycodestyle", text="E701"), 9 | Error(source="pylint", text="C0321"), 10 | ] 11 | errors = list(remove_duplicates(errors)) 12 | assert len(errors) == 1 13 | 14 | 15 | def test_checkpath(parse_options): 16 | from pylama.main import check_paths 17 | 18 | path = op.abspath("dummy.py") 19 | options = parse_options([path]) 20 | result = check_paths(None, options) 21 | assert result 22 | assert result[0].filename == "dummy.py" 23 | 24 | 25 | def test_run_with_code(run, parse_options): 26 | options = parse_options(linters="pyflakes") 27 | errors = run("filename.py", code="unknown_call()", options=options) 28 | assert errors 29 | 30 | 31 | def test_async(parse_options): 32 | from pylama.check_async import check_async 33 | 34 | options = parse_options(config=False) 35 | errors = check_async(["dummy.py"], options=options, rootdir=".") 36 | assert errors 37 | 38 | 39 | def test_errors(): 40 | from pylama.errors import Error 41 | 42 | err = Error(col=0) 43 | assert err.col == 1 44 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Dict 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture(scope="session") 8 | def parse_options(): 9 | from pylama.config import parse_options as parse_options_ 10 | 11 | return parse_options_ 12 | 13 | 14 | @pytest.fixture(scope="session") 15 | def parse_args(parse_options): 16 | def parse_args_(args: str): 17 | return parse_options(args.split()) 18 | 19 | return parse_args_ 20 | 21 | 22 | @pytest.fixture(scope="session") 23 | def run(): 24 | from pylama.core import run as run_ 25 | 26 | return run_ 27 | 28 | 29 | @pytest.fixture(scope="session") 30 | def source(): 31 | dummy = Path(__file__).parent / "../dummy.py" 32 | return dummy.read_text() 33 | 34 | 35 | @pytest.fixture 36 | def context(source, parse_args, request): 37 | from pylama.context import RunContext 38 | 39 | def fabric(*, code: str = None, args: str = None, options=None, **linters_params): 40 | if args: 41 | options = parse_args(args) 42 | ctx = RunContext("dummy.py", source if code is None else code, options=options) 43 | ctx.linters_params = linters_params 44 | request.addfinalizer(lambda: ctx.__exit__(None, None, None)) 45 | return ctx 46 | 47 | return fabric 48 | -------------------------------------------------------------------------------- /pylama/lint/pylama_mccabe.py: -------------------------------------------------------------------------------- 1 | """Code complexity checking.""" 2 | from argparse import ArgumentError 3 | 4 | from mccabe import McCabeChecker 5 | 6 | from pylama.context import RunContext 7 | from pylama.lint import ArgumentParser 8 | from pylama.lint import LinterV2 as Abstract 9 | 10 | 11 | class Linter(Abstract): 12 | """Run complexity checking.""" 13 | 14 | name = "mccabe" 15 | 16 | @classmethod 17 | def add_args(cls, parser: ArgumentParser): 18 | """Add --max-complexity option.""" 19 | try: 20 | parser.add_argument( 21 | "--max-complexity", default=10, type=int, help="Max complexity threshold" 22 | ) 23 | except ArgumentError: 24 | pass 25 | 26 | def run_check(self, ctx: RunContext): 27 | """Run Mccabe code checker.""" 28 | params = ctx.get_params("mccabe") 29 | options = ctx.options 30 | if options: 31 | params.setdefault("max-complexity", options.max_complexity) 32 | 33 | McCabeChecker.max_complexity = int(params.get("max-complexity", 10)) 34 | McCabeChecker._error_tmpl = "%r is too complex (%d)" 35 | number = McCabeChecker._code 36 | for lineno, offset, text, _ in McCabeChecker(ctx.ast, ctx.filename).run(): 37 | ctx.push( 38 | col=offset + 1, 39 | lnum=lineno, 40 | number=number, 41 | text=text, 42 | type="C", 43 | source="mccabe", 44 | ) 45 | 46 | 47 | # pylama:ignore=W0212 48 | -------------------------------------------------------------------------------- /pylama/lint/pylama_pycodestyle.py: -------------------------------------------------------------------------------- 1 | """pycodestyle support.""" 2 | from pycodestyle import BaseReport, Checker, StyleGuide, get_parser 3 | 4 | from pylama.context import RunContext 5 | from pylama.lint import LinterV2 as Abstract 6 | 7 | 8 | class Linter(Abstract): 9 | """pycodestyle runner.""" 10 | 11 | name = "pycodestyle" 12 | 13 | def run_check(self, ctx: RunContext): # noqa 14 | """Check code with pycodestyle.""" 15 | params = ctx.get_params("pycodestyle") 16 | options = ctx.options 17 | if options: 18 | params.setdefault("max_line_length", options.max_line_length) 19 | 20 | if params: 21 | parser = get_parser() 22 | for option in parser.option_list: 23 | if option.dest and option.dest in params: 24 | value = params[option.dest] 25 | if isinstance(value, str): 26 | params[option.dest] = option.convert_value(option, value) 27 | 28 | style = StyleGuide(reporter=_PycodestyleReport, **params) 29 | options = style.options 30 | options.report.ctx = ctx # type: ignore 31 | checker = Checker(ctx.filename, lines=ctx.lines, options=options) 32 | checker.check_all() 33 | 34 | 35 | class _PycodestyleReport(BaseReport): 36 | 37 | ctx: RunContext 38 | 39 | def error(self, line_number, offset, text, _): 40 | """Save errors.""" 41 | code, _, text = text.partition(" ") 42 | self.ctx.push( 43 | text=text, 44 | type=code[0], 45 | number=code, 46 | col=offset + 1, 47 | lnum=line_number, 48 | source="pycodestyle", 49 | ) 50 | -------------------------------------------------------------------------------- /pylama/lint/pylama_pydocstyle.py: -------------------------------------------------------------------------------- 1 | """pydocstyle support.""" 2 | 3 | from argparse import ArgumentParser 4 | 5 | from pydocstyle import ConventionChecker as PyDocChecker 6 | from pydocstyle.violations import conventions 7 | 8 | from pylama.context import RunContext 9 | from pylama.lint import LinterV2 as Abstract 10 | 11 | 12 | class Linter(Abstract): 13 | """Check pydocstyle errors.""" 14 | 15 | name = "pydocstyle" 16 | 17 | @classmethod 18 | def add_args(cls, parser: ArgumentParser): 19 | """Add --max-complexity option.""" 20 | parser.add_argument( 21 | "--pydocstyle-convention", 22 | choices=list(conventions.keys()), 23 | help="choose the basic list of checked errors by specifying an existing convention.", 24 | ) 25 | 26 | def run_check(self, ctx: RunContext): # noqa 27 | """Check code with pydocstyle.""" 28 | params = ctx.get_params("pydocstyle") 29 | options = ctx.options 30 | if options and options.pydocstyle_convention: 31 | params.setdefault("convention", options.pydocstyle_convention) 32 | convention_codes = conventions.get(params.get("convention")) 33 | for err in PyDocChecker().check_source( 34 | ctx.source, 35 | ctx.filename, 36 | params.get("ignore_decorators"), 37 | params.get("ignore_inline_noqa", False), 38 | ): 39 | if convention_codes is None or err.code in convention_codes: 40 | ctx.push( 41 | lnum=err.line, 42 | text=err.short_desc, 43 | type="D", 44 | number=err.code, 45 | source="pydocstyle", 46 | ) 47 | -------------------------------------------------------------------------------- /pylama/lint/pylama_mypy.py: -------------------------------------------------------------------------------- 1 | """MyPy support. 2 | 3 | TODO: Error codes 4 | """ 5 | from __future__ import annotations 6 | 7 | from mypy import api 8 | 9 | from pylama.context import RunContext 10 | from pylama.lint import LinterV2 as Abstract 11 | 12 | 13 | class Linter(Abstract): 14 | """MyPy runner.""" 15 | 16 | name = "mypy" 17 | 18 | def run_check(self, ctx: RunContext): 19 | """Check code with mypy.""" 20 | # Support stdin 21 | args = [ctx.temp_filename, "--follow-imports=skip", "--show-column-numbers"] 22 | stdout, _, _ = api.run(args) # noqa 23 | 24 | for line in stdout.splitlines(): 25 | if not line: 26 | continue 27 | message = _MyPyMessage(line) 28 | if message.valid: 29 | ctx.push( 30 | source="mypy", 31 | lnum=message.line_num, 32 | col=message.column, 33 | text=message.text, 34 | type=message.types.get(message.message_type.strip(), "W"), 35 | ) 36 | 37 | 38 | class _MyPyMessage: 39 | """Parser for a single MyPy output line.""" 40 | 41 | types = {"error": "E", "warning": "W", "note": "N"} 42 | 43 | valid = False 44 | 45 | def __init__(self, line): 46 | self.filename = None 47 | self.line_num = None 48 | self.column = None 49 | 50 | try: 51 | result = line.split(":", maxsplit=4) 52 | self.filename, line_num_txt, column_txt, self.message_type, text = result 53 | except ValueError: 54 | return 55 | 56 | try: 57 | self.line_num = int(line_num_txt.strip()) 58 | self.column = int(column_txt.strip()) 59 | except ValueError: 60 | return 61 | 62 | self.text = text.strip() 63 | self.valid = True 64 | -------------------------------------------------------------------------------- /pylama/core.py: -------------------------------------------------------------------------------- 1 | """Pylama's core functionality. 2 | 3 | Prepare params, check a modeline and run the checkers. 4 | """ 5 | import os.path as op 6 | from pathlib import Path 7 | from typing import List 8 | 9 | from pylama.config import CURDIR, LOGGER, Namespace 10 | from pylama.context import RunContext 11 | from pylama.errors import Error, default_sorter, remove_duplicates 12 | from pylama.lint import LINTERS, LinterV2 13 | 14 | 15 | def run( 16 | path: str, code: str = None, rootdir: Path = CURDIR, options: Namespace = None 17 | ) -> List[Error]: 18 | """Run code checkers with the given params. 19 | 20 | :param path: (str) A file's path. 21 | """ 22 | path = op.relpath(path, rootdir) 23 | 24 | with RunContext(path, code, options) as ctx: 25 | if ctx.skip: 26 | LOGGER.info("Skip checking for path: %s", path) 27 | 28 | else: 29 | for lname in ctx.linters or LINTERS: 30 | linter_cls = LINTERS.get(lname) 31 | if not linter_cls: 32 | continue 33 | linter = linter_cls() 34 | LOGGER.info("Run [%s] %s", lname, path) 35 | if isinstance(linter, LinterV2): 36 | linter.run_check(ctx) 37 | else: 38 | for err_info in linter.run( 39 | ctx.temp_filename, code=ctx.source, params=ctx.get_params(lname) 40 | ): 41 | ctx.push(source=lname, **err_info) 42 | 43 | if not ctx.errors: 44 | return ctx.errors 45 | 46 | errors = list(remove_duplicates(ctx.errors)) 47 | 48 | sorter = default_sorter 49 | if options and options.sort: 50 | sort = options.sort 51 | sorter = lambda err: (sort.get(err.etype, 999), err.lnum) # pylint: disable=C3001 52 | 53 | return sorted(errors, key=sorter) 54 | 55 | 56 | # pylama:ignore=R0912,D210,F0001,C3001 57 | -------------------------------------------------------------------------------- /pylama/lint/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom module loader.""" 2 | from __future__ import annotations 3 | 4 | from argparse import ArgumentParser 5 | from importlib import import_module 6 | from pathlib import Path 7 | from pkgutil import walk_packages 8 | from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type 9 | 10 | from pkg_resources import iter_entry_points 11 | 12 | LINTERS: Dict[str, Type[LinterV2]] = {} 13 | 14 | if TYPE_CHECKING: 15 | from pylama.context import RunContext 16 | 17 | 18 | class LinterMeta(type): 19 | """Register linters.""" 20 | 21 | def __new__(mcs, name, bases, params): 22 | """Register linters.""" 23 | cls: Type[LinterV2] = super().__new__(mcs, name, bases, params) 24 | if cls.name is not None: 25 | LINTERS[cls.name] = cls 26 | return cls 27 | 28 | 29 | class Linter(metaclass=LinterMeta): 30 | """Abstract class for linter plugin.""" 31 | 32 | name: Optional[str] = None 33 | 34 | @classmethod 35 | def add_args(cls, _: ArgumentParser): 36 | """Add options from linters. 37 | 38 | The method has to be a classmethod. 39 | """ 40 | 41 | def run(self, _path: str, **_meta) -> List[Dict[str, Any]]: # noqa 42 | """Legacy method (support old extenstions).""" 43 | return [] 44 | 45 | 46 | class LinterV2(Linter): 47 | """A new linter class.""" 48 | 49 | def run_check(self, ctx: RunContext): 50 | """Check code.""" 51 | 52 | 53 | # Import default linters 54 | for _, pname, _ in walk_packages([str(Path(__file__).parent)]): # type: ignore 55 | try: 56 | import_module(f"{__name__}.{pname}") 57 | except ImportError: 58 | pass 59 | 60 | # Import installed linters 61 | for entry in iter_entry_points("pylama.linter"): 62 | if entry.name not in LINTERS: 63 | try: 64 | LINTERS[entry.name] = entry.load() 65 | except ImportError: 66 | pass 67 | -------------------------------------------------------------------------------- /tests/test_config_toml.py: -------------------------------------------------------------------------------- 1 | """Test TOML config handling.""" 2 | 3 | from unittest import mock 4 | 5 | from pylama import config_toml 6 | from pylama.config import DEFAULT_SECTION 7 | from pylama.libs import inirama 8 | 9 | CONFIG_TOML = """ 10 | [tool.pylama] 11 | async = 1 12 | ignore = "D203,D213,F0401,C0111,E731,I0011" 13 | linters = "pycodestyle,pyflakes,mccabe,pydocstyle,pylint,mypy" 14 | skip = "pylama/inirama.py,pylama/libs/*" 15 | verbose = 0 16 | max_line_length = 100 17 | 18 | [tool.pylama.linter.pyflakes] 19 | builtins = "_" 20 | 21 | [tool.pylama.linter.pylint] 22 | ignore = "R,E1002,W0511,C0103,C0204" 23 | 24 | [[tool.pylama.files]] 25 | path = "pylama/core.py" 26 | ignore = "C901,R0914" 27 | 28 | [[tool.pylama.files]] 29 | path = "pylama/main.py" 30 | ignore = "R0914,W0212,C901,E1103" 31 | 32 | [[tool.pylama.files]] 33 | path = "tests/*" 34 | ignore = "D,C,W,E1103" 35 | """ 36 | 37 | CONFIG_INI=""" 38 | [pylama] 39 | async = 1 40 | ignore = D203,D213,F0401,C0111,E731,I0011 41 | linters = pycodestyle,pyflakes,mccabe,pydocstyle,pylint,mypy 42 | skip = pylama/inirama.py,pylama/libs/* 43 | verbose = 0 44 | max_line_length = 100 45 | 46 | [pylama:pyflakes] 47 | builtins = _ 48 | 49 | [pylama:pylint] 50 | ignore=R,E1002,W0511,C0103,C0204 51 | 52 | [pylama:pylama/core.py] 53 | ignore = C901,R0914 54 | 55 | [pylama:pylama/main.py] 56 | ignore = R0914,W0212,C901,E1103 57 | 58 | [pylama:tests/*] 59 | ignore = D,C,W,E1103 60 | """ 61 | 62 | def test_toml_parsing_matches_ini(): 63 | """Ensure the parsed TOML namepsace matches INI parsing.""" 64 | with mock.patch("pylama.libs.inirama.io.open", mock.mock_open(read_data=CONFIG_INI)): 65 | ini = inirama.Namespace() 66 | ini.default_section = DEFAULT_SECTION 67 | ini.read("ini") 68 | 69 | with mock.patch("pylama.libs.inirama.io.open", mock.mock_open(read_data=CONFIG_TOML)): 70 | toml = config_toml.Namespace() 71 | toml.default_section = DEFAULT_SECTION 72 | toml.read("toml") 73 | 74 | assert ini.sections == toml.sections 75 | -------------------------------------------------------------------------------- /pylama/lint/pylama_pyflakes.py: -------------------------------------------------------------------------------- 1 | """Pyflakes support.""" 2 | 3 | from pyflakes import checker 4 | 5 | from pylama.context import RunContext 6 | from pylama.lint import LinterV2 as Abstract 7 | 8 | m = checker.messages 9 | CODES = { 10 | m.UnusedImport.message: "W0611", 11 | m.RedefinedWhileUnused.message: "W0404", 12 | m.ImportShadowedByLoopVar.message: "W0621", 13 | m.ImportStarUsed.message: "W0401", 14 | m.ImportStarUsage.message: "W0401", 15 | m.UndefinedName.message: "E0602", 16 | m.DoctestSyntaxError.message: "W0511", 17 | m.UndefinedExport.message: "E0603", 18 | m.UndefinedLocal.message: "E0602", 19 | m.DuplicateArgument.message: "E1122", 20 | m.LateFutureImport.message: "W0410", 21 | m.UnusedVariable.message: "W0612", 22 | m.ReturnOutsideFunction.message: "E0104", 23 | } 24 | 25 | # RedefinedInListComp and ReturnWithArgsInsideGenerator were removed at pyflakes 2.5.0: 26 | # https://github.com/PyCQA/pyflakes/commit/2246217295dc8cb30ef4a7b9d8dc449ce32e603a 27 | if hasattr(m, "RedefinedInListComp"): 28 | CODES[m.RedefinedInListComp.message] = "W0621" 29 | if hasattr(m, "ReturnWithArgsInsideGenerator"): 30 | CODES[m.ReturnWithArgsInsideGenerator.message] = "E0106" 31 | 32 | 33 | class Linter(Abstract): 34 | """Pyflakes runner.""" 35 | 36 | name = "pyflakes" 37 | 38 | def run_check(self, context: RunContext): # noqa 39 | """Check code with pyflakes.""" 40 | params = context.get_params("pyflakes") 41 | builtins = params.get("builtins", "") 42 | if builtins: 43 | builtins = builtins.split(",") 44 | 45 | check = checker.Checker(context.ast, context.filename, builtins=builtins) 46 | for msg in check.messages: 47 | context.push( 48 | lnum=msg.lineno, 49 | col=msg.col + 1, 50 | text=msg.message % msg.message_args, 51 | number=CODES.get(msg.message, ""), 52 | source="pyflakes", 53 | ) 54 | 55 | 56 | # pylama:ignore=E501,C0301 57 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MODULE=pylama 2 | SPHINXBUILD=sphinx-build 3 | ALLSPHINXOPTS= -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 4 | BUILDDIR=_build 5 | VIRTUAL_ENV ?= env 6 | 7 | LIBSDIR=$(CURDIR)/libs 8 | 9 | .PHONY: help 10 | # target: help - Display callable targets 11 | help: 12 | @egrep "^# target:" [Mm]akefile 13 | 14 | $(VIRTUAL_ENV): setup.cfg requirements/requirements.txt requirements/requirements-tests.txt 15 | @[ -d $(VIRTUAL_ENV) ] || python -m venv $(VIRTUAL_ENV) 16 | @$(VIRTUAL_ENV)/bin/pip install -e .[tests] 17 | @touch $(VIRTUAL_ENV) 18 | 19 | .PHONY: clean 20 | # target: clean - Clean repo 21 | clean: 22 | @rm -rf build dist docs/_build *.egg 23 | @find . -name "*.pyc" -delete 24 | @find . -name "*.orig" -delete 25 | @rm -rf $(CURDIR)/libs 26 | 27 | # ============== 28 | # Bump version 29 | # ============== 30 | 31 | .PHONY: release 32 | VERSION?=minor 33 | # target: release - Bump version 34 | release minor: 35 | @pip install bumpversion 36 | @bumpversion $(VERSION) 37 | @git checkout master 38 | @git merge develop 39 | @git checkout develop 40 | @git push --all 41 | @git push --tags 42 | 43 | .PHONY: major 44 | major: 45 | make release VERSION=major 46 | 47 | .PHONY: patch 48 | patch: 49 | make release VERSION=patch 50 | 51 | # =============== 52 | # Build package 53 | # =============== 54 | 55 | .PHONY: upload 56 | # target: upload - Upload module on PyPi 57 | upload: clean 58 | @git push --all 59 | @git push --tags 60 | @pip install twine wheel 61 | @python setup.py sdist bdist_wheel 62 | @twine upload dist/*.tar.gz || true 63 | @twine upload dist/*.whl || true 64 | 65 | # ============= 66 | # Development 67 | # ============= 68 | 69 | .PHONY: t 70 | t test: $(VIRTUAL_ENV) 71 | @pytest --pylama pylama 72 | @pytest tests 73 | 74 | mypy: $(VIRTUAL_ENV) 75 | mypy pylama 76 | 77 | .PHONY: audit 78 | audit: 79 | @python -m "pylama.main" 80 | 81 | .PHONY: docs 82 | docs: docs 83 | @python setup.py build_sphinx --source-dir=docs/ --build-dir=docs/_build --all-files 84 | 85 | docker: 86 | docker build -t pylama . 87 | 88 | docker-sh: 89 | docker run --rm -it pylama sh 90 | 91 | docker-test: 92 | docker run --rm -it pylama pytest --pylama pylama 93 | -------------------------------------------------------------------------------- /tests/test_shell.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | 4 | def test_shell(): 5 | from pylama.main import shell 6 | 7 | errors = shell('-o dummy dummy.py'.split(), error=False) 8 | assert errors 9 | 10 | errors = shell(['unknown.py'], error=False) 11 | assert not errors 12 | 13 | 14 | def test_sort(parse_options): 15 | from pylama.core import run 16 | 17 | options = parse_options() 18 | options.sort = {'C': 1, 'D': 2} 19 | errors = run('dummy.py', options=options) 20 | assert errors[0].etype == 'C' 21 | 22 | 23 | def test_linters_params(parse_options, run): 24 | options = parse_options(linters='mccabe', config=False) 25 | options.linters_params['mccabe'] = {'max-complexity': '1'} 26 | errors = run('dummy.py', options=options) 27 | assert len(errors) == 1 28 | 29 | options.linters_params['mccabe'] = {'max-complexity': '20'} 30 | errors = run('dummy.py', options=options) 31 | assert not errors 32 | 33 | 34 | def test_ignore_select(parse_options, run): 35 | options = parse_options() 36 | options.ignore = {'E301', 'D102'} 37 | options.linters = ['pycodestyle', 'pydocstyle', 'pyflakes', 'mccabe'] 38 | errors = run('dummy.py', options=options) 39 | assert errors 40 | for err in errors: 41 | assert err.number not in options.ignore 42 | 43 | numbers = [error.number for error in errors] 44 | assert 'D100' in numbers 45 | assert 'E301' not in numbers 46 | assert 'D102' not in numbers 47 | 48 | options.ignore = {'E', 'D', 'W'} 49 | errors = run('dummy.py', options=options) 50 | assert not errors 51 | 52 | options.select = {'E301'} 53 | errors = run('dummy.py', options=options) 54 | assert len(errors) == 1 55 | assert errors[0].col 56 | 57 | 58 | def test_skip(parse_options, run): 59 | options = parse_options() 60 | errors = run('dummy.py', options=options, code=( 61 | "undefined()\n" 62 | "# pylama: skip=1" 63 | )) 64 | assert not errors 65 | 66 | 67 | def test_stdin(monkeypatch, parse_args): 68 | monkeypatch.setattr('sys.stdin', io.StringIO('unknown_call()\ndef no_doc():\n pass\n\n')) 69 | options = parse_args("--from-stdin dummy.py") 70 | assert options.from_stdin 71 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | python-tag = py3 3 | 4 | [metadata] 5 | name = pylama 6 | version = attr: pylama.__version__ 7 | url = https://github.com/klen/pylama 8 | description = Code audit tool for python 9 | long_description = file: README.rst 10 | author = Kirill Klenov 11 | author_email = horneds@gmail.com 12 | license = MIT 13 | license_files = LICENSE 14 | keywords = qa, linter, pydocstyle, pycodestyle, mccabe, pylint 15 | project_urls = 16 | Documentation = https://klen.github.io/pylama 17 | Source code = https://github.com/klen/pylama 18 | Issue tracker = https://github.com/klen/pylama/issues 19 | classifiers = 20 | Development Status :: 5 - Production/Stable 21 | Environment :: Console 22 | Intended Audience :: Developers 23 | Intended Audience :: System Administrators 24 | License :: OSI Approved :: MIT License 25 | Programming Language :: Python 26 | Programming Language :: Python :: 3 27 | Programming Language :: Python :: 3.10 28 | Programming Language :: Python :: 3.7 29 | Programming Language :: Python :: 3.8 30 | Programming Language :: Python :: 3.9 31 | Topic :: Software Development :: Quality Assurance 32 | Topic :: Software Development :: Testing 33 | 34 | [options] 35 | packages = pylama 36 | python_requires = >= 3.7 37 | include_package_data = True 38 | 39 | [options.package_data] 40 | pylama = 41 | py.typed 42 | 43 | [options.entry_points] 44 | console_scripts = 45 | pylama = pylama.main:shell 46 | pytest11 = 47 | pylama = pylama.pytest 48 | 49 | [tool:pytest] 50 | addopts = -xsv 51 | 52 | [pylama] 53 | async = 1 54 | ignore = D203,D213,F0401,C0111,E731,I0011 55 | linters = pycodestyle,pyflakes,mccabe,pydocstyle,pylint,mypy 56 | skip = pylama/inirama.py,pylama/libs/* 57 | verbose = 0 58 | max_line_length = 100 59 | 60 | [pylama:pyflakes] 61 | builtins = _ 62 | 63 | [pylama:pylint] 64 | ignore=R,E1002,W0511,C0103,C0204,W0012 65 | 66 | [pylama:pylama/core.py] 67 | ignore = C901,R0914 68 | 69 | [pylama:pylama/main.py] 70 | ignore = R0914,W0212,C901,E1103 71 | 72 | [pylama:tests/*] 73 | ignore = D,C,W,E1103 74 | 75 | [mypy] 76 | ignore_missing_imports = True 77 | 78 | [tox:tox] 79 | envlist = py37,py38,py39,py310 80 | 81 | [testenv] 82 | deps = -e .[tests] 83 | commands= 84 | pylama pylama 85 | pytest --pylama pylama {posargs} 86 | pytest tests -s {posargs} 87 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Setup pylama configuration.""" 3 | 4 | import os 5 | import sys 6 | 7 | import pkg_resources 8 | 9 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 10 | 11 | # Add any Sphinx extension module names here, as strings. They can be extensions 12 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 13 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx_copybutton"] 14 | 15 | # The suffix of source filenames. 16 | source_suffix = ".rst" 17 | 18 | # The master toctree document. 19 | master_doc = "index" 20 | 21 | # General information about the project. 22 | project = "Pylama" 23 | copyright = "2013, Kirill Klenov" 24 | 25 | # The version info for the project you're documenting, acts as replacement for 26 | # |version| and |release|, also used in various other places throughout the 27 | # built documents. 28 | try: 29 | release = pkg_resources.get_distribution("pylama").version 30 | except pkg_resources.DistributionNotFound: 31 | print("To build the documentation, The distribution information of Muffin") 32 | print("Has to be available. Either install the package into your") 33 | print('development environment or run "setup.py develop" to setup the') 34 | print("metadata. A virtualenv is recommended!") 35 | sys.exit(1) 36 | del pkg_resources 37 | 38 | version = release 39 | 40 | # List of patterns, relative to source directory, that match files and 41 | # directories to ignore when looking for source files. 42 | exclude_patterns = ["_build"] 43 | 44 | # If false, no module index is generated. 45 | html_use_modindex = False 46 | 47 | html_show_sphinx = False 48 | htmlhelp_basename = "Pylamadoc" 49 | latex_documents = [ 50 | ("index", "Pylama.tex", "Pylama Documentation", "Kirill Klenov", "manual"), 51 | ] 52 | latex_use_modindex = False 53 | latex_use_parts = True 54 | man_pages = [("index", "Pylama", "Pylama Documentation", ["Kirill Klenov"], 1)] 55 | pygments_style = "tango" 56 | 57 | # The theme to use for HTML and HTML Help pages. Major themes that come with 58 | # Sphinx are currently 'default' and 'sphinxdoc'. 59 | html_theme = "sphinx_rtd_theme" 60 | 61 | # Theme options are theme-specific and customize the look and feel of a theme 62 | # further. For a list of options available for each theme, see the 63 | # documentation. 64 | html_theme_options = {"github_url": "https://github.com/klen/pylama"} 65 | -------------------------------------------------------------------------------- /pylama/lint/pylama_radon.py: -------------------------------------------------------------------------------- 1 | """Support Radon. 2 | 3 | Supports stdin. 4 | """ 5 | 6 | from argparse import ArgumentError, ArgumentParser 7 | 8 | from radon.complexity import add_inner_blocks 9 | from radon.visitors import ComplexityVisitor 10 | 11 | from pylama.context import RunContext 12 | from pylama.lint import LinterV2 as Abstract 13 | 14 | 15 | class Linter(Abstract): 16 | """Radon runner.""" 17 | 18 | name = "radon" 19 | 20 | @classmethod 21 | def add_args(cls, parser: ArgumentParser): 22 | """Add --max-complexity option.""" 23 | parser.add_argument( 24 | "--radon-no-assert", 25 | default=False, 26 | action="store_true", 27 | help="Ignore `assert` statements.", 28 | ) 29 | parser.add_argument( 30 | "--radon-show-closures", 31 | default=False, 32 | action="store_true", 33 | help="Increase complexity on closures.", 34 | ) 35 | try: 36 | parser.add_argument( 37 | "--max-complexity", 38 | default=10, 39 | type=int, 40 | help="Max complexity threshold", 41 | ) 42 | except ArgumentError: 43 | pass 44 | 45 | def run_check(self, ctx: RunContext): # noqa # noqa 46 | """Check code with Radon.""" 47 | params = ctx.get_params("radon") 48 | options = ctx.options 49 | if options: 50 | params.setdefault("complexity", options.max_complexity) 51 | params.setdefault("no_assert", options.radon_no_assert) 52 | params.setdefault("show_closures", options.radon_show_closures) 53 | 54 | complexity = params.get("complexity", 10) 55 | no_assert = params.get("no_assert", False) 56 | show_closures = params.get("show_closures", False) 57 | visitor = ComplexityVisitor.from_code(ctx.source, no_assert=no_assert) 58 | blocks = visitor.blocks 59 | if show_closures: 60 | blocks = add_inner_blocks(blocks) 61 | for block in visitor.blocks: 62 | if block.complexity > complexity: 63 | ctx.push( 64 | lnum=block.lineno, 65 | col=block.col_offset + 1, 66 | source="radon", 67 | type="R", 68 | number="R901", 69 | text=f"{block.name} is too complex {block.complexity}", 70 | ) 71 | -------------------------------------------------------------------------------- /pylama/lint/pylama_vulture.py: -------------------------------------------------------------------------------- 1 | """Support Vulture.""" 2 | from argparse import ArgumentParser 3 | 4 | from vulture.core import ERROR_CODES, Vulture, make_config 5 | 6 | from pylama.context import RunContext 7 | from pylama.lint import LinterV2 as BaseLinter 8 | 9 | 10 | class Linter(BaseLinter): 11 | """vulture runner.""" 12 | 13 | name = "vulture" 14 | 15 | @classmethod 16 | def add_args(cls, parser: ArgumentParser): 17 | """Add --max-complexity option.""" 18 | parser.add_argument( 19 | "--vulture-min-confidence", 20 | type=int, 21 | help="Minimum confidence (between 0 and 100) for code to be reported as unused.", 22 | ) 23 | parser.add_argument( 24 | "--vulture-ignore-names", 25 | help="Comma-separated list of names to ignore", 26 | ) 27 | parser.add_argument( 28 | "--vulture-ignore-decorators", 29 | help="Comma-separated list of decorators to ignore", 30 | ) 31 | 32 | def run_check(self, ctx: RunContext): # noqa 33 | """Check code with vulture.""" 34 | params = ctx.get_params("vulture") 35 | options = ctx.options 36 | if options: 37 | params.setdefault("min-confidence", options.vulture_min_confidence) 38 | params.setdefault("ignore-names", options.vulture_ignore_names) 39 | params.setdefault("ignore-decorators", options.vulture_ignore_decorators) 40 | 41 | config = make_config(parse_params(ctx.filename, params)) 42 | vulture = Vulture( 43 | verbose=config["verbose"], 44 | ignore_names=config["ignore_names"], 45 | ignore_decorators=config["ignore_decorators"], 46 | ) 47 | vulture.scan(ctx.source, filename=ctx.filename) 48 | unused_code_items = vulture.get_unused_code( 49 | min_confidence=config["min_confidence"], sort_by_size=config["sort_by_size"] 50 | ) 51 | for item in unused_code_items: 52 | error_code = ERROR_CODES[item.typ] 53 | ctx.push( 54 | source="vulture", 55 | type="R", 56 | lnum=item.first_lineno, 57 | number=error_code, 58 | text=f"{item.message} ({item.confidence}% confidence)", 59 | ) 60 | 61 | 62 | def parse_params(path, params=None): 63 | """Convert params from pylama.""" 64 | return [f"--{key}={value}" for key, value in params.items() if value] + [path] 65 | -------------------------------------------------------------------------------- /tests/test_context.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def test_modeline(context): 5 | ctx = context(code=("def test():\n" " pass\n\n" "# pylama:ignore=D:select=D100")) 6 | assert ctx.select == {"D100"} 7 | assert ctx.ignore == {"D"} 8 | 9 | 10 | def test_filter(parse_args, context): 11 | ctx = context() 12 | assert not ctx.select 13 | assert not ctx.ignore 14 | ctx.push(number="D100") 15 | ctx.push(number="D200") 16 | ctx.push(number="E300") 17 | assert ctx.errors 18 | assert len(ctx.errors) == 3 19 | 20 | options = parse_args("--ignore=D,E300 --select=D100 dummy.py") 21 | ctx = context(options=options) 22 | assert ctx.select 23 | assert ctx.ignore 24 | 25 | ctx.push(number="D100") 26 | ctx.push(number="D200") 27 | ctx.push(number="E300") 28 | assert ctx.errors 29 | assert len(ctx.errors) == 1 30 | assert ctx.errors[0].number == "D100" 31 | 32 | ctx = context(options=options, pydocstyle={"select": "D200"}) 33 | ctx.push(number="D100", source="pydocstyle") 34 | ctx.push(number="D200", source="pydocstyle") 35 | ctx.push(number="E300", source="pydocstyle") 36 | assert ctx.errors 37 | assert len(ctx.errors) == 2 38 | 39 | 40 | def test_context_doesnt_suppress_exception(context): 41 | ctx = context() 42 | 43 | with pytest.raises(Exception): 44 | with ctx: 45 | raise Exception() 46 | 47 | 48 | def test_get_params_doesnt_fail_on_subsequent_invocation(context): 49 | linter_params = {"pycodestyle": {"ignore": "D203,W503"}} 50 | 51 | ctx = context(**linter_params) 52 | ctx.get_params("pycodestyle") 53 | 54 | ctx = context(**linter_params) 55 | ctx.get_params("pycodestyle") 56 | 57 | 58 | def test_context_linters_params(context): 59 | params = {"pylint": {"good_names": "f"}} 60 | ctx = context(**params) 61 | lparams = ctx.get_params("pylint") 62 | assert lparams 63 | lparams["enable"] = True 64 | assert "enable" in lparams 65 | 66 | ctx = context(**params) 67 | lparams = ctx.get_params("pylint") 68 | assert lparams 69 | assert "enable" not in lparams 70 | 71 | 72 | def test_context_push_with_empty_file(context): 73 | ctx = context(code="") 74 | ctx.push(number="D100") 75 | assert ctx.errors 76 | 77 | 78 | def test_context_does_not_change_global_options(context, parse_args): 79 | """Ensure a RunContext does not change the passed in options object.""" 80 | options = parse_args(" --select=W123 --ignore=W234 --linters=pylint dummy.py") 81 | ctx = context(options=options) 82 | ctx.update_params(linters="pycodestyle", select="W345", ignore="W678") 83 | 84 | assert ctx.linters is not options.linters 85 | assert ctx.select is not options.select 86 | assert ctx.ignore is not options.ignore 87 | 88 | assert options.linters == ["pylint"] 89 | assert options.select == {"W123"} 90 | assert options.ignore == {"W234"} 91 | -------------------------------------------------------------------------------- /pylama/pytest.py: -------------------------------------------------------------------------------- 1 | """ py.test plugin for checking files with pylama. """ 2 | from __future__ import absolute_import 3 | 4 | import pathlib 5 | from os import path as op 6 | 7 | import pytest 8 | 9 | from pylama.config import CURDIR 10 | from pylama.main import DEFAULT_FORMAT, check_paths, parse_options 11 | 12 | HISTKEY = "pylama/mtimes" 13 | 14 | 15 | def pytest_load_initial_conftests(early_config, *_): 16 | # Marks have to be registered before usage 17 | # to not fail with --strict command line argument 18 | early_config.addinivalue_line( 19 | "markers", "pycodestyle: Mark test as using pylama code audit tool." 20 | ) 21 | 22 | 23 | def pytest_addoption(parser): 24 | group = parser.getgroup("general") 25 | group.addoption( 26 | "--pylama", 27 | action="store_true", 28 | help="perform some pylama code checks on .py files", 29 | ) 30 | 31 | 32 | def pytest_sessionstart(session): 33 | config = session.config 34 | if config.option.pylama and getattr(config, "cache", None): 35 | config._pylamamtimes = config.cache.get(HISTKEY, {}) 36 | 37 | 38 | def pytest_sessionfinish(session): 39 | config = session.config 40 | if hasattr(config, "_pylamamtimes"): 41 | config.cache.set(HISTKEY, config._pylamamtimes) 42 | 43 | 44 | def pytest_collect_file(path, parent): 45 | config = parent.config 46 | if config.option.pylama and path.ext == ".py": 47 | return PylamaFile.from_parent(parent, path=pathlib.Path(path)) 48 | return None 49 | 50 | 51 | class PylamaError(Exception): 52 | """indicates an error during pylama checks.""" 53 | 54 | 55 | class PylamaFile(pytest.File): 56 | def collect(self): 57 | return [PylamaItem.from_parent(self, name="pylama")] 58 | 59 | 60 | class PylamaItem(pytest.Item): 61 | def __init__(self, *args, **kwargs): 62 | super().__init__(*args, **kwargs) 63 | self.add_marker("pycodestyle") 64 | self.cache = None 65 | self._pylamamtimes = None 66 | 67 | def setup(self): 68 | if not getattr(self.config, "cache", None): 69 | return False 70 | 71 | self.cache = True 72 | self._pylamamtimes = self.fspath.mtime() 73 | pylamamtimes = self.config._pylamamtimes 74 | old = pylamamtimes.get(str(self.fspath), 0) 75 | if old == self._pylamamtimes: 76 | pytest.skip("file(s) previously passed Pylama checks") 77 | 78 | return True 79 | 80 | def runtest(self): 81 | errors = check_file(self.fspath) 82 | if errors: 83 | out = "\n".join(err.format(DEFAULT_FORMAT) for err in errors) 84 | raise PylamaError(out) 85 | 86 | # update mtime only if test passed 87 | # otherwise failures would not be re-run next time 88 | if self.cache: 89 | self.config._pylamamtimes[str(self.fspath)] = self._pylamamtimes 90 | 91 | def repr_failure(self, excinfo, style=None): 92 | if excinfo.errisinstance(PylamaError): 93 | return excinfo.value.args[0] 94 | return super().repr_failure(excinfo, style) 95 | 96 | 97 | def check_file(path): 98 | options = parse_options() 99 | path = op.relpath(str(path), CURDIR) 100 | return check_paths([path], options, rootdir=CURDIR) 101 | 102 | 103 | # pylama:ignore=D,E1002,W0212,F0001,C0115,C0116 104 | -------------------------------------------------------------------------------- /pylama/lint/pylama_pylint.py: -------------------------------------------------------------------------------- 1 | """Pylint integration to Pylama.""" 2 | import logging 3 | from argparse import ArgumentParser 4 | from os import environ 5 | from pathlib import Path 6 | from typing import Dict 7 | 8 | from pylint.interfaces import CONFIDENCE_LEVELS 9 | from pylint.lint import Run 10 | from pylint.reporters import BaseReporter 11 | 12 | from pylama.context import RunContext 13 | from pylama.lint import LinterV2 as BaseLinter 14 | 15 | HOME_RCFILE = Path(environ.get("HOME", "")) / ".pylintrc" 16 | 17 | 18 | logger = logging.getLogger("pylama") 19 | 20 | 21 | class Linter(BaseLinter): 22 | """Check code with Pylint.""" 23 | 24 | name = "pylint" 25 | 26 | @classmethod 27 | def add_args(cls, parser: ArgumentParser): 28 | """Add --max-complexity option.""" 29 | parser.add_argument( 30 | "--pylint-confidence", 31 | choices=[cc.name for cc in CONFIDENCE_LEVELS], 32 | help="Only show warnings with the listed confidence levels.", 33 | ) 34 | 35 | def run_check(self, ctx: RunContext): 36 | """Pylint code checking.""" 37 | logger.debug("Start pylint") 38 | params = ctx.get_params("pylint") 39 | options = ctx.options 40 | if options: 41 | params.setdefault("max_line_length", options.max_line_length) 42 | params.setdefault("confidence", options.pylint_confidence) 43 | 44 | params.setdefault("enable", ctx.select | ctx.get_filter("pylint", "select")) 45 | params.setdefault("disable", ctx.ignore | ctx.get_filter("pylint", "ignore")) 46 | # if params.get("disable"): 47 | # params["disable"].add("W0012") 48 | 49 | class Reporter(BaseReporter): 50 | """Handle messages.""" 51 | 52 | def _display(self, _): 53 | pass 54 | 55 | def handle_message(self, msg): 56 | msg_id = msg.msg_id 57 | ctx.push( 58 | filtrate=False, 59 | col=msg.column + 1, 60 | lnum=msg.line, 61 | number=msg_id, 62 | text=msg.msg, 63 | type=msg_id[0], 64 | source="pylint", 65 | ) 66 | 67 | logger.debug(params) 68 | 69 | reporter = Reporter() 70 | args = _Params(params).to_attrs() 71 | Run([ctx.temp_filename] + args, reporter=reporter, exit=False) 72 | 73 | 74 | class _Params: 75 | """Store pylint params.""" 76 | 77 | def __init__(self, params: Dict): 78 | attrs = { 79 | name.replace("_", "-"): self.prepare_value(value) 80 | for name, value in params.items() 81 | if value 82 | } 83 | if HOME_RCFILE.exists(): 84 | attrs["rcfile"] = HOME_RCFILE.as_posix() 85 | 86 | if attrs.get("disable"): 87 | attrs["disable"] += ",W0012" 88 | else: 89 | attrs["disable"] = "W0012" 90 | 91 | self.attrs = attrs 92 | 93 | @staticmethod 94 | def prepare_value(value): 95 | """Prepare value to pylint.""" 96 | if isinstance(value, (list, tuple, set)): 97 | return ",".join(value) 98 | 99 | if isinstance(value, bool): 100 | return "y" if value else "n" 101 | 102 | return str(value) 103 | 104 | def to_attrs(self): 105 | """Convert to argument list.""" 106 | return [f"--{key}={value}" for key, value in self.attrs.items()] # noqa 107 | 108 | def __str__(self): 109 | return " ".join(self.to_attrs()) 110 | 111 | def __repr__(self): 112 | return f"" 113 | 114 | 115 | # pylama:ignore=W0403 116 | -------------------------------------------------------------------------------- /pylama/hook.py: -------------------------------------------------------------------------------- 1 | """SCM hooks. Integration with git and mercurial.""" 2 | 3 | from __future__ import absolute_import 4 | 5 | import sys 6 | from configparser import ConfigParser # noqa 7 | from os import chmod, getcwd 8 | from os import path as op 9 | from subprocess import PIPE, Popen 10 | from typing import List, Tuple 11 | 12 | from pylama.config import parse_options, setup_logger 13 | from pylama.main import LOGGER, check_paths, display_errors 14 | 15 | 16 | def run(command: str) -> Tuple[int, List[bytes], List[bytes]]: 17 | """Run a shell command.""" 18 | with Popen(command.split(), stdout=PIPE, stderr=PIPE) as pipe: 19 | (stdout, stderr) = pipe.communicate() 20 | return ( 21 | pipe.returncode, 22 | [line.strip() for line in stdout.splitlines()], 23 | [line.strip() for line in stderr.splitlines()], 24 | ) 25 | 26 | 27 | def git_hook(error=True): 28 | """Run pylama after git commit.""" 29 | _, files_modified, _ = run("git diff-index --cached --name-only HEAD") 30 | 31 | options = parse_options() 32 | setup_logger(options) 33 | candidates = [f.decode("utf-8") for f in files_modified] 34 | if candidates: 35 | errors = check_paths(candidates, options, rootdir=getcwd()) 36 | display_errors(errors, options) 37 | sys.exit(int(error and bool(errors))) 38 | 39 | 40 | def hg_hook(_, repo, node=None, **kwargs): # noqa 41 | """Run pylama after mercurial commit.""" 42 | seen = set() 43 | candidates = [] 44 | if len(repo): 45 | for rev in range(repo[node], len(repo)): 46 | for file_ in repo[rev].files(): 47 | file_ = op.join(repo.root, file_) 48 | if file_ in seen or not op.exists(file_): 49 | continue 50 | seen.add(file_) 51 | candidates.append(file_) 52 | 53 | options = parse_options() 54 | setup_logger(options) 55 | if candidates: 56 | errors = check_paths(candidates, options) 57 | display_errors(errors, options) 58 | sys.exit(int(bool(errors))) 59 | 60 | 61 | def install_git(path): 62 | """Install hook in Git repository.""" 63 | hook = op.join(path, "pre-commit") 64 | with open(hook, "w", encoding="utf-8") as target: 65 | target.write( 66 | """#!/usr/bin/env python 67 | import sys 68 | from pylama.hook import git_hook 69 | 70 | if __name__ == '__main__': 71 | sys.exit(git_hook()) 72 | """ 73 | ) 74 | chmod(hook, 484) 75 | 76 | 77 | def install_hg(path): 78 | """Install hook in Mercurial repository.""" 79 | hook = op.join(path, "hgrc") 80 | if not op.isfile(hook): 81 | open(hook, "w+", encoding="utf-8").close() 82 | 83 | cfgp = ConfigParser() 84 | with open(hook, "r", encoding="utf-8") as source: 85 | cfgp.read_file(source) 86 | 87 | if not cfgp.has_section("hooks"): 88 | cfgp.add_section("hooks") 89 | 90 | if not cfgp.has_option("hooks", "commit"): 91 | cfgp.set("hooks", "commit", "python:pylama.hooks.hg_hook") 92 | 93 | if not cfgp.has_option("hooks", "qrefresh"): 94 | cfgp.set("hooks", "qrefresh", "python:pylama.hooks.hg_hook") 95 | 96 | with open(hook, "w+", encoding="utf-8") as target: 97 | cfgp.write(target) 98 | 99 | 100 | def install_hook(path): 101 | """Auto definition of SCM and hook installation.""" 102 | is_git = op.join(path, ".git", "hooks") 103 | is_hg = op.join(path, ".hg") 104 | if op.exists(is_git): 105 | install_git(is_git) 106 | LOGGER.warning("Git hook has been installed.") 107 | 108 | elif op.exists(is_hg): 109 | install_hg(is_hg) 110 | LOGGER.warning("Mercurial hook has been installed.") 111 | 112 | else: 113 | LOGGER.error("VCS has not found. Check your path.") 114 | sys.exit(1) 115 | 116 | 117 | # pylama:ignore=F0401,E1103,D210,F0001 118 | -------------------------------------------------------------------------------- /pylama/main.py: -------------------------------------------------------------------------------- 1 | """Pylama's shell support.""" 2 | 3 | import sys 4 | import warnings 5 | from json import dumps 6 | from os import path as op 7 | from os import walk 8 | from pathlib import Path 9 | from typing import List, Optional 10 | 11 | from pylama.check_async import check_async 12 | from pylama.config import CURDIR, Namespace, parse_options, setup_logger 13 | from pylama.core import LOGGER, run 14 | from pylama.errors import Error 15 | from pylama.utils import read_stdin 16 | 17 | DEFAULT_FORMAT = "{filename}:{lnum}:{col} [{etype}] {number} {message} [{source}]" 18 | MESSAGE_FORMATS = { 19 | "pylint": "{filename}:{lnum}: [{etype}] {number} {message} [{source}]", 20 | "pycodestyle": "{filename}:{lnum}:{col} {number} {message} [{source}]", 21 | "parsable": DEFAULT_FORMAT, 22 | } 23 | 24 | 25 | def check_paths( 26 | paths: Optional[List[str]], 27 | options: Namespace, 28 | code: str = None, 29 | rootdir: Path = None, 30 | ) -> List[Error]: 31 | """Check the given paths. 32 | 33 | :param rootdir: Root directory (for making relative file paths) 34 | :param options: Parsed pylama options (from pylama.config.parse_options) 35 | """ 36 | paths = paths or options.paths 37 | if not paths: 38 | return [] 39 | 40 | if code is None: 41 | candidates = [] 42 | for path in paths or options.paths: 43 | if not op.exists(path): 44 | continue 45 | 46 | if not op.isdir(path): 47 | candidates.append(op.abspath(path)) 48 | 49 | for root, _, files in walk(path): 50 | candidates += [op.relpath(op.join(root, f), CURDIR) for f in files] 51 | else: 52 | candidates = [paths[0]] 53 | 54 | if not candidates: 55 | return [] 56 | 57 | if rootdir is None: 58 | path = candidates[0] 59 | rootdir = Path(path if op.isdir(path) else op.dirname(path)) 60 | 61 | candidates = [path for path in candidates if path.endswith(".py")] 62 | 63 | if options.concurrent: 64 | return check_async(candidates, code=code, options=options, rootdir=rootdir) 65 | 66 | errors = [] 67 | for path in candidates: 68 | errors += run(path=path, code=code, rootdir=rootdir, options=options) 69 | 70 | return errors 71 | 72 | 73 | def check_path( 74 | options: Namespace, 75 | rootdir: str = None, 76 | candidates: List[str] = None, 77 | code: str = None, # noqa 78 | ) -> List[Error]: 79 | """Support legacy code.""" 80 | warnings.warn( 81 | "pylama.main.check_path is depricated and will be removed in pylama 9", 82 | DeprecationWarning, 83 | ) 84 | return check_paths( 85 | candidates, 86 | code=code, 87 | options=options, 88 | rootdir=rootdir and Path(rootdir) or None, 89 | ) 90 | 91 | 92 | def shell(args: List[str] = None, error: bool = True): 93 | """Endpoint for console. 94 | 95 | Parse a command arguments, configuration files and run a checkers. 96 | """ 97 | if args is None: 98 | args = sys.argv[1:] 99 | 100 | options = parse_options(args) 101 | setup_logger(options) 102 | LOGGER.info(options) 103 | 104 | # Install VSC hook 105 | if options.hook: 106 | from .hook import install_hook # noqa 107 | 108 | for path in options.paths: 109 | return install_hook(path) 110 | 111 | if options.from_stdin and not options.paths: 112 | LOGGER.error("--from-stdin requires a filename") 113 | return sys.exit(1) 114 | 115 | errors = check_paths( 116 | options.paths, 117 | code=read_stdin() if options.from_stdin else None, 118 | options=options, 119 | rootdir=CURDIR, 120 | ) 121 | display_errors(errors, options) 122 | 123 | if error: 124 | sys.exit(int(bool(errors))) 125 | 126 | return errors 127 | 128 | 129 | def display_errors(errors: List[Error], options: Namespace): 130 | """Format and display the given errors.""" 131 | if options.format == "json": 132 | LOGGER.warning(dumps([err.to_dict() for err in errors])) 133 | 134 | else: 135 | pattern = MESSAGE_FORMATS.get(options.format, DEFAULT_FORMAT) 136 | for err in errors: 137 | LOGGER.warning(err.format(pattern)) 138 | 139 | 140 | if __name__ == "__main__": 141 | shell() 142 | 143 | # pylama:ignore=F0001 144 | -------------------------------------------------------------------------------- /dummy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | # (c) 2005 Divmod, Inc. See LICENSE file for details 4 | 5 | 6 | # commented code 7 | #import os 8 | # from foo import junk 9 | # a = 3 10 | a = 4 11 | #foo(1, 2, 3) 12 | 13 | 14 | class Message(object): 15 | message = '' 16 | message_args = () 17 | def __init__(self, filename, loc, use_column=True): 18 | self.filename = filename 19 | self.lineno = loc.lineno 20 | self.col = getattr(loc, 'col_offset', None) if use_column else None 21 | test = 1 22 | if test == 1: 23 | if test == 1: 24 | return 28 25 | elif test == 2: 26 | return 28 27 | return 28 28 | elif test == 2: 29 | return 28 30 | 31 | def __str__(self): 32 | return '%s:%s: %s' % (self.filename, self.lineno, self.message % self.message_args) 33 | 34 | 35 | class UnusedImport(Message): 36 | message = 'W402 %r imported but unused' 37 | 38 | def __init__(self, filename, lineno, name): 39 | Message.__init__(self, filename, lineno) 40 | self.message_args = (name,) 41 | 42 | 43 | class RedefinedWhileUnused(Message): 44 | message = 'W801 redefinition of unused %r from line %r' 45 | 46 | def __init__(self, filename, lineno, name, orig_lineno): 47 | Message.__init__(self, filename, lineno) 48 | self.message_args = (name, orig_lineno) 49 | 50 | 51 | class ImportShadowedByLoopVar(Message): 52 | message = 'W403 import %r from line %r shadowed by loop variable' 53 | 54 | def __init__(self, filename, lineno, name, orig_lineno): 55 | Message.__init__(self, filename, lineno) 56 | self.message_args = (name, orig_lineno) 57 | 58 | 59 | class ImportStarUsed(Message): 60 | message = "W404 'from %s import *' used; unable to detect undefined names" 61 | 62 | def __init__(self, filename, lineno, modname): 63 | Message.__init__(self, filename, lineno) 64 | self.message_args = (modname,) 65 | 66 | 67 | class UndefinedName(Message): 68 | message = 'W802 undefined name %r' 69 | 70 | def __init__(self, filename, lineno, name): 71 | Message.__init__(self, filename, lineno) 72 | self.message_args = (name,) 73 | 74 | 75 | class UndefinedExport(Message): 76 | message = 'W803 undefined name %r in __all__' 77 | 78 | def __init__(self, filename, lineno, name): 79 | Message.__init__(self, filename, lineno) 80 | self.message_args = (name,) 81 | 82 | 83 | class UndefinedLocal(Message): 84 | message = "W804 local variable %r (defined in enclosing scope on line " \ 85 | "%r) referenced before assignment" 86 | 87 | def __init__(self, filename, lineno, name, orig_lineno): 88 | Message.__init__(self, filename, lineno) 89 | self.message_args = (name, orig_lineno) 90 | 91 | 92 | class DuplicateArgument(Message): 93 | message = 'W805 duplicate argument %r in function definition' 94 | 95 | def __init__(self, filename, lineno, name): 96 | Message.__init__(self, filename, lineno) 97 | self.message_args = (name,) 98 | 99 | 100 | class RedefinedFunction(Message): 101 | message = 'W806 redefinition of function %r from line %r' 102 | 103 | def __init__(self, filename, lineno, name, orig_lineno): 104 | Message.__init__(self, filename, lineno) 105 | self.message_args = (name, orig_lineno) 106 | 107 | 108 | class LateFutureImport(Message): 109 | message = 'W405 future import(s) %r after other statements' 110 | 111 | def __init__(self, filename, lineno, names): 112 | Message.__init__(self, filename, lineno) 113 | self.message_args = (names,) 114 | 115 | 116 | class UnusedVariable(Message): 117 | """ 118 | Indicates that a variable has been explicitly assigned to but not actually 119 | used. 120 | """ 121 | 122 | message = 'W806 local variable %r is assigned to but never used' 123 | 124 | def __init__(self, filename, lineno, names): 125 | Message.__init__(self, filename, lineno) 126 | self.message_args = (names,) 127 | error = 1 # noQa and some comments 128 | another = 42 129 | 130 | 131 | class BadTyping(Message): 132 | """Test the MyPy linting.""" 133 | 134 | message = 'error: No return value expected' 135 | 136 | def bad_method(self): # type: () -> None 137 | """Return type mismatch.""" 138 | return 1 139 | -------------------------------------------------------------------------------- /pylama/errors.py: -------------------------------------------------------------------------------- 1 | """ Don't duplicate same errors from different linters. """ 2 | from __future__ import annotations 3 | 4 | import re 5 | from collections import defaultdict 6 | from typing import Any, DefaultDict, Dict, Generator, List, Set, Tuple 7 | 8 | PATTERN_NUMBER = re.compile(r"^\s*([A-Z]\d+)\s*", re.I) 9 | 10 | DUPLICATES: Dict[Tuple[str, str], Set] = { 11 | key: values # type: ignore 12 | for values in ( 13 | # multiple statements on one line 14 | {("pycodestyle", "E701"), ("pylint", "C0321")}, 15 | # unused variable 16 | {("pylint", "W0612"), ("pyflakes", "W0612")}, 17 | # undefined variable 18 | {("pylint", "E0602"), ("pyflakes", "E0602")}, 19 | # unused import 20 | {("pylint", "W0611"), ("pyflakes", "W0611")}, 21 | # whitespace before ')' 22 | {("pylint", "C0326"), ("pycodestyle", "E202")}, 23 | # whitespace before '(' 24 | {("pylint", "C0326"), ("pycodestyle", "E211")}, 25 | # multiple spaces after operator 26 | {("pylint", "C0326"), ("pycodestyle", "E222")}, 27 | # missing whitespace around operator 28 | {("pylint", "C0326"), ("pycodestyle", "E225")}, 29 | # unexpected spaces 30 | {("pylint", "C0326"), ("pycodestyle", "E251")}, 31 | # long lines 32 | {("pylint", "C0301"), ("pycodestyle", "E501")}, 33 | # statement ends with a semicolon 34 | {("pylint", "W0301"), ("pycodestyle", "E703")}, 35 | # multiple statements on one line 36 | {('pylint", "C0321'), ("pycodestyle", "E702")}, 37 | # bad indentation 38 | {("pylint", "W0311"), ("pycodestyle", "E111")}, 39 | # wildcart import 40 | {("pylint", "W00401"), ("pyflakes", "W0401")}, 41 | # module docstring 42 | {("pydocstyle", "D100"), ("pylint", "C0111")}, 43 | ) 44 | for key in values # type: ignore 45 | } 46 | 47 | 48 | class Error: 49 | """Store an error's information.""" 50 | 51 | __slots__ = "source", "col", "lnum", "etype", "message", "filename", "number" 52 | 53 | def __init__( 54 | self, 55 | source="pylama", 56 | col=1, 57 | lnum=1, 58 | type=None, # pylint: disable=R0913 59 | text="unknown error", 60 | filename="", 61 | number="", 62 | **_, 63 | ): 64 | """Init error information with default values.""" 65 | text = str(text).strip().replace("\n", " ") 66 | if number: 67 | self.number = number 68 | else: 69 | number = PATTERN_NUMBER.match(text) 70 | self.number = number.group(1).upper() if number else "" 71 | 72 | self.etype = type[:1] if type else (number[0] if number else "E") 73 | self.col = max(col, 1) 74 | self.filename = filename 75 | self.source = source 76 | self.lnum = int(lnum) 77 | self.message = text 78 | 79 | def __repr__(self): 80 | return f"" 81 | 82 | def format(self, pattern: str) -> str: 83 | """Format the error with the given pattern.""" 84 | return pattern.format( 85 | filename=self.filename, 86 | lnum=self.lnum, 87 | col=self.col, 88 | message=self.message, 89 | etype=self.etype, 90 | source=self.source, 91 | number=self.number, 92 | ) 93 | 94 | def to_dict(self) -> Dict[str, Any]: 95 | """Return the error as a dict.""" 96 | return { 97 | "source": self.source, 98 | "col": self.col, 99 | "lnum": self.lnum, 100 | "etype": self.etype, 101 | "message": self.message, 102 | "filename": self.filename, 103 | "number": self.number, 104 | } 105 | 106 | 107 | def remove_duplicates(errors: List[Error]) -> Generator[Error, None, None]: 108 | """Filter duplicates from given error's list.""" 109 | passed: DefaultDict[int, Set] = defaultdict(set) 110 | for error in errors: 111 | key = error.source, error.number 112 | if key in DUPLICATES: 113 | if key in passed[error.lnum]: 114 | continue 115 | passed[error.lnum] = DUPLICATES[key] 116 | yield error 117 | 118 | 119 | def default_sorter(err: Error) -> Any: 120 | """Sort by line number.""" 121 | return err.lnum 122 | 123 | 124 | # pylama:ignore=W0622,D,R0924 125 | -------------------------------------------------------------------------------- /tests/test_linters.py: -------------------------------------------------------------------------------- 1 | def test_skip_optional_if_not_installed(): 2 | from pylama.lint import LINTERS 3 | 4 | assert "fake" not in LINTERS 5 | 6 | 7 | def test_mccabe(context): 8 | from pylama.lint import LINTERS 9 | 10 | mccabe = LINTERS["mccabe"] 11 | assert mccabe 12 | 13 | ctx = context(mccabe={"max-complexity": 3}) 14 | mccabe().run_check(ctx) 15 | errors = ctx.errors 16 | assert errors 17 | assert errors[0].number 18 | assert not errors[0].message.startswith(errors[0].number) 19 | assert errors[0].col == 5 20 | 21 | ctx = context(args="--max-complexity 3") 22 | mccabe().run_check(ctx) 23 | errors = ctx.errors 24 | assert errors 25 | 26 | 27 | def test_pydocstyle(context): 28 | from pylama.lint import LINTERS 29 | 30 | pydocstyle = LINTERS["pydocstyle"] 31 | assert pydocstyle 32 | 33 | ctx = context() 34 | pydocstyle().run_check(ctx) 35 | errors = ctx.errors 36 | assert errors 37 | assert errors[0].number 38 | assert not errors[0].message.startswith(errors[0].number) 39 | 40 | ctx = context(pydocstyle={"convention": "numpy"}) 41 | pydocstyle().run_check(ctx) 42 | errors2 = ctx.errors 43 | assert errors2 44 | assert len(errors) > len(errors2) 45 | 46 | ctx = context(args="--pydocstyle-convention numpy") 47 | pydocstyle().run_check(ctx) 48 | errors3 = ctx.errors 49 | assert errors3 50 | assert len(errors3) == len(errors2) 51 | 52 | 53 | def test_pycodestyle(context): 54 | from pylama.lint import LINTERS 55 | 56 | pycodestyle = LINTERS["pycodestyle"] 57 | assert pycodestyle 58 | 59 | ctx = context() 60 | pycodestyle().run_check(ctx) 61 | errors = ctx.errors 62 | assert errors 63 | assert errors[0].number 64 | assert not errors[0].message.startswith(errors[0].number) 65 | assert len(errors) == 5 66 | 67 | ctx = context(pycodestyle={"max_line_length": 60}) 68 | pycodestyle().run_check(ctx) 69 | errors2 = ctx.errors 70 | assert errors2 71 | assert len(errors2) > len(errors) 72 | 73 | ctx = context(args="--max-line-length=60") 74 | pycodestyle().run_check(ctx) 75 | errors3 = ctx.errors 76 | assert errors3 77 | assert len(errors3) == len(errors2) 78 | 79 | 80 | def test_pyflakes(context): 81 | from pylama.lint import LINTERS 82 | 83 | pyflakes = LINTERS["pyflakes"] 84 | assert pyflakes 85 | 86 | ctx = context() 87 | pyflakes().run_check(ctx) 88 | errors = ctx.errors 89 | assert errors 90 | assert errors[0].number 91 | assert not errors[0].message.startswith(errors[0].number) 92 | 93 | 94 | def test_eradicate(context): 95 | from pylama.lint import LINTERS 96 | 97 | eradicate = LINTERS["eradicate"] 98 | assert eradicate 99 | 100 | ctx = context() 101 | eradicate().run_check(ctx) 102 | errors = ctx.errors 103 | assert errors 104 | assert errors[0].number 105 | assert not errors[0].message.startswith(errors[0].number) 106 | 107 | ctx = context(code=("#import os\n" "# from foo import junk\n" "#a = 3\n" "a = 4\n")) 108 | eradicate().run_check(ctx) 109 | errors = ctx.errors 110 | assert len(errors) == 3 111 | 112 | ctx = context(code="") 113 | eradicate().run_check(ctx) 114 | errors = ctx.errors 115 | assert not errors 116 | 117 | 118 | def test_mypy(context): 119 | from pylama.lint import LINTERS 120 | 121 | mypy = LINTERS["mypy"] 122 | assert mypy 123 | 124 | ctx = context() 125 | mypy().run_check(ctx) 126 | errors = ctx.errors 127 | assert errors 128 | # assert errors[0]['number'] 129 | # assert not errors[0]['text'].startswith(errors[0]['number']) 130 | 131 | 132 | def test_radon(context): 133 | from pylama.lint import LINTERS 134 | 135 | radon = LINTERS["radon"] 136 | assert radon 137 | 138 | ctx = context(radon={"complexity": 3}) 139 | radon().run_check(ctx) 140 | errors = ctx.errors 141 | assert errors 142 | assert errors[0].number 143 | assert errors[0].col == 1 144 | assert not errors[0].message.startswith(errors[0].number) 145 | 146 | # Issue #164 147 | assert ":" not in errors[0].message 148 | 149 | ctx = context(args="--max-complexity=3 --radon-no-assert") 150 | radon().run_check(ctx) 151 | errors = ctx.errors 152 | assert errors 153 | 154 | 155 | def test_pylint(context): 156 | from pylama.lint import LINTERS 157 | 158 | pylint = LINTERS["pylint"] 159 | assert pylint 160 | 161 | ctx = context() 162 | pylint().run_check(ctx) 163 | errors = ctx.errors 164 | assert errors 165 | assert errors[0].number 166 | assert errors[0].col == 1 167 | assert not errors[0].message.startswith(errors[0].number) 168 | 169 | # Test immutable params 170 | ctx = context() 171 | pylint().run_check(ctx) 172 | assert ctx.errors 173 | assert not ctx.linters_params 174 | 175 | ctx = context(args="--pylint-confidence=HIGH --ignore=C") 176 | pylint().run_check(ctx) 177 | assert not ctx.errors 178 | 179 | ctx = context(pylint={"ignore": "E,R,W,C"}) 180 | pylint().run_check(ctx) 181 | errors = ctx.errors 182 | assert not errors 183 | 184 | ctx = context(args="--ignore=E,R,W,C") 185 | pylint().run_check(ctx) 186 | errors = ctx.errors 187 | assert not errors 188 | 189 | ctx = context(pylint={"disable": "E,R,W,C"}) 190 | pylint().run_check(ctx) 191 | errors = ctx.errors 192 | assert not errors 193 | 194 | 195 | def test_quotes(source): 196 | from pylama.lint import LINTERS, Linter 197 | 198 | quotes = LINTERS["quotes"] 199 | assert quotes 200 | assert issubclass(quotes, Linter) 201 | 202 | errors = quotes().run("dummy.py", code=source) 203 | assert errors 204 | 205 | 206 | def test_vulture(context): 207 | from pylama.lint import LINTERS 208 | 209 | vulture = LINTERS["vulture"] 210 | assert vulture 211 | 212 | ctx = context() 213 | vulture().run_check(ctx) 214 | errors = ctx.errors 215 | assert errors 216 | assert errors[0].number 217 | assert errors[0].col == 1 218 | assert not errors[0].message.startswith(errors[0].number) 219 | 220 | ctx = context(args="--vulture-min-confidence=80") 221 | vulture().run_check(ctx) 222 | assert not ctx.errors 223 | -------------------------------------------------------------------------------- /pylama/context.py: -------------------------------------------------------------------------------- 1 | """Manage resources.""" 2 | 3 | import ast 4 | import os.path as op 5 | import re 6 | from argparse import Namespace 7 | from copy import copy 8 | from functools import lru_cache 9 | from pathlib import Path 10 | from tempfile import NamedTemporaryFile, mkdtemp 11 | from typing import Dict, List, Set 12 | 13 | from pylama.errors import Error 14 | from pylama.utils import read 15 | 16 | # Parse modeline 17 | MODELINE_RE = re.compile( 18 | r"^\s*#\s+(?:pylama:)\s*((?:[\w_]*=[^:\n\s]+:?)+)", re.I | re.M 19 | ).search 20 | 21 | SKIP_PATTERN = re.compile(r"# *noqa\b", re.I).search 22 | 23 | 24 | class RunContext: # pylint: disable=R0902 25 | """Manage resources.""" 26 | 27 | __slots__ = ( 28 | "errors", 29 | "options", 30 | "skip", 31 | "ignore", 32 | "select", 33 | "linters", 34 | "linters_params", 35 | "filename", 36 | "_ast", 37 | "_from_stdin", 38 | "_source", 39 | "_tempfile", 40 | "_lines", 41 | ) 42 | 43 | def __init__(self, filename: str, source: str = None, options: Namespace = None): 44 | """Initialize the class.""" 45 | self.errors: List[Error] = [] 46 | self.options = options 47 | self.skip = False 48 | self.ignore = set() 49 | self.select = set() 50 | self.linters = [] 51 | self.linters_params = {} 52 | 53 | self._ast = None 54 | self._from_stdin = source is not None 55 | self._source = source 56 | self._tempfile = None 57 | self._lines = None 58 | 59 | if options: 60 | if options.abspath: 61 | filename = op.abspath(filename) 62 | self.skip = options.skip and any( 63 | ptrn.match(filename) for ptrn in options.skip 64 | ) 65 | self.linters.extend(options.linters) 66 | self.ignore |= options.ignore 67 | self.select |= options.select 68 | self.linters_params.update(options.linters_params) 69 | 70 | for mask in options.file_params: 71 | if mask.match(filename): 72 | fparams = options.file_params[mask] 73 | self.update_params(**fparams) 74 | 75 | self.filename = filename 76 | 77 | # Read/parse modeline 78 | if not self.skip: 79 | modeline = MODELINE_RE(self.source) 80 | if modeline: 81 | values = modeline.group(1).split(":") 82 | self.update_params(**dict(v.split("=", 1) for v in values)) # type: ignore 83 | 84 | def __enter__(self): 85 | """Enter to context.""" 86 | return self 87 | 88 | def __exit__(self, etype, evalue, _): 89 | """Exit from the context.""" 90 | if self._tempfile is not None: 91 | tmpfile = Path(self._tempfile) 92 | tmpfile.unlink() 93 | tmpfile.parent.rmdir() 94 | 95 | if evalue is not None: 96 | if etype is IOError: 97 | self.push(text=f"{evalue}", number="E001") 98 | elif etype is UnicodeDecodeError: 99 | self.push(text=f"UnicodeError: {self.filename}", number="E001") 100 | elif etype is SyntaxError: 101 | self.push( 102 | lnum=evalue.lineno, 103 | col=evalue.offset, 104 | text=f"SyntaxError: {evalue.args[0]}", 105 | ) 106 | else: 107 | self.push(lnum=1, col=1, text=str(evalue)) 108 | return False 109 | 110 | return True 111 | 112 | @property 113 | def source(self): 114 | """Get the current source code.""" 115 | if self._source is None: 116 | self._source = read(self.filename) 117 | return self._source 118 | 119 | @property 120 | def lines(self): 121 | """Split source to lines.""" 122 | if self._lines is None: 123 | self._lines = self.source.splitlines(True) 124 | return self._lines 125 | 126 | @property 127 | def ast(self): 128 | """Get the AST for the source.""" 129 | if self._ast is None: 130 | self._ast = compile(self.source, self.filename, "exec", ast.PyCF_ONLY_AST) 131 | return self._ast 132 | 133 | @property 134 | def temp_filename(self): 135 | """Get a filename for run external command.""" 136 | if not self._from_stdin: 137 | return self.filename 138 | 139 | if self._tempfile is None: 140 | file = NamedTemporaryFile( # noqa 141 | "w", 142 | encoding="utf8", 143 | suffix=".py", 144 | dir=mkdtemp(prefix="pylama_"), 145 | delete=False, 146 | ) 147 | file.write(self.source) 148 | file.close() 149 | self._tempfile = file.name 150 | 151 | return self._tempfile 152 | 153 | def update_params(self, ignore=None, select=None, linters=None, skip=None, **_): 154 | """Update general params (from file configs or modeline).""" 155 | if select: 156 | self.select |= set(select.split(",")) 157 | if ignore: 158 | self.ignore |= set(ignore.split(",")) 159 | if linters: 160 | self.linters = linters.split(",") 161 | if skip is not None: 162 | self.skip = bool(int(skip)) 163 | 164 | @lru_cache(42) 165 | def get_params(self, name: str) -> Dict: 166 | """Get params for a linter with the given name.""" 167 | lparams = copy(self.linters_params.get(name, {})) 168 | for key in ("ignore", "select"): 169 | if key in lparams and not isinstance(lparams[key], set): 170 | lparams[key] = set(lparams[key].split(",")) 171 | return lparams 172 | 173 | @lru_cache(42) 174 | def get_filter(self, name: str, key: str) -> Set: 175 | """Get select/ignore from linter params.""" 176 | lparams = self.get_params(name) 177 | return lparams.get(key, set()) 178 | 179 | def push(self, filtrate: bool = True, **params): 180 | """Record an error.""" 181 | err = Error(filename=self.filename, **params) 182 | number = err.number 183 | 184 | if len(self.lines) >= err.lnum and SKIP_PATTERN(self.lines[err.lnum - 1]): 185 | return None 186 | 187 | if filtrate: 188 | 189 | for rule in self.select | self.get_filter(err.source, "select"): 190 | if number.startswith(rule): 191 | return self.errors.append(err) 192 | 193 | for rule in self.ignore | self.get_filter(err.source, "ignore"): 194 | if number.startswith(rule): 195 | return None 196 | 197 | return self.errors.append(err) 198 | -------------------------------------------------------------------------------- /Changelog: -------------------------------------------------------------------------------- 1 | 2022-08-08 k.klenov 2 | * Version 8.4.1 3 | * Support TOML configuration (thank you https://github.com/villainy) 4 | * Fix pylint integration 5 | * Fix linting for empty files 6 | 7 | 2022-03-11 k.klenov 8 | * Version 8.3.8 9 | * Better pytest integration 10 | 11 | 2021-12-15 k.klenov 12 | * Version 8.3.6 13 | * Fixed processing of linters params 14 | 15 | 2021-12-02 k.klenov 16 | * Version 8.3.0 17 | * Added support for default config file `~/.pylama.ini` 18 | 19 | 2021-11-28 k.klenov 20 | * Version 8.2.0 21 | * Added `--max-line-length` to setup max line length for pycodestyle and 22 | pylint 23 | * Support for linters options in command line 24 | 25 | 2021-11-27 k.klenov 26 | 27 | * Version 8.1.4 28 | * Support json format 29 | * Support `--from-stdin` option 30 | * Changed: pylama only allows to check python files (--force is removed) 31 | * Changed: mccabe argument `complexity` -> `max-complexity` 32 | 33 | 2021-11-26 k.klenov 34 | 35 | * Version 8.0.5 36 | * Drop support for python 2 37 | * Support python 3.7, 3.8, 3.9 38 | * Support mccabe==0.6.1, pycodestyle==2.8.0, pydocstyle==6.1.1, 39 | pyflakes==2.4.0, eradicate==2.0.0, radon==5.1.0, pylint==2.11.1 40 | * Support vulture 41 | * Support 'convention' for pydocstyle 42 | * License changed to MIT (see LICENSE file for details) 43 | * Fix Radon message format 44 | 45 | 2019-04-10 k.klenov 46 | * Version 7.7.1 47 | * Fix CI by removing eradicate from linters 48 | 49 | 2019-04-10 k.klenov 50 | * Version 7.7.0 51 | * Add note about configuration option names 52 | * Added eradicate as a requirement #144 53 | * Adds mypy linter #150 54 | * Remove eradicate from default linters. 55 | 56 | 2018-11-02 k.klenov 57 | * Version 7.6.6 58 | * Avoid reference usage for linter specific ignore/select 59 | * Update Python requirements description 60 | * Update the command help message description 61 | * Add eradicate to tools references 62 | 63 | 2018-10-10 k.klenov 64 | * Version 7.6.5 65 | * Fix build 66 | 67 | 2018-10-09 k.klenov 68 | * Version 7.6.4 69 | * No changes other than version number 70 | 71 | 2018-10-09 k.klenov 72 | * Version 7.6.3 73 | * Respect linters params 74 | 75 | 2018-10-09 k.klenov 76 | * Version 7.6.2 77 | * No changes other than version number 78 | 79 | 2018-10-09 k.klenov 80 | * Version 7.6.1 81 | * Merge #131 82 | 83 | 2018-10-09 k.klenov 84 | * Version 7.6.0 85 | * Log errors in linters with ERROR logging level 86 | * Include pylint support into pylama by default. 87 | 88 | 2018-10-02 k.klenov 89 | * Version 7.5.5 90 | * Take advantage of startswith accepting a tuple #119 91 | 92 | 2018-10-02 k.klenov 93 | * Version 7.5.4 94 | * Fix build with ASCII locale #116 95 | * Respect tools own config without pylama #117 96 | 97 | 2018-10-02 k.klenov 98 | * Version 7.5.3 99 | * Fix Travis CI 100 | 101 | 2018-10-02 k.klenov 102 | * Version 7.5.2 103 | * Fix Travis CI 104 | 105 | 2018-10-02 k.klenov 106 | * Version 7.5.1 107 | * Fix tests & update authors 108 | 109 | 2018-10-02 k.klenov 110 | * Version 7.5.0 111 | * 112 | 113 | 2017-09-13 k.klenov 114 | * Version 7.4.2 115 | * Fix Git hook with Python 3.6 #111 116 | 117 | 2017-09-13 k.klenov 118 | * Version 7.4.2 119 | 120 | 121 | 2017-09-04 horneds 122 | * Version 7.4.1 123 | * Fix Windows encoding problem #108 124 | 125 | 2016-10-25 horneds 126 | 127 | * Version 7.2.0 128 | * Replace PEP8 with pycodestyle (c) Serg Baburin 129 | 130 | 2015-08-17 k.klenov 131 | 132 | * --abspath 133 | 134 | 2015-06-30 horneds 135 | 136 | * Pyflakes 0.9.2 137 | 138 | 2015-06-03 horneds 139 | 140 | * Pyflakes 0.9.1-pre 141 | 142 | 143 | 2015-03-25 horneds 144 | 145 | * Version 6.2.0 146 | * Pep257 0.5.0 147 | * PEP8 1.6.3a0 148 | * Pyflakes 0.8.2a0 149 | 150 | 2014-10-26 horneds 151 | 152 | * Version 6.1.0 153 | 154 | 2014-07-23 horneds 155 | 156 | * Fix mercurial hook installation (c) MrShark 157 | * Version 6.0.1 158 | 159 | 2014-07-01 horneds 160 | 161 | * Add sorting (--sort) 162 | * Version 6.0.0 163 | 164 | 2014-06-15 horneds 165 | 166 | * Better handling pylint properties 167 | 168 | 2014-06-11 horneds 169 | 170 | * Pytest support (as plugin) 171 | 172 | 2014-06-08 horneds 173 | 174 | * WARNING: Change format INI-options. See README for details. 175 | * INI configurations could be read from `pylama.ini`, `setup.cfg`, 176 | `pytest.ini`, `tox.ini` files. 177 | 178 | 2014-06-07 horneds 179 | 180 | * Reduce duplicate messages #3 181 | * Update pep8 to version 1.6.0a0 182 | 183 | 2014-05-07 horneds 184 | 185 | * Update pep8 to version 1.5.7a0 186 | * Update pyflakes to version 0.8.2a0 187 | 188 | 2014-05-04 horneds 189 | 190 | * Version 3.1.2 191 | * Parse numbers from ini correctly (c) Grzegorz Śliwiński 192 | 193 | 2014-03-26 horneds 194 | 195 | * Version 3.1.1 196 | * Update PEP8 to version 1.5.0 197 | 198 | 2014-03-24 horneds 199 | 200 | * File options (and modeline) 'lint_ignore' -> 'ignore', 'lint_select' 201 | -> 'select', 'lint' -> 'skip' 202 | * Update pep257 203 | * Update pyflakes 204 | * Added frosted 205 | * Version 3.0.2 206 | 207 | 2013-11-12 horneds 208 | 209 | * Version 2.0.4 210 | * Bugfix release 211 | 212 | 2013-10-27 horneds 213 | 214 | * Version 2.0.2 215 | 216 | 2013-10-13 horneds 217 | 218 | * Version 2.0.1 219 | * Append JavaScript code checker (c) lukaszpiotr 220 | * Create plugin structure (move pylint, gjslint to plugins) 221 | 222 | 2013-09-16 horneds 223 | 224 | * Version 1.5.4 225 | * fix default liners value for parsing options (c) Grzegorz Śliwiński 226 | 227 | 2013-09-05 horneds 228 | 229 | * Version 1.5.3 230 | * Hotfix release 231 | 232 | 2013-08-30 horneds 233 | 234 | * Version 1.5.1 235 | * Remove ordereddict requirement for python 2.6 236 | * pep257 0.2.4 237 | * pep8 1.4.7a0 238 | 239 | 2013-08-07 horneds 240 | 241 | * Version 1.4.0 242 | * Pylint 1.0.0 243 | * Pep257 0.2.3 244 | * mccabe 0.2.1 245 | 246 | 2013-07-25 horneds 247 | 248 | * Version 1.3.3 249 | 250 | 2013-07-08 horneds 251 | 252 | * Merge settings from command lines, ini files and modelines 253 | * Version 1.3.1 254 | 255 | 2013-07-03 horneds 256 | 257 | * PEP8 1.4.6 258 | * Pyflakes 0.7.3 259 | 260 | 2013-06-25 horneds 261 | 262 | * Fix file paths 263 | 264 | 2013-06-20 horneds 265 | 266 | * Version 1.1.0 267 | * File's sections in `pylama.ini` now supports a filemasks 268 | 269 | 2013-06-17 horneds 270 | 271 | * WARNING: Change skipline pattern 'nolint' -> 'noqa' for better compatibility 272 | 273 | 2013-06-07 horneds 274 | 275 | * Version 1.0.4 276 | * Added PEP257 checker 277 | * Experemental async support 278 | 279 | 2013-05-31 horneds 280 | 281 | * Version 1.0.2 282 | * Fix release 1.0.0 283 | 284 | 2013-05-30 horneds 285 | 286 | * Beta release 1.0.0 287 | 288 | 2013-05-29 horneds 289 | 290 | * Version 0.3.8 291 | * Added docs 292 | 293 | 2013-05-22 horneds 294 | 295 | * Version 0.3.6 296 | * Fix release 0.3.5 297 | 298 | 2013-05-21 horneds 299 | 300 | * Version 0.3.5 301 | * Now pylama can parse global and file-related options from file. 302 | 303 | 2013-05-15 horneds 304 | 305 | * Version 0.3.2 306 | * Fix PEP8 UTF bug 307 | 308 | 2013-05-03 horneds 309 | 310 | * Version 0.3.1 311 | * pylint 0.28.0 312 | * pyflakes 0.7.3a0 313 | 314 | 2013-03-31 klen 315 | 316 | * Version 0.3.0; 317 | * Python3 support; 318 | 319 | 2013-03-29 klen 320 | 321 | * Added git and mercurial hooks; 322 | * Version 0.2.8 323 | 324 | 2013-03-22 klen 325 | 326 | * Version 0.2.7; 327 | * Added 'skipline' flag. See `# nolint`; 328 | * Added pylint parseable format; 329 | 330 | 2013-03-15 klen 331 | 332 | * Version 0.2.3 333 | * Update pyflakes to 0.6.1 334 | 335 | 2013-03-14 klen 336 | 337 | * Version 0.2.2 338 | * PEP8 to version 1.4.5; 339 | * Added Pylint 0.27.0 (disabled by default) 340 | 341 | 2013-02-15 klen 342 | 343 | * Version 0.1.4 344 | * Update pep8 and pyflakes 345 | * `skip` option allowed to use unix file masks 346 | * `skip` option allowed to use many patterns (split by comma) 347 | * Added `report` option for file reports 348 | 349 | 2012-08-17 klen 350 | 351 | * Initial release 352 | -------------------------------------------------------------------------------- /pylama/config.py: -------------------------------------------------------------------------------- 1 | """Parse arguments from command line and configuration files.""" 2 | import fnmatch 3 | import logging 4 | import os 5 | import re 6 | import sys 7 | from argparse import ArgumentParser, Namespace 8 | from pathlib import Path 9 | from typing import Any, Collection, Dict, List, Optional, Set, Union 10 | 11 | from pylama import LOGGER, __version__ 12 | from pylama.libs import inirama 13 | from pylama.lint import LINTERS 14 | 15 | try: 16 | from pylama import config_toml 17 | CONFIG_FILES = ["pylama.ini", "pyproject.toml", "setup.cfg", "tox.ini", "pytest.ini"] 18 | except ImportError: 19 | CONFIG_FILES = ["pylama.ini", "setup.cfg", "tox.ini", "pytest.ini"] 20 | 21 | 22 | #: A default checkers 23 | DEFAULT_LINTERS = "pycodestyle", "pyflakes", "mccabe" 24 | 25 | CURDIR = Path.cwd() 26 | HOMECFG = Path.home() / ".pylama.ini" 27 | DEFAULT_SECTION = "pylama" 28 | 29 | # Setup a logger 30 | LOGGER.propagate = False 31 | STREAM = logging.StreamHandler(sys.stdout) 32 | LOGGER.addHandler(STREAM) 33 | 34 | 35 | class _Default: 36 | def __init__(self, value=None): 37 | self.value = value 38 | 39 | def __str__(self): 40 | return str(self.value) 41 | 42 | def __repr__(self): 43 | return f"<_Default [{self.value}]>" 44 | 45 | 46 | def split_csp_str(val: Union[Collection[str], str]) -> Set[str]: 47 | """Split comma separated string into unique values, keeping their order.""" 48 | if isinstance(val, str): 49 | val = val.strip().split(",") 50 | return set(x for x in val if x) 51 | 52 | 53 | def prepare_sorter(val: Union[Collection[str], str]) -> Optional[Dict[str, int]]: 54 | """Parse sort value.""" 55 | if val: 56 | types = split_csp_str(val) 57 | return dict((v, n) for n, v in enumerate(types, 1)) 58 | 59 | return None 60 | 61 | 62 | def parse_linters(linters: str) -> List[str]: 63 | """Initialize choosen linters.""" 64 | return [name for name in split_csp_str(linters) if name in LINTERS] 65 | 66 | 67 | def get_default_config_file(rootdir: Path = None) -> Optional[str]: 68 | """Search for configuration file.""" 69 | if rootdir is None: 70 | return DEFAULT_CONFIG_FILE 71 | 72 | for filename in CONFIG_FILES: 73 | path = rootdir / filename 74 | if path.is_file() and os.access(path, os.R_OK): 75 | return path.as_posix() 76 | 77 | return None 78 | 79 | 80 | DEFAULT_CONFIG_FILE = get_default_config_file(CURDIR) 81 | 82 | 83 | def setup_parser() -> ArgumentParser: 84 | """Create and setup parser for command line.""" 85 | parser = ArgumentParser(description="Code audit tool for python.") 86 | parser.add_argument( 87 | "paths", 88 | nargs="*", 89 | default=_Default([CURDIR.as_posix()]), 90 | help="Paths to files or directories for code check.", 91 | ) 92 | parser.add_argument( 93 | "--version", action="version", version="%(prog)s " + __version__ 94 | ) 95 | parser.add_argument("--verbose", "-v", action="store_true", help="Verbose mode.") 96 | parser.add_argument( 97 | "--options", 98 | "-o", 99 | default=DEFAULT_CONFIG_FILE, 100 | metavar="FILE", 101 | help=( 102 | "Specify configuration file. " 103 | f"Looks for {', '.join(CONFIG_FILES[:-1])}, or {CONFIG_FILES[-1]}" 104 | f" in the current directory (default: {DEFAULT_CONFIG_FILE})" 105 | ), 106 | ) 107 | parser.add_argument( 108 | "--linters", 109 | "-l", 110 | default=_Default(",".join(DEFAULT_LINTERS)), 111 | type=parse_linters, 112 | help=( 113 | f"Select linters. (comma-separated). Choices are {','.join(s for s in LINTERS)}." 114 | ), 115 | ) 116 | parser.add_argument( 117 | "--from-stdin", 118 | action="store_true", 119 | help="Interpret the stdin as a python script, " 120 | "whose filename needs to be passed as the path argument.", 121 | ) 122 | parser.add_argument( 123 | "--concurrent", 124 | "--async", 125 | action="store_true", 126 | help="Enable async mode. Useful for checking a lot of files. ", 127 | ) 128 | 129 | parser.add_argument( 130 | "--format", 131 | "-f", 132 | default=_Default("pycodestyle"), 133 | choices=["pydocstyle", "pycodestyle", "pylint", "parsable", "json"], 134 | help="Choose output format.", 135 | ) 136 | parser.add_argument( 137 | "--abspath", 138 | "-a", 139 | action="store_true", 140 | default=_Default(False), 141 | help="Use absolute paths in output.", 142 | ) 143 | parser.add_argument( 144 | "--max-line-length", 145 | "-m", 146 | default=_Default(100), 147 | type=int, 148 | help="Maximum allowed line length", 149 | ) 150 | parser.add_argument( 151 | "--select", 152 | "-s", 153 | default=_Default(""), 154 | type=split_csp_str, 155 | help="Select errors and warnings. (comma-separated list)", 156 | ) 157 | parser.add_argument( 158 | "--ignore", 159 | "-i", 160 | default=_Default(""), 161 | type=split_csp_str, 162 | help="Ignore errors and warnings. (comma-separated)", 163 | ) 164 | parser.add_argument( 165 | "--skip", 166 | default=_Default(""), 167 | type=lambda s: [re.compile(fnmatch.translate(p)) for p in s.split(",") if p], 168 | help="Skip files by masks (comma-separated, Ex. */messages.py)", 169 | ) 170 | parser.add_argument( 171 | "--sort", 172 | default=_Default(), 173 | type=prepare_sorter, 174 | help="Sort result by error types. Ex. E,W,D", 175 | ) 176 | parser.add_argument("--report", "-r", help="Send report to file [REPORT]") 177 | parser.add_argument( 178 | "--hook", action="store_true", help="Install Git (Mercurial) hook." 179 | ) 180 | 181 | for linter_type in LINTERS.values(): 182 | linter_type.add_args(parser) 183 | 184 | return parser 185 | 186 | 187 | def parse_options( # noqa 188 | args: List[str] = None, config: bool = True, rootdir: Path = CURDIR, **overrides 189 | ) -> Namespace: 190 | """Parse options from command line and configuration files.""" 191 | # Parse args from command string 192 | parser = setup_parser() 193 | actions = dict( 194 | (a.dest, a) for a in parser._actions 195 | ) # pylint: disable=protected-access 196 | 197 | options = parser.parse_args(args or []) 198 | options.file_params = {} 199 | options.linters_params = {} 200 | 201 | # Compile options from ini 202 | if config: 203 | cfg = get_config(options.options, rootdir=rootdir) 204 | for opt, val in cfg.default.items(): 205 | LOGGER.info("Find option %s (%s)", opt, val) 206 | passed_value = getattr(options, opt, _Default()) 207 | if isinstance(passed_value, _Default): 208 | if opt == "paths": 209 | val = val.split() 210 | if opt == "skip": 211 | val = fix_pathname_sep(val) 212 | setattr(options, opt, _Default(val)) 213 | 214 | # Parse file related options 215 | for name, opts in cfg.sections.items(): 216 | 217 | if name == cfg.default_section: 218 | continue 219 | 220 | if name.startswith("pylama"): 221 | name = name[7:] 222 | 223 | if name in LINTERS: 224 | options.linters_params[name] = dict(opts) 225 | continue 226 | 227 | mask = re.compile(fnmatch.translate(fix_pathname_sep(name))) 228 | options.file_params[mask] = dict(opts) 229 | 230 | # Override options 231 | for opt, val in overrides.items(): 232 | setattr(options, opt, process_value(actions, opt, val)) 233 | 234 | # Postprocess options 235 | for name in options.__dict__: 236 | value = getattr(options, name) 237 | if isinstance(value, _Default): 238 | setattr(options, name, process_value(actions, name, value.value)) 239 | 240 | if options.concurrent and "pylint" in options.linters: 241 | LOGGER.warning("Can't parse code asynchronously with pylint enabled.") 242 | options.concurrent = False 243 | 244 | return options 245 | 246 | 247 | def process_value(actions: Dict, name: str, value: Any) -> Any: 248 | """Compile option value.""" 249 | action = actions.get(name) 250 | if not action: 251 | 252 | return value 253 | 254 | if callable(action.type): 255 | return action.type(value) 256 | 257 | if action.const: 258 | return bool(int(value)) 259 | 260 | return value 261 | 262 | 263 | def get_config(user_path: str = None, rootdir: Path = None) -> inirama.Namespace: 264 | """Load configuration from files.""" 265 | cfg_path = user_path or get_default_config_file(rootdir) 266 | if not cfg_path and HOMECFG.exists(): 267 | cfg_path = HOMECFG.as_posix() 268 | 269 | if cfg_path: 270 | LOGGER.info("Read config: %s", cfg_path) 271 | if cfg_path.endswith(".toml"): 272 | return get_config_toml(cfg_path) 273 | else: 274 | return get_config_ini(cfg_path) 275 | 276 | return inirama.Namespace() 277 | 278 | 279 | def get_config_ini(ini_path: str) -> inirama.Namespace: 280 | """Load configuration from INI.""" 281 | config = inirama.Namespace() 282 | config.default_section = DEFAULT_SECTION 283 | config.read(ini_path) 284 | 285 | return config 286 | 287 | 288 | def get_config_toml(toml_path: str) -> inirama.Namespace: 289 | """Load configuration from TOML.""" 290 | config = config_toml.Namespace() 291 | config.default_section = DEFAULT_SECTION 292 | config.read(toml_path) 293 | 294 | return config 295 | 296 | 297 | def setup_logger(options: Namespace): 298 | """Do the logger setup with options.""" 299 | LOGGER.setLevel(logging.INFO if options.verbose else logging.WARN) 300 | if options.report: 301 | LOGGER.removeHandler(STREAM) 302 | LOGGER.addHandler(logging.FileHandler(options.report, mode="w")) 303 | 304 | if options.options: 305 | LOGGER.info("Try to read configuration from: %r", options.options) 306 | 307 | 308 | def fix_pathname_sep(val: str) -> str: 309 | """Fix pathnames for Win.""" 310 | return val.replace(os.altsep or "\\", os.sep) 311 | 312 | 313 | # pylama:ignore=W0212,D210,F0001 314 | -------------------------------------------------------------------------------- /pylama/libs/inirama.py: -------------------------------------------------------------------------------- 1 | """ 2 | Inirama is a python module that parses INI files. 3 | 4 | .. _badges: 5 | .. include:: ../README.rst 6 | :start-after: .. _badges: 7 | :end-before: .. _contents: 8 | 9 | .. _description: 10 | .. include:: ../README.rst 11 | :start-after: .. _description: 12 | :end-before: .. _badges: 13 | 14 | :copyright: 2013 by Kirill Klenov. 15 | :license: BSD, see LICENSE for more details. 16 | """ 17 | from __future__ import unicode_literals, print_function 18 | 19 | 20 | __version__ = "0.8.0" 21 | __project__ = "Inirama" 22 | __author__ = "Kirill Klenov " 23 | __license__ = "BSD" 24 | 25 | 26 | import io 27 | import re 28 | import logging 29 | from collections import OrderedDict 30 | 31 | 32 | NS_LOGGER = logging.getLogger('inirama') 33 | 34 | 35 | class Scanner: 36 | 37 | """ Split a code string on tokens. """ 38 | 39 | def __init__(self, source, ignore=None, patterns=None): 40 | """ Init Scanner instance. 41 | 42 | :param patterns: List of token patterns [(token, regexp)] 43 | :param ignore: List of ignored tokens 44 | 45 | """ 46 | self.reset(source) 47 | if patterns: 48 | self.patterns = [] 49 | for k, r in patterns: 50 | self.patterns.append((k, re.compile(r))) 51 | 52 | if ignore: 53 | self.ignore = ignore 54 | 55 | def reset(self, source): 56 | """ Reset scanner's state. 57 | 58 | :param source: Source for parsing 59 | 60 | """ 61 | self.tokens = [] 62 | self.source = source 63 | self.pos = 0 64 | 65 | def scan(self): 66 | """ Scan source and grab tokens. """ 67 | 68 | self.pre_scan() 69 | 70 | token = None 71 | end = len(self.source) 72 | 73 | while self.pos < end: 74 | 75 | best_pat = None 76 | best_pat_len = 0 77 | 78 | # Check patterns 79 | for p, regexp in self.patterns: 80 | m = regexp.match(self.source, self.pos) 81 | if m: 82 | best_pat = p 83 | best_pat_len = len(m.group(0)) 84 | break 85 | 86 | if best_pat is None: 87 | raise SyntaxError( 88 | "SyntaxError[@char {0}: {1}]".format( 89 | self.pos, "Bad token.")) 90 | 91 | # Ignore patterns 92 | if best_pat in self.ignore: 93 | self.pos += best_pat_len 94 | continue 95 | 96 | # Create token 97 | token = ( 98 | best_pat, 99 | self.source[self.pos:self.pos + best_pat_len], 100 | self.pos, 101 | self.pos + best_pat_len, 102 | ) 103 | 104 | self.pos = token[-1] 105 | self.tokens.append(token) 106 | 107 | def pre_scan(self): 108 | """ Prepare source. """ 109 | pass 110 | 111 | def __repr__(self): 112 | """ Print the last 5 tokens that have been scanned in. 113 | 114 | :return str: 115 | 116 | """ 117 | return '" 119 | 120 | 121 | class INIScanner(Scanner): 122 | 123 | """ Get tokens for INI. """ 124 | 125 | patterns = [ 126 | ('SECTION', re.compile(r'\[[^]]+\]')), 127 | ('IGNORE', re.compile(r'[ \r\t\n]+')), 128 | ('COMMENT', re.compile(r'[;#].*')), 129 | ('KEY_VALUE', re.compile(r'[^=\s]+\s*[:=].*')), 130 | ('CONTINUATION', re.compile(r'.*')) 131 | ] 132 | 133 | ignore = ['IGNORE'] 134 | 135 | def pre_scan(self): 136 | """ Prepare string for scanning. """ 137 | escape_re = re.compile(r'\\\n[\t ]+') 138 | self.source = escape_re.sub('', self.source) 139 | 140 | 141 | undefined = object() 142 | 143 | 144 | class Section(OrderedDict): 145 | 146 | """ Representation of INI section. """ 147 | 148 | def __init__(self, namespace, *args, **kwargs): 149 | super(Section, self).__init__(*args, **kwargs) 150 | self.namespace = namespace 151 | 152 | def __setitem__(self, name, value): 153 | value = str(value) 154 | if value.isdigit(): 155 | value = int(value) 156 | 157 | super(Section, self).__setitem__(name, value) 158 | 159 | 160 | class InterpolationSection(Section): 161 | 162 | """ INI section with interpolation support. """ 163 | 164 | var_re = re.compile('{([^}]+)}') 165 | 166 | def get(self, name, default=None): 167 | """ Get item by name. 168 | 169 | :return object: value or None if name not exists 170 | 171 | """ 172 | 173 | if name in self: 174 | return self[name] 175 | return default 176 | 177 | def __interpolate__(self, math): 178 | try: 179 | key = math.group(1).strip() 180 | return self.namespace.default.get(key) or self[key] 181 | except KeyError: 182 | return '' 183 | 184 | def __getitem__(self, name, raw=False): 185 | value = super(InterpolationSection, self).__getitem__(name) 186 | if not raw: 187 | sample = undefined 188 | while sample != value: 189 | try: 190 | sample, value = value, self.var_re.sub( 191 | self.__interpolate__, value) 192 | except RuntimeError: 193 | message = "Interpolation failed: {0}".format(name) 194 | NS_LOGGER.error(message) 195 | raise ValueError(message) 196 | return value 197 | 198 | def iteritems(self, raw=False): 199 | """ Iterate self items. """ 200 | 201 | for key in self: 202 | yield key, self.__getitem__(key, raw=raw) 203 | 204 | items = iteritems 205 | 206 | 207 | class Namespace(object): 208 | 209 | """ Default class for parsing INI. 210 | 211 | :param **default_items: Default items for default section. 212 | 213 | Usage 214 | ----- 215 | 216 | :: 217 | 218 | from inirama import Namespace 219 | 220 | ns = Namespace() 221 | ns.read('config.ini') 222 | 223 | print ns['section']['key'] 224 | 225 | ns['other']['new'] = 'value' 226 | ns.write('new_config.ini') 227 | 228 | """ 229 | 230 | #: Name of default section (:attr:`~inirama.Namespace.default`) 231 | default_section = 'DEFAULT' 232 | 233 | #: Dont raise any exception on file reading errors 234 | silent_read = True 235 | 236 | #: Class for generating sections 237 | section_type = Section 238 | 239 | def __init__(self, **default_items): 240 | self.sections = OrderedDict() 241 | for k, v in default_items.items(): 242 | self[self.default_section][k] = v 243 | 244 | @property 245 | def default(self): 246 | """ Return default section or empty dict. 247 | 248 | :return :class:`inirama.Section`: section 249 | 250 | """ 251 | return self.sections.get(self.default_section, dict()) 252 | 253 | def read(self, *files, **params): 254 | """ Read and parse INI files. 255 | 256 | :param *files: Files for reading 257 | :param **params: Params for parsing 258 | 259 | Set `update=False` for prevent values redefinition. 260 | 261 | """ 262 | for f in files: 263 | try: 264 | with io.open(f, encoding='utf-8') as ff: 265 | NS_LOGGER.info('Read from `{0}`'.format(ff.name)) 266 | self.parse(ff.read(), **params) 267 | except (IOError, TypeError, SyntaxError, io.UnsupportedOperation): 268 | if not self.silent_read: 269 | NS_LOGGER.error('Reading error `{0}`'.format(ff.name)) 270 | raise 271 | 272 | def write(self, f): 273 | """ Write namespace as INI file. 274 | 275 | :param f: File object or path to file. 276 | 277 | """ 278 | if isinstance(f, str): 279 | f = io.open(f, 'w', encoding='utf-8') 280 | 281 | if not hasattr(f, 'read'): 282 | raise AttributeError("Wrong type of file: {0}".format(type(f))) 283 | 284 | NS_LOGGER.info('Write to `{0}`'.format(f.name)) 285 | for section in self.sections.keys(): 286 | f.write('[{0}]\n'.format(section)) 287 | for k, v in self[section].items(): 288 | f.write('{0:15}= {1}\n'.format(k, v)) 289 | f.write('\n') 290 | f.close() 291 | 292 | def parse(self, source, update=True, **params): 293 | """ Parse INI source as string. 294 | 295 | :param source: Source of INI 296 | :param update: Replace already defined items 297 | 298 | """ 299 | scanner = INIScanner(source) 300 | scanner.scan() 301 | 302 | section = self.default_section 303 | name = None 304 | 305 | for token in scanner.tokens: 306 | if token[0] == 'KEY_VALUE': 307 | name, value = re.split('[=:]', token[1], 1) 308 | name, value = name.strip(), value.strip() 309 | if not update and name in self[section]: 310 | continue 311 | self[section][name] = value 312 | 313 | elif token[0] == 'SECTION': 314 | section = token[1].strip('[]') 315 | 316 | elif token[0] == 'CONTINUATION': 317 | if not name: 318 | raise SyntaxError( 319 | "SyntaxError[@char {0}: {1}]".format( 320 | token[2], "Bad continuation.")) 321 | self[section][name] += '\n' + token[1].strip() 322 | 323 | def __getitem__(self, name): 324 | """ Look name in self sections. 325 | 326 | :return :class:`inirama.Section`: section 327 | 328 | """ 329 | if name not in self.sections: 330 | self.sections[name] = self.section_type(self) 331 | return self.sections[name] 332 | 333 | def __contains__(self, name): 334 | return name in self.sections 335 | 336 | def __repr__(self): 337 | return "".format(self.sections) 338 | 339 | 340 | class InterpolationNamespace(Namespace): 341 | 342 | """ That implements the interpolation feature. 343 | 344 | :: 345 | 346 | from inirama import InterpolationNamespace 347 | 348 | ns = InterpolationNamespace() 349 | ns.parse(''' 350 | [main] 351 | test = value 352 | foo = bar {test} 353 | more_deep = wow {foo} 354 | ''') 355 | print ns['main']['more_deep'] # wow bar value 356 | 357 | """ 358 | 359 | section_type = InterpolationSection 360 | 361 | # pylama:ignore=D,W02,E731,W0621 362 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |logo| Pylama 2 | ############# 3 | 4 | .. _badges: 5 | 6 | .. image:: https://github.com/klen/pylama/workflows/tests/badge.svg 7 | :target: https://github.com/klen/pylama/actions/workflows/tests.yml 8 | :alt: Tests Status 9 | 10 | .. image:: https://github.com/klen/pylama/workflows/docs/badge.svg 11 | :target: https://klen.github.io/pylama 12 | :alt: Documentation Status 13 | 14 | .. image:: https://img.shields.io/pypi/v/pylama 15 | :target: https://pypi.org/project/pylama/ 16 | :alt: PYPI Version 17 | 18 | .. image:: https://img.shields.io/pypi/pyversions/pylama 19 | :target: https://pypi.org/project/pylama/ 20 | :alt: Python Versions 21 | 22 | .. _description: 23 | 24 | Code audit tool for Python. Pylama wraps these tools: 25 | 26 | * pycodestyle_ (formerly pep8) © 2012-2013, Florent Xicluna; 27 | * pydocstyle_ (formerly pep257 by Vladimir Keleshev) © 2014, Amir Rachum; 28 | * PyFlakes_ © 2005-2013, Kevin Watters; 29 | * Mccabe_ © Ned Batchelder; 30 | * Pylint_ © 2013, Logilab; 31 | * Radon_ © Michele Lacchia 32 | * eradicate_ © Steven Myint; 33 | * Mypy_ © Jukka Lehtosalo and contributors; 34 | * Vulture_ © Jendrik Seipp and contributors; 35 | 36 | 37 | .. _documentation: 38 | 39 | Docs are available at https://klen.github.io/pylama/. Pull requests with 40 | documentation enhancements and/or fixes are awesome and most welcome. 41 | 42 | 43 | .. _contents: 44 | 45 | .. contents:: 46 | 47 | .. _requirements: 48 | 49 | Requirements: 50 | ============= 51 | 52 | - Python (3.7, 3.8, 3.9, 3.10) 53 | - If your tests are failing on Win platform you are missing: ``curses`` - 54 | http://www.lfd.uci.edu/~gohlke/pythonlibs/ (The curses library supplies a 55 | terminal-independent screen-painting and keyboard-handling facility for 56 | text-based terminals) 57 | 58 | For python versions < 3.7 install pylama 7.7.1 59 | 60 | 61 | .. _installation: 62 | 63 | Installation: 64 | ============= 65 | **Pylama** can be installed using pip: :: 66 | 67 | $ pip install pylama 68 | 69 | TOML configuration can be enabled optionally: :: 70 | 71 | $ pip install pylama[toml] 72 | 73 | You may optionally install the requirements with the library: :: 74 | 75 | $ pip install pylama[mypy] 76 | $ pip install pylama[pylint] 77 | $ pip install pylama[eradicate] 78 | $ pip install pylama[radon] 79 | $ pip install pylama[vulture] 80 | 81 | Or install them all: :: 82 | 83 | $ pip install pylama[all] 84 | 85 | 86 | .. _quickstart: 87 | 88 | Quickstart 89 | ========== 90 | 91 | **Pylama** is easy to use and really fun for checking code quality. Just run 92 | `pylama` and get common output from all pylama plugins (pycodestyle_, 93 | PyFlakes_, etc.) 94 | 95 | Recursively check the current directory. :: 96 | 97 | $ pylama 98 | 99 | Recursively check a path. :: 100 | 101 | $ pylama 102 | 103 | Ignore errors :: 104 | 105 | $ pylama -i W,E501 106 | 107 | .. note:: You can choose a group of errors like `D`, `E1`, etc, or special errors like `C0312` 108 | 109 | Choose code checkers :: 110 | 111 | $ pylama -l "pycodestyle,mccabe" 112 | 113 | 114 | .. _options: 115 | 116 | Set Pylama (checkers) options 117 | ============================= 118 | 119 | Command line options 120 | -------------------- 121 | 122 | :: 123 | 124 | $ pylama --help 125 | 126 | usage: pylama [-h] [--version] [--verbose] [--options FILE] [--linters LINTERS] [--from-stdin] [--concurrent] [--format {pydocstyle,pycodestyle,pylint,parsable,json}] [--abspath] 127 | [--max-line-length MAX_LINE_LENGTH] [--select SELECT] [--ignore IGNORE] [--skip SKIP] [--sort SORT] [--report REPORT] [--hook] [--max-complexity MAX_COMPLEXITY] 128 | [--pydocstyle-convention {pep257,numpy,google}] [--pylint-confidence {HIGH,INFERENCE,INFERENCE_FAILURE,UNDEFINED}] 129 | [paths ...] 130 | 131 | Code audit tool for python. 132 | 133 | positional arguments: 134 | paths Paths to files or directories for code check. 135 | 136 | optional arguments: 137 | -h, --help show this help message and exit 138 | --version show program's version number and exit 139 | --verbose, -v Verbose mode. 140 | --options FILE, -o FILE 141 | Specify configuration file. Looks for pylama.ini, setup.cfg, tox.ini, or pytest.ini in the current directory (default: None) 142 | --linters LINTERS, -l LINTERS 143 | Select linters. (comma-separated). Choices are eradicate,mccabe,mypy,pycodestyle,pydocstyle,pyflakes,pylint,isort. 144 | --from-stdin Interpret the stdin as a python script, whose filename needs to be passed as the path argument. 145 | --concurrent, --async 146 | Enable async mode. Useful for checking a lot of files. 147 | --format {pydocstyle,pycodestyle,pylint,parsable,json}, -f {pydocstyle,pycodestyle,pylint,parsable,json} 148 | Choose output format. 149 | --abspath, -a Use absolute paths in output. 150 | --max-line-length MAX_LINE_LENGTH, -m MAX_LINE_LENGTH 151 | Maximum allowed line length 152 | --select SELECT, -s SELECT 153 | Select errors and warnings. (comma-separated list) 154 | --ignore IGNORE, -i IGNORE 155 | Ignore errors and warnings. (comma-separated) 156 | --skip SKIP Skip files by masks (comma-separated, Ex. */messages.py) 157 | --sort SORT Sort result by error types. Ex. E,W,D 158 | --report REPORT, -r REPORT 159 | Send report to file [REPORT] 160 | --hook Install Git (Mercurial) hook. 161 | --max-complexity MAX_COMPLEXITY 162 | Max complexity threshold 163 | 164 | .. note:: additional options may be available depending on installed linters 165 | 166 | .. _modeline: 167 | 168 | File modelines 169 | -------------- 170 | 171 | You can set options for **Pylama** inside a source file. Use 172 | a pylama *modeline* for this, anywhere in the file. 173 | 174 | Format: :: 175 | 176 | # pylama:{name1}={value1}:{name2}={value2}:... 177 | 178 | 179 | For example, ignore warnings except W301: :: 180 | 181 | # pylama:ignore=W:select=W301 182 | 183 | 184 | Disable code checking for current file: :: 185 | 186 | # pylama:skip=1 187 | 188 | Those options have a higher priority. 189 | 190 | .. _skiplines: 191 | 192 | Skip lines (noqa) 193 | ----------------- 194 | 195 | Just add ``# noqa`` at the end of a line to ignore: 196 | 197 | :: 198 | 199 | def urgent_fuction(): 200 | unused_var = 'No errors here' # noqa 201 | 202 | 203 | .. _config: 204 | 205 | Configuration file 206 | ================== 207 | 208 | **Pylama** looks for a configuration file in the current directory. 209 | 210 | You can use a “global” configuration, stored in `.pylama.ini` in your home 211 | directory. This will be used as a fallback configuration. 212 | 213 | The program searches for the first matching configuration file in the 214 | directories of command line argument. Pylama looks for the configuration in 215 | this order: :: 216 | 217 | ./pylama.ini 218 | ./pyproject.toml 219 | ./setup.cfg 220 | ./tox.ini 221 | ./pytest.ini 222 | ~/.pylama.ini 223 | 224 | The ``--option`` / ``-o`` argument can be used to specify a configuration file. 225 | 226 | INI-style configuration 227 | ----------------------- 228 | 229 | Pylama searches for sections whose names start with `pylama`. 230 | 231 | The `pylama` section configures global options like `linters` and `skip`. 232 | 233 | :: 234 | 235 | [pylama] 236 | format = pylint 237 | skip = */.tox/*,*/.env/* 238 | linters = pylint,mccabe 239 | ignore = F0401,C0111,E731 240 | 241 | Set code-checkers' options 242 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 243 | 244 | You can set options for a special code checkers with pylama configurations. 245 | 246 | :: 247 | 248 | [pylama:pyflakes] 249 | builtins = _ 250 | 251 | [pylama:pycodestyle] 252 | max_line_length = 100 253 | 254 | [pylama:pylint] 255 | max_line_length = 100 256 | disable = R 257 | 258 | See code-checkers' documentation for more info. Note that dashes are 259 | replaced by underscores (e.g. Pylint's ``max-line-length`` becomes 260 | ``max_line_length``). 261 | 262 | 263 | Set options for file (group of files) 264 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 265 | 266 | You can set options for special file (group of files) 267 | with sections: 268 | 269 | The options have a higher priority than in the `pylama` section. 270 | 271 | :: 272 | 273 | [pylama:*/pylama/main.py] 274 | ignore = C901,R0914,W0212 275 | select = R 276 | 277 | [pylama:*/tests.py] 278 | ignore = C0110 279 | 280 | [pylama:*/setup.py] 281 | skip = 1 282 | 283 | TOML configuration 284 | ----------------------- 285 | 286 | Pylama searches for sections whose names start with `tool.pylama`. 287 | 288 | The `tool.pylama` section configures global options like `linters` and `skip`. 289 | 290 | :: 291 | 292 | [tool.pylama] 293 | format = "pylint" 294 | skip = "*/.tox/*,*/.env/*" 295 | linters = "pylint,mccabe" 296 | ignore = "F0401,C0111,E731" 297 | 298 | Set code-checkers' options 299 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 300 | 301 | You can set options for a special code checkers with pylama configurations. 302 | 303 | :: 304 | 305 | [tool.pylama.linter.pyflakes] 306 | builtins = "_" 307 | 308 | [tool.pylama.linter.pycodestyle] 309 | max_line_length = 100 310 | 311 | [tool.pylama.linter.pylint] 312 | max_line_length = 100 313 | disable = "R" 314 | 315 | See code-checkers' documentation for more info. Note that dashes are 316 | replaced by underscores (e.g. Pylint's ``max-line-length`` becomes 317 | ``max_line_length``). 318 | 319 | 320 | Set options for file (group of files) 321 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 322 | 323 | You can set options for special file (group of files) 324 | with sections: 325 | 326 | The options have a higher priority than in the `tool.pylama` section. 327 | 328 | :: 329 | 330 | [[tool.pylama.files]] 331 | path = "*/pylama/main.py" 332 | ignore = "C901,R0914,W0212" 333 | select = "R" 334 | 335 | [[tool.pylama.files]] 336 | path = "pylama:*/tests.py" 337 | ignore = "C0110" 338 | 339 | [[tool.pylama.files]] 340 | path = "pylama:*/setup.py" 341 | skip = 1 342 | 343 | 344 | Pytest integration 345 | ================== 346 | 347 | Pylama has Pytest_ support. The package automatically registers itself as a pytest 348 | plugin during installation. Pylama also supports the `pytest_cache` plugin. 349 | 350 | Check files with pylama :: 351 | 352 | pytest --pylama ... 353 | 354 | The recommended way to set pylama options when using pytest — configuration 355 | files (see below). 356 | 357 | 358 | Writing a linter 359 | ================ 360 | 361 | You can write a custom extension for Pylama. 362 | The custom linter should be a python module. Its name should be like 'pylama_'. 363 | 364 | In 'setup.py', 'pylama.linter' entry point should be defined. :: 365 | 366 | setup( 367 | # ... 368 | entry_points={ 369 | 'pylama.linter': ['lintername = pylama_lintername.main:Linter'], 370 | } 371 | # ... 372 | ) 373 | 374 | 'Linter' should be an instance of 'pylama.lint.Linter' class. 375 | It must implement two methods: 376 | 377 | 1. ``allow`` takes a `path` argument and returns true if the linter can check this file for errors. 378 | 2. ``run`` takes a `path` argument and `meta` keyword arguments and returns a list of errors. 379 | 380 | Example: 381 | -------- 382 | 383 | Just a virtual 'WOW' checker. 384 | 385 | setup.py: :: 386 | 387 | setup( 388 | name='pylama_wow', 389 | install_requires=[ 'setuptools' ], 390 | entry_points={ 391 | 'pylama.linter': ['wow = pylama_wow.main:Linter'], 392 | } 393 | # ... 394 | ) 395 | 396 | pylama_wow.py: :: 397 | 398 | from pylama.lint import Linter as BaseLinter 399 | 400 | class Linter(BaseLinter): 401 | 402 | def allow(self, path): 403 | return 'wow' in path 404 | 405 | def run(self, path, **meta): 406 | with open(path) as f: 407 | if 'wow' in f.read(): 408 | return [{ 409 | lnum: 0, 410 | col: 0, 411 | text: '"wow" has been found.', 412 | type: 'WOW' 413 | }] 414 | 415 | 416 | Run pylama from python code 417 | --------------------------- 418 | :: 419 | 420 | from pylama.main import check_paths, parse_options 421 | 422 | # Use and/or modify 0 or more of the options defined as keys in the variable my_redefined_options below. 423 | # To use defaults for any option, remove that key completely. 424 | my_redefined_options = { 425 | 'linters': ['pep257', 'pydocstyle', 'pycodestyle', 'pyflakes' ...], 426 | 'ignore': ['D203', 'D213', 'D406', 'D407', 'D413' ...], 427 | 'select': ['R1705' ...], 428 | 'sort': 'F,E,W,C,D,...', 429 | 'skip': '*__init__.py,*/test/*.py,...', 430 | 'async': True, 431 | 'force': True 432 | ... 433 | } 434 | # relative path of the directory in which pylama should check 435 | my_path = '...' 436 | 437 | options = parse_options([my_path], **my_redefined_options) 438 | errors = check_paths(my_path, options, rootdir='.') 439 | 440 | 441 | .. _bagtracker: 442 | 443 | Bug tracker 444 | ----------- 445 | 446 | If you have any suggestions, bug reports or annoyances please report them to the issue tracker at https://github.com/klen/pylama/issues 447 | 448 | 449 | .. _contributing: 450 | 451 | Contributing 452 | ------------ 453 | 454 | Development of `pylama` happens at GitHub: https://github.com/klen/pylama 455 | 456 | Contributors 457 | ^^^^^^^^^^^^ 458 | 459 | See CONTRIBUTORS_. 460 | 461 | 462 | .. _license: 463 | 464 | License 465 | ------- 466 | 467 | This is free software. You are permitted to use, copy, modify, merge, publish, 468 | distribute, sublicense, and/or sell copies of it, under the terms of the MIT 469 | License. See LICENSE file for the complete license. 470 | 471 | This software is provided WITHOUT ANY WARRANTY; without even the implied 472 | warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See 473 | LICENSE file for the complete disclaimer. 474 | 475 | 476 | .. _links: 477 | 478 | .. _CONTRIBUTORS: https://github.com/klen/pylama/graphs/contributors 479 | .. _Mccabe: http://nedbatchelder.com/blog/200803/python_code_complexity_microtool.html 480 | .. _pydocstyle: https://github.com/PyCQA/pydocstyle/ 481 | .. _pycodestyle: https://github.com/PyCQA/pycodestyle 482 | .. _PyFlakes: https://github.com/pyflakes/pyflakes 483 | .. _Pylint: http://pylint.org 484 | .. _Pytest: http://pytest.org 485 | .. _klen: http://klen.github.io/ 486 | .. _eradicate: https://github.com/myint/eradicate 487 | .. _Mypy: https://github.com/python/mypy 488 | .. _Vulture: https://github.com/jendrikseipp/vulture 489 | 490 | .. |logo| image:: https://raw.github.com/klen/pylama/develop/docs/_static/logo.png 491 | :width: 100 492 | .. _Radon: https://github.com/rubik/radon 493 | 494 | --------------------------------------------------------------------------------