pax_global_header00006660000000000000000000000064146656374250014534gustar00rootroot0000000000000052 comment=9be427d3346c15e5e7ab331d4a312e5a83477cee pylsp-mypy-0.6.9/000077500000000000000000000000001466563742500137135ustar00rootroot00000000000000pylsp-mypy-0.6.9/.github/000077500000000000000000000000001466563742500152535ustar00rootroot00000000000000pylsp-mypy-0.6.9/.github/workflows/000077500000000000000000000000001466563742500173105ustar00rootroot00000000000000pylsp-mypy-0.6.9/.github/workflows/python-package.yml000066400000000000000000000037451466563742500227560ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Python package on: push: branches: [ master ] pull_request: branches: [ master ] defaults: run: shell: bash jobs: testCode: strategy: matrix: os: [ubuntu-latest, windows-latest] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --show-source --statistics - name: Check black formatting run: | # stop the build if black detect any changes black --check . - name: Check isort sorting run: | # stop the build if isort detect any changes isort . --check --diff - name: Test with pytest run: | pytest testUploadability: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Test with rstcheck run: | rstcheck README.rst - name: Build and check run: | python setup.py sdist bdist_wheel twine check dist/* pylsp-mypy-0.6.9/.github/workflows/python-publish.yml000066400000000000000000000015161466563742500230230ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: [created] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine - name: Build and publish env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | python setup.py sdist bdist_wheel twine upload dist/* pylsp-mypy-0.6.9/.gitignore000066400000000000000000000022051466563742500157020ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ pylsp-mypy-0.6.9/.pre-commit-config.yaml000066400000000000000000000006111466563742500201720ustar00rootroot00000000000000# See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/psf/black rev: 22.3.0 hooks: - id: black - repo: https://github.com/Lucas-C/pre-commit-hooks-markup rev: v1.0.1 hooks: - id: rst-linter - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: - id: isort pylsp-mypy-0.6.9/LICENSE000066400000000000000000000021271466563742500147220ustar00rootroot00000000000000MIT License Copyright (c) 2017 Tom van Ommeren Copyright (c) 2024 Richard Kellnberger Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pylsp-mypy-0.6.9/MANIFEST.in000066400000000000000000000000231466563742500154440ustar00rootroot00000000000000include README.rst pylsp-mypy-0.6.9/README.rst000066400000000000000000000165201466563742500154060ustar00rootroot00000000000000Mypy plugin for PYLSP ====================== .. image:: https://badge.fury.io/py/pylsp-mypy.svg :target: https://badge.fury.io/py/pylsp-mypy .. image:: https://github.com/python-lsp/pylsp-mypy/workflows/Python%20package/badge.svg?branch=master :target: https://github.com/python-lsp/pylsp-mypy/ This is a plugin for the `Python LSP Server`_. .. _`Python LSP Server`: https://github.com/python-lsp/python-lsp-server It, like mypy, requires Python 3.8 or newer. Installation ------------ Install into the same virtualenv as python-lsp-server itself. ``pip install pylsp-mypy`` Configuration ------------- ``pylsp-mypy`` supports the use of ``pyproject.toml`` for configuration. It can also be configuered using configs provided to the LSP server. The configuration keys are listed in the following. .. list-table:: Configuration :header-rows: 1 * - ``pyproject.toml`` key - LSP Configuration Key - Type - Description - Default * - ``live_mode`` - ``pylsp.plugins.pylsp_mypy.live_mode`` - ``boolean`` - **Provides type checking as you type**. This writes to a tempfile every time a check is done. Turning off ``live_mode`` means you must save your changes for mypy diagnostics to update correctly. - true * - ``dmypy`` - ``pylsp.plugins.pylsp_mypy.dmypy`` - ``boolean`` - **Executes via** ``dmypy run`` **rather than** ``mypy``. This uses the ``dmypy`` daemon and may dramatically improve the responsiveness of the ``pylsp`` server, however this currently does not work in ``live_mode``. Enabling this disables ``live_mode``, even for conflicting configs. - false * - ``strict`` - ``pylsp.plugins.pylsp_mypy.strict`` - ``boolean`` - **Refers to the** ``strict`` **option of** ``mypy``. This option often is too strict to be useful. - false * - ``overrides`` - ``pylsp.plugins.pylsp_mypy.overrides`` - ``array`` of (``string`` items or ``true``) - **A list of alternate or supplemental command-line options**. This modifies the options passed to ``mypy`` or the mypy-specific ones passed to ``dmypy run``. When present, the special boolean member ``true`` is replaced with the command-line options that would've been passed had ``overrides`` not been specified. - ``[true]`` * - ``dmypy_status_file`` - ``pylsp.plugins.pylsp_mypy.dmypy_status_file`` - ``string`` - **Specifies which status file dmypy should use**. This modifies the ``--status-file`` option passed to ``dmypy`` given ``dmypy`` is active. - ``.dmypy.json`` * - ``config_sub_paths`` - ``pylsp.plugins.pylsp_mypy.config_sub_paths`` - ``array`` of ``string`` items - **Specifies sub paths under which the mypy configuration file may be found**. For each directory searched for the mypy config file, this also searches the sub paths specified here. - ``[]`` * - ``report_progress`` - ``pylsp.plugins.pylsp_mypy.report_progress`` - ``boolean`` - **Report basic progress to the LSP client**. With this option, pylsp-mypy will report when mypy is running, given your editor supports LSP progress reporting. For small files this might produce annoying flashing in your editor, especially in ``live_mode``. For large projects, enabling this can be helpful to assure yourself whether mypy is still running. - false * - ``exclude`` - ``pylsp.plugins.pylsp_mypy.exclude`` - ``array`` of ``string`` items - **A list of regular expressions which should be ignored**. The ``mypy`` runner wil not be invoked when a document path is matched by one of the expressions. Note that this differs from the ``exclude`` directive of a ``mypy`` config which is only used for recursively discovering files when mypy is invoked on a whole directory. For both windows or unix platforms you should use forward slashes (``/``) to indicate paths. - ``[]`` * - ``follow-imports`` - ``pylsp.plugins.pylsp_mypy.follow-imports`` - ``normal``, ``silent``, ``skip`` or ``error`` - ``mypy`` **parameter** ``follow-imports``. In ``mypy`` this is ``normal`` by default. We set it ``silent``, to sort out unwanted results. This can cause cash invalidation if you also run ``mypy`` in other ways. Setting this to ``normal`` avoids this at the cost of a small performance penalty. - ``silent`` Using a ``pyproject.toml`` for configuration, which is in fact the preferred way, your configuration could look like this: :: [tool.pylsp-mypy] enabled = true live_mode = true strict = true exclude = ["tests/*"] A ``pyproject.toml`` does not conflict with the legacy config file (deprecated) given that it does not contain a ``pylsp-mypy`` section. The following explanation uses the syntax of the legacy config file (deprecated). However, all these options also apply to the ``pyproject.toml`` configuration (note the lowercase bools). Depending on your editor, the configuration (found in a file called pylsp-mypy.cfg in your workspace or a parent directory) should be roughly like this for a standard configuration: :: { "enabled": True, "live_mode": True, "strict": False, "exclude": ["tests/*"] } With ``dmypy`` enabled your config should look like this: :: { "enabled": True, "live_mode": False, "dmypy": True, "strict": False } With ``overrides`` specified (for example to tell mypy to use a different python than the currently active venv), your config could look like this: :: { "enabled": True, "overrides": ["--python-executable", "/home/me/bin/python", True] } With ``dmypy_status_file`` your config could look like this: :: { "enabled": True, "live_mode": False, "dmypy": True, "strict": False, "dmypy_status_file": ".custom_dmypy_status_file.json" } With ``config_sub_paths`` your config could look like this: :: { "enabled": True, "config_sub_paths": [".config"] } With ``report_progress`` your config could look like this: :: { "enabled": True, "report_progress": True } Developing ------------- Install development dependencies with (you might want to create a virtualenv first): :: pip install -r requirements.txt The project is formatted with `black`_. You can either configure your IDE to automatically format code with it, run it manually (``black .``) or rely on pre-commit (see below) to format files on git commit. The project is formatted with `isort`_. You can either configure your IDE to automatically sort imports with it, run it manually (``isort .``) or rely on pre-commit (see below) to sort files on git commit. The project uses two rst tests in order to assure uploadability to pypi: `rst-linter`_ as a pre-commit hook and `rstcheck`_ in a GitHub workflow. This does not catch all errors. This project uses `pre-commit`_ to enforce code-quality. After cloning the repository install the pre-commit hooks with: :: pre-commit install After that pre-commit will run `all defined hooks`_ on every ``git commit`` and keep you from committing if there are any errors. .. _black: https://github.com/psf/black .. _isort: https://github.com/PyCQA/isort .. _rst-linter: https://github.com/Lucas-C/pre-commit-hooks-markup .. _rstcheck: https://github.com/myint/rstcheck .. _pre-commit: https://pre-commit.com/ .. _all defined hooks: .pre-commit-config.yaml pylsp-mypy-0.6.9/pylsp_mypy/000077500000000000000000000000001466563742500161405ustar00rootroot00000000000000pylsp-mypy-0.6.9/pylsp_mypy/__init__.py000066400000000000000000000000001466563742500202370ustar00rootroot00000000000000pylsp-mypy-0.6.9/pylsp_mypy/_version.py000066400000000000000000000000261466563742500203340ustar00rootroot00000000000000__version__ = "0.6.9" pylsp-mypy-0.6.9/pylsp_mypy/plugin.py000066400000000000000000000513701466563742500200160ustar00rootroot00000000000000# -*- coding: utf-8 -*- """ File that contains the python-lsp-server plugin pylsp-mypy. Created on Fri Jul 10 09:53:57 2020 @author: Richard Kellnberger """ import ast import atexit import collections import logging import os import os.path import re import shutil import subprocess import tempfile from configparser import ConfigParser from pathlib import Path from typing import IO, Any, Dict, List, Optional try: import tomllib except ModuleNotFoundError: import tomli as tomllib from mypy import api as mypy_api from pylsp import hookimpl from pylsp.config.config import Config from pylsp.workspace import Document, Workspace line_pattern = re.compile( ( r"^(?P.+):(?P\d+):(?P\d*):(?P\d*):(?P\d*): " r"(?P\w+): (?P.+?)(?: +\[(?P.+)\])?$" ) ) whole_line_pattern = re.compile( # certain mypy warnings do not report start-end ranges ( r"^(?P.+):(?P\d+): " r"(?P\w+): (?P.+?)(?: +\[(?P.+)\])?$" ) ) log = logging.getLogger(__name__) # A mapping from workspace path to config file path mypyConfigFileMap: Dict[str, Optional[str]] = {} settingsCache: Dict[str, Dict[str, Any]] = {} tmpFile: Optional[IO[str]] = None # In non-live-mode the file contents aren't updated. # Returning an empty diagnostic clears the diagnostic result, # so store a cache of last diagnostics for each file a-la the pylint plugin, # so we can return some potentially-stale diagnostics. # https://github.com/python-lsp/python-lsp-server/blob/v1.0.1/pylsp/plugins/pylint_lint.py#L55-L62 last_diagnostics: Dict[str, List[Dict[str, Any]]] = collections.defaultdict(list) # Windows started opening opening a cmd-like window for every subprocess call # This flag prevents that. # This flag is new in python 3.7 # This flag only exists on Windows windows_flag: Dict[str, int] = ( {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {} # type: ignore ) def parse_line(line: str, document: Optional[Document] = None) -> Optional[Dict[str, Any]]: """ Return a language-server diagnostic from a line of the Mypy error report. optionally, use the whole document to provide more context on it. Parameters ---------- line : str Line of mypy output to be analysed. document : Optional[Document], optional Document in wich the line is found. The default is None. Returns ------- Optional[Dict[str, Any]] The dict with the lint data. """ result = line_pattern.match(line) or whole_line_pattern.match(line) if not result: return None file_path = result["file"] if file_path != "": # live mode # results from other files can be included, but we cannot return # them. if document and document.path and not document.path.endswith(file_path): log.warning("discarding result for %s against %s", file_path, document.path) return None lineno = int(result["start_line"]) - 1 # 0-based line number offset = int(result.groupdict().get("start_col", 1)) - 1 # 0-based offset end_lineno = int(result.groupdict().get("end_line", lineno + 1)) - 1 end_offset = int(result.groupdict().get("end_col", 1)) # end is exclusive severity = result["severity"] if severity not in ("error", "note"): log.warning(f"invalid error severity '{severity}'") errno = 1 if severity == "error" else 3 return { "source": "mypy", "range": { "start": {"line": lineno, "character": offset}, "end": {"line": end_lineno, "character": end_offset}, }, "message": result["message"], "severity": errno, "code": result["code"], } def apply_overrides(args: List[str], overrides: List[Any]) -> List[str]: """Replace or combine default command-line options with overrides.""" overrides_iterator = iter(overrides) if True not in overrides_iterator: return overrides # If True is in the list, the if above leaves the iterator at the element after True, # therefore, the list below only contains the elements after the True rest = list(overrides_iterator) # slice of the True and the rest, add the args, add the rest return overrides[: -(len(rest) + 1)] + args + rest def didSettingsChange(workspace: str, settings: Dict[str, Any]) -> None: """Handle relevant changes to the settings between runs.""" configSubPaths = settings.get("config_sub_paths", []) if settingsCache[workspace].get("config_sub_paths", []) != configSubPaths: mypyConfigFile = findConfigFile( workspace, configSubPaths, ["mypy.ini", ".mypy.ini", "pyproject.toml", "setup.cfg"], True, ) mypyConfigFileMap[workspace] = mypyConfigFile settingsCache[workspace] = settings.copy() def match_exclude_patterns(document_path: str, exclude_patterns: list) -> bool: """Check if the current document path matches any of the configures exlude patterns.""" document_path = document_path.replace(os.sep, "/") for pattern in exclude_patterns: try: if re.search(pattern, document_path): log.debug(f"{document_path} matches " f"exclude pattern '{pattern}'") return True except re.error as e: log.error(f"pattern {pattern} is not a valid regular expression: {e}") return False @hookimpl def pylsp_lint( config: Config, workspace: Workspace, document: Document, is_saved: bool ) -> List[Dict[str, Any]]: """ Call the linter. Parameters ---------- config : Config The pylsp config. workspace : Workspace The pylsp workspace. document : Document The document to be linted. is_saved : bool Weather the document is saved. Returns ------- List[Dict[str, Any]] List of the linting data. """ settings = config.plugin_settings("pylsp_mypy") oldSettings1 = config.plugin_settings("mypy-ls") oldSettings2 = config.plugin_settings("mypy_ls") if oldSettings1 != {} or oldSettings2 != {}: raise NameError( "Your configuration uses an old namespace (mypy-ls or mypy_ls)." + "This should be changed to pylsp_mypy" ) if settings == {}: settings = oldSettings1 if settings == {}: settings = oldSettings2 didSettingsChange(workspace.root_path, settings) # Running mypy with a single file (document) ignores any exclude pattern # configured with mypy. We can now add our own exclude section like so: # [tool.pylsp-mypy] # exclude = ["tests/*"] exclude_patterns = settings.get("exclude", []) if match_exclude_patterns(document_path=document.path, exclude_patterns=exclude_patterns): log.debug( f"Not running because {document.path} matches " f"exclude patterns '{exclude_patterns}'" ) return [] if settings.get("report_progress", False): with workspace.report_progress("lint: mypy"): return get_diagnostics(workspace, document, settings, is_saved) else: return get_diagnostics(workspace, document, settings, is_saved) def get_diagnostics( workspace: Workspace, document: Document, settings: Dict[str, Any], is_saved: bool, ) -> List[Dict[str, Any]]: """ Lints. Parameters ---------- workspace : Workspace The pylsp workspace. document : Document The document to be linted. is_saved : bool Weather the document is saved. Returns ------- List[Dict[str, Any]] List of the linting data. """ log.info( "lint settings = %s document.path = %s is_saved = %s", settings, document.path, is_saved, ) live_mode = settings.get("live_mode", True) dmypy = settings.get("dmypy", False) if dmypy and live_mode: # dmypy can only be efficiently run on files that have been saved, see: # https://github.com/python/mypy/issues/9309 log.warning("live_mode is not supported with dmypy, disabling") live_mode = False if dmypy: dmypy_status_file = settings.get("dmypy_status_file", ".dmypy.json") args = ["--show-error-end", "--no-error-summary", "--no-pretty"] global tmpFile if live_mode and not is_saved: if tmpFile: tmpFile = open(tmpFile.name, "w", encoding="utf-8") else: tmpFile = tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") log.info("live_mode tmpFile = %s", tmpFile.name) tmpFile.write(document.source) tmpFile.close() args.extend(["--shadow-file", document.path, tmpFile.name]) elif not is_saved and document.path in last_diagnostics: # On-launch the document isn't marked as saved, so fall through and run # the diagnostics anyway even if the file contents may be out of date. log.info( "non-live, returning cached diagnostics len(cached) = %s", last_diagnostics[document.path], ) return last_diagnostics[document.path] mypyConfigFile = mypyConfigFileMap.get(workspace.root_path) if mypyConfigFile: args.append("--config-file") args.append(mypyConfigFile) args.append(document.path) if settings.get("strict", False): args.append("--strict") overrides = settings.get("overrides", [True]) exit_status = 0 if not dmypy: args.extend(["--incremental", "--follow-imports", settings.get("follow-imports", "silent")]) args = apply_overrides(args, overrides) if shutil.which("mypy"): # mypy exists on path # -> use mypy on path log.info("executing mypy args = %s on path", args) completed_process = subprocess.run( ["mypy", *args], capture_output=True, **windows_flag, encoding="utf-8" ) report = completed_process.stdout errors = completed_process.stderr exit_status = completed_process.returncode else: # mypy does not exist on path, but must exist in the env pylsp-mypy is installed in # -> use mypy via api log.info("executing mypy args = %s via api", args) report, errors, exit_status = mypy_api.run(args) else: # If dmypy daemon is non-responsive calls to run will block. # Check daemon status, if non-zero daemon is dead or hung. # If daemon is hung, kill will reset # If daemon is dead/absent, kill will no-op. # In either case, reset to fresh state if shutil.which("dmypy"): # dmypy exists on path # -> use dmypy on path completed_process = subprocess.run( ["dmypy", "--status-file", dmypy_status_file, "status"], capture_output=True, **windows_flag, encoding="utf-8", ) errors = completed_process.stderr exit_status = completed_process.returncode if exit_status != 0: log.info( "restarting dmypy from status: %s message: %s via path", exit_status, errors.strip(), ) subprocess.run( ["dmypy", "--status-file", dmypy_status_file, "restart"], capture_output=True, **windows_flag, encoding="utf-8", ) else: # dmypy does not exist on path, but must exist in the env pylsp-mypy is installed in # -> use dmypy via api _, errors, exit_status = mypy_api.run_dmypy( ["--status-file", dmypy_status_file, "status"] ) if exit_status != 0: log.info( "restarting dmypy from status: %s message: %s via api", exit_status, errors.strip(), ) mypy_api.run_dmypy(["--status-file", dmypy_status_file, "restart"]) # run to use existing daemon or restart if required args = ["--status-file", dmypy_status_file, "run", "--"] + apply_overrides(args, overrides) if shutil.which("dmypy"): # dmypy exists on path # -> use mypy on path log.info("dmypy run args = %s via path", args) completed_process = subprocess.run( ["dmypy", *args], capture_output=True, **windows_flag, encoding="utf-8" ) report = completed_process.stdout errors = completed_process.stderr exit_status = completed_process.returncode else: # dmypy does not exist on path, but must exist in the env pylsp-mypy is installed in # -> use dmypy via api log.info("dmypy run args = %s via api", args) report, errors, exit_status = mypy_api.run_dmypy(args) log.debug("report:\n%s", report) log.debug("errors:\n%s", errors) diagnostics = [] # Expose generic mypy error on the first line. if errors: diagnostics.append( { "source": "mypy", "range": { "start": {"line": 0, "character": 0}, # Client is supposed to clip end column to line length. "end": {"line": 0, "character": 1000}, }, "message": errors, "severity": 1 if exit_status != 0 else 2, # Error if exited with error or warning. } ) for line in report.splitlines(): log.debug("parsing: line = %r", line) diag = parse_line(line, document) if diag: diagnostics.append(diag) log.info("pylsp-mypy len(diagnostics) = %s", len(diagnostics)) last_diagnostics[document.path] = diagnostics return diagnostics @hookimpl def pylsp_settings(config: Config) -> Dict[str, Dict[str, Dict[str, str]]]: """ Read the settings. Parameters ---------- config : Config The pylsp config. Returns ------- Dict[str, Dict[str, Dict[str, str]]] The config dict. """ configuration = init(config._root_path) return {"plugins": {"pylsp_mypy": configuration}} def init(workspace: str) -> Dict[str, str]: """ Find plugin and mypy config files and creates the temp file should it be used. Parameters ---------- workspace : str The path to the current workspace. Returns ------- Dict[str, str] The plugin config dict. """ log.info("init workspace = %s", workspace) configuration = {} path = findConfigFile( workspace, [], ["pylsp-mypy.cfg", "mypy-ls.cfg", "mypy_ls.cfg", "pyproject.toml"], False ) if path: if "pyproject.toml" in path: with open(path, "rb") as file: configuration = tomllib.load(file).get("tool").get("pylsp-mypy") else: with open(path) as file: configuration = ast.literal_eval(file.read()) configSubPaths = configuration.get("config_sub_paths", []) mypyConfigFile = findConfigFile( workspace, configSubPaths, ["mypy.ini", ".mypy.ini", "pyproject.toml", "setup.cfg"], True ) mypyConfigFileMap[workspace] = mypyConfigFile settingsCache[workspace] = configuration.copy() log.info("mypyConfigFile = %s configuration = %s", mypyConfigFile, configuration) return configuration def findConfigFile( path: str, configSubPaths: List[str], names: List[str], mypy: bool ) -> Optional[str]: """ Search for a config file. Search for a file of a given name from the directory specifyed by path through all parent directories. The first file found is selected. Parameters ---------- path : str The path where the search starts. configSubPaths : List[str] Additional sub search paths in which mypy configs might be located names : List[str] The file to be found (or alternative names). mypy : bool whether the config file searched is for mypy (plugin otherwise) Returns ------- Optional[str] The path where the file has been found or None if no matching file has been found. """ start = Path(path).joinpath(names[0]) # the join causes the parents to include path for parent in start.parents: for name in names: for subPath in [""] + configSubPaths: file = parent.joinpath(subPath).joinpath(name) if file.is_file(): if file.name in ["mypy-ls.cfg", "mypy_ls.cfg"]: raise NameError( f"{str(file)}: {file.name} is no longer supported, you should rename " "your config file to pylsp-mypy.cfg or preferably use a pyproject.toml " "instead." ) if file.name == "pyproject.toml": with open(file, "rb") as fileO: configPresent = ( tomllib.load(fileO) .get("tool", {}) .get("mypy" if mypy else "pylsp-mypy") is not None ) if not configPresent: continue if file.name == "setup.cfg": config = ConfigParser() config.read(str(file)) if "mypy" not in config: continue return str(file) # No config file found in the whole directory tree # -> check mypy default locations for mypy config if mypy: defaultPaths = ["~/.config/mypy/config", "~/.mypy.ini"] XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME") if XDG_CONFIG_HOME: defaultPaths.insert(0, f"{XDG_CONFIG_HOME}/mypy/config") for path in defaultPaths: if Path(path).expanduser().exists(): return str(Path(path).expanduser()) return None @hookimpl def pylsp_code_actions( config: Config, workspace: Workspace, document: Document, range: Dict, context: Dict, ) -> List[Dict]: """ Provide code actions to ignore errors. Parameters ---------- config : pylsp.config.config.Config Current config. workspace : pylsp.workspace.Workspace Current workspace. document : pylsp.workspace.Document Document to apply code actions on. range : Dict Range argument given by pylsp. context : Dict CodeActionContext given as dict. Returns ------- List of dicts containing the code actions. """ actions = [] # Code actions based on diagnostics for diagnostic in context.get("diagnostics", []): if diagnostic["source"] != "mypy": continue code = diagnostic["code"] lineNumberEnd = diagnostic["range"]["end"]["line"] line = document.lines[lineNumberEnd] endOfLine = len(line) - 1 start = {"line": lineNumberEnd, "character": endOfLine} edit_range = {"start": start, "end": start} edit = {"range": edit_range, "newText": f" # type: ignore[{code}]"} action = { "title": f"# type: ignore[{code}]", "kind": "quickfix", "diagnostics": [diagnostic], "edit": {"changes": {document.uri: [edit]}}, } actions.append(action) if context.get("diagnostics", []) != []: return actions # Code actions based on current selected range for diagnostic in last_diagnostics[document.path]: lineNumberStart = diagnostic["range"]["start"]["line"] lineNumberEnd = diagnostic["range"]["end"]["line"] rStart = range["start"]["line"] rEnd = range["end"]["line"] if (rStart <= lineNumberStart and rEnd >= lineNumberStart) or ( rStart <= lineNumberEnd and rEnd >= lineNumberEnd ): code = diagnostic["code"] line = document.lines[lineNumberEnd] endOfLine = len(line) - 1 start = {"line": lineNumberEnd, "character": endOfLine} edit_range = {"start": start, "end": start} edit = {"range": edit_range, "newText": f" # type: ignore[{code}]"} action = { "title": f"# type: ignore[{code}]", "kind": "quickfix", "edit": {"changes": {document.uri: [edit]}}, } actions.append(action) return actions @atexit.register def close() -> None: """ Deltes the tempFile should it exist. Returns ------- None. """ if tmpFile and tmpFile.name: os.unlink(tmpFile.name) pylsp-mypy-0.6.9/pyproject.toml000066400000000000000000000007241466563742500166320ustar00rootroot00000000000000[tool.black] line-length = 100 include = '\.pyi?$' exclude = ''' /( \.git | \.mypy_cache | \.tox | \.venv | _build | build | dist )/ ''' [tool.isort] profile = "black" line_length = 100 [tool.pylsp-mypy] enabled = true live_mode = true strict = true [tool.mypy] python_version = "3.8" [[tool.mypy.overrides]] module = "pylsp.*" ignore_missing_imports = true [[tool.mypy.overrides]] module = "pylsp_mypy.plugin" disallow_untyped_decorators = false pylsp-mypy-0.6.9/requirements.txt000066400000000000000000000001511466563742500171740ustar00rootroot00000000000000python-lsp-server mypy >= 0.981 tomli >= 1.1.0 ; python_version < "3.11" black pre-commit rstcheck isort pylsp-mypy-0.6.9/setup.cfg000066400000000000000000000020001466563742500155240ustar00rootroot00000000000000[metadata] name = pylsp-mypy author = Richard Kellnberger, Tom van Ommeren description = Mypy linter for the Python LSP Server url = https://github.com/python-lsp/pylsp-mypy long_description = file: README.rst license='MIT' classifiers = Development Status :: 4 - Beta Intended Audience :: Developers Topic :: Software Development License :: OSI Approved :: MIT License Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 [options] python_requires = >= 3.8 packages = find: install_requires = python-lsp-server >=1.7.0 mypy >= 0.981 tomli >= 1.1.0 ; python_version < "3.11" [flake8] max-complexity = 20 max-line-length = 100 [options.entry_points] pylsp = pylsp_mypy = pylsp_mypy.plugin [options.extras_require] test = tox pytest pytest-cov coverage [options.packages.find] exclude = contrib docs test pylsp-mypy-0.6.9/setup.py000077500000000000000000000003041466563742500154250ustar00rootroot00000000000000#!/usr/bin/env python from setuptools import setup from pylsp_mypy import _version if __name__ == "__main__": setup(version=_version.__version__, long_description_content_type="text/x-rst") pylsp-mypy-0.6.9/test/000077500000000000000000000000001466563742500146725ustar00rootroot00000000000000pylsp-mypy-0.6.9/test/__init__.py000066400000000000000000000000001466563742500167710ustar00rootroot00000000000000pylsp-mypy-0.6.9/test/test_plugin.py000066400000000000000000000306211466563742500176030ustar00rootroot00000000000000import collections import os import re import subprocess import sys from pathlib import Path from typing import Dict from unittest.mock import Mock, patch import pytest from mypy import api as mypy_api from pylsp import _utils, uris from pylsp.config.config import Config from pylsp.workspace import Document, Workspace from pylsp_mypy import plugin # TODO using these file as a document is a bad idea as tests can break by adding new tests DOC_URI = f"file:/{Path(__file__)}" DOC_TYPE_ERR = """{}.append(3) """ # Mypy 1.7 changed into "Never", so make this a regex to be compatible # with multiple versions of mypy TYPE_ERR_MSG_REGEX = ( r'"Dict\[(?:(?:)|(?:Never)), (?:(?:)|(?:Never))\]" has no attribute "append"' ) TEST_LINE = 'test_plugin.py:279:8:279:16: error: "Request" has no attribute "id" [attr-defined]' TEST_LINE_NOTE = ( 'test_plugin.py:124:1:129:77: note: Use "-> None" if function does not return a value' ) windows_flag: Dict[str, int] = ( {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {} # type: ignore ) @pytest.fixture def last_diagnostics_monkeypatch(monkeypatch): # gets called before every test altering last_diagnostics in order to reset it monkeypatch.setattr(plugin, "last_diagnostics", collections.defaultdict(list)) return monkeypatch @pytest.fixture def workspace(tmpdir): """Return a workspace.""" ws = Workspace(uris.from_fs_path(str(tmpdir)), Mock()) ws._config = Config(ws.root_uri, {}, 0, {}) return ws class FakeConfig(object): def __init__(self, path): self._root_path = path def plugin_settings(self, plugin, document_path=None): return {} def test_settings(tmpdir): config = Config(uris.from_fs_path(str(tmpdir)), {}, 0, {}) settings = plugin.pylsp_settings(config) assert settings == {"plugins": {"pylsp_mypy": {}}} def test_plugin(workspace, last_diagnostics_monkeypatch): doc = Document(DOC_URI, workspace, DOC_TYPE_ERR) plugin.pylsp_settings(workspace._config) diags = plugin.pylsp_lint(workspace._config, workspace, doc, is_saved=False) assert len(diags) == 1 diag = diags[0] assert re.fullmatch(TYPE_ERR_MSG_REGEX, diag["message"]) assert diag["range"]["start"] == {"line": 0, "character": 0} # Running mypy in 3.7 produces wrong error ends this can be removed when 3.7 reaches EOL if sys.version_info < (3, 8): assert diag["range"]["end"] == {"line": 0, "character": 1} else: assert diag["range"]["end"] == {"line": 0, "character": 9} assert diag["severity"] == 1 assert diag["code"] == "attr-defined" def test_parse_full_line(workspace): diag = plugin.parse_line(TEST_LINE) # TODO parse a document here assert diag["message"] == '"Request" has no attribute "id"' assert diag["range"]["start"] == {"line": 278, "character": 7} assert diag["range"]["end"] == {"line": 278, "character": 16} assert diag["severity"] == 1 assert diag["code"] == "attr-defined" def test_parse_note_line(workspace): diag = plugin.parse_line(TEST_LINE_NOTE) assert diag["message"] == 'Use "-> None" if function does not return a value' assert diag["range"]["start"] == {"line": 123, "character": 0} assert diag["range"]["end"] == {"line": 128, "character": 77} assert diag["severity"] == 3 assert diag["code"] is None def test_multiple_workspaces(tmpdir, last_diagnostics_monkeypatch): DOC_SOURCE = """ def foo(): return unreachable = 1 """ DOC_ERR_MSG = "Statement is unreachable" # Initialize two workspace folders. folder1 = tmpdir.mkdir("folder1") folder2 = tmpdir.mkdir("folder2") # Create configuration file for workspace folder 1. mypy_config = folder1.join("mypy.ini") mypy_config.write("[mypy]\nwarn_unreachable = True\ncheck_untyped_defs = True") ws1 = Workspace(uris.from_fs_path(str(folder1)), Mock()) ws1._config = Config(ws1.root_uri, {}, 0, {}) ws2 = Workspace(uris.from_fs_path(str(folder2)), Mock()) ws2._config = Config(ws2.root_uri, {}, 0, {}) # Initialize settings for both folders. plugin.pylsp_settings(ws1._config) plugin.pylsp_settings(ws2._config) # Test document in workspace 1 (uses mypy.ini configuration). doc1 = Document(DOC_URI, ws1, DOC_SOURCE) diags = plugin.pylsp_lint(ws1._config, ws1, doc1, is_saved=False) assert len(diags) == 1 diag = diags[0] assert diag["message"] == DOC_ERR_MSG assert diag["code"] == "unreachable" # Test document in workspace 2 (without mypy.ini configuration) doc2 = Document(DOC_URI, ws2, DOC_SOURCE) diags = plugin.pylsp_lint(ws2._config, ws2, doc2, is_saved=False) assert len(diags) == 0 def test_apply_overrides(): assert plugin.apply_overrides(["1", "2"], []) == [] assert plugin.apply_overrides(["1", "2"], ["a"]) == ["a"] assert plugin.apply_overrides(["1", "2"], ["a", True]) == ["a", "1", "2"] assert plugin.apply_overrides(["1", "2"], [True, "a"]) == ["1", "2", "a"] assert plugin.apply_overrides(["1"], ["a", True, "b"]) == ["a", "1", "b"] @pytest.mark.skipif(os.name == "nt", reason="Not working on Windows due to test design.") def test_option_overrides(tmpdir, last_diagnostics_monkeypatch, workspace): import sys from stat import S_IRWXU from textwrap import dedent sentinel = tmpdir / "ran" source = dedent( """\ #!{} import os, sys, pathlib pathlib.Path({!r}).touch() os.execv({!r}, sys.argv) """ ).format(sys.executable, str(sentinel), sys.executable) wrapper = tmpdir / "bin/wrapper" wrapper.write(source, ensure=True) wrapper.chmod(S_IRWXU) overrides = ["--python-executable", wrapper.strpath, True] last_diagnostics_monkeypatch.setattr( FakeConfig, "plugin_settings", lambda _, p: {"overrides": overrides} if p == "pylsp_mypy" else {}, ) config = FakeConfig(uris.to_fs_path(workspace.root_uri)) plugin.pylsp_settings(config) assert not sentinel.exists() diags = plugin.pylsp_lint( config=config, workspace=workspace, document=Document(DOC_URI, workspace, DOC_TYPE_ERR), is_saved=False, ) assert len(diags) == 1 assert sentinel.exists() def test_option_overrides_dmypy(last_diagnostics_monkeypatch, workspace): overrides = ["--python-executable", "/tmp/fake", True] last_diagnostics_monkeypatch.setattr( FakeConfig, "plugin_settings", lambda _, p: ( { "overrides": overrides, "dmypy": True, "live_mode": False, } if p == "pylsp_mypy" else {} ), ) m = Mock(wraps=lambda a, **_: Mock(returncode=0, **{"stdout": ""})) last_diagnostics_monkeypatch.setattr(plugin.subprocess, "run", m) document = Document(DOC_URI, workspace, DOC_TYPE_ERR) config = FakeConfig(uris.to_fs_path(workspace.root_uri)) plugin.pylsp_settings(config) plugin.pylsp_lint( config=config, workspace=workspace, document=document, is_saved=False, ) expected = [ "dmypy", "--status-file", ".dmypy.json", "run", "--", "--python-executable", "/tmp/fake", "--show-error-end", "--no-error-summary", "--no-pretty", document.path, ] m.assert_called_with(expected, capture_output=True, **windows_flag, encoding="utf-8") def test_dmypy_status_file(tmpdir, last_diagnostics_monkeypatch, workspace): statusFile = tmpdir / ".custom_dmypy_status_file.json" last_diagnostics_monkeypatch.setattr( FakeConfig, "plugin_settings", lambda _, p: ( { "dmypy": True, "live_mode": False, "dmypy_status_file": str(statusFile), } if p == "pylsp_mypy" else {} ), ) document = Document(DOC_URI, workspace, DOC_TYPE_ERR) config = FakeConfig(uris.to_fs_path(workspace.root_uri)) plugin.pylsp_settings(config) assert not statusFile.exists() try: plugin.pylsp_lint( config=config, workspace=workspace, document=document, is_saved=False, ) assert statusFile.exists() finally: mypy_api.run_dmypy(["--status-file", str(statusFile), "stop"]) def test_config_sub_paths(tmpdir, last_diagnostics_monkeypatch): DOC_SOURCE = """ def foo(): return unreachable = 1 """ DOC_ERR_MSG = "Statement is unreachable" config_sub_paths = [".config"] # Create configuration file for workspace. plugin_config = tmpdir.join("pyproject.toml") plugin_config.write(f"[tool.pylsp-mypy]\nenabled = true\nconfig_sub_paths = {config_sub_paths}") config_dir = tmpdir.mkdir(".config") mypy_config = config_dir.join("mypy.ini") mypy_config.write("[mypy]\nwarn_unreachable = True\ncheck_untyped_defs = True") # Initialize workspace. ws = Workspace(uris.from_fs_path(str(tmpdir)), Mock()) ws._config = Config(ws.root_uri, {}, 0, {}) # Update settings for workspace. settings = plugin.pylsp_settings(ws._config) ws._config._plugin_settings = _utils.merge_dicts(ws._config._plugin_settings, settings) # Test document to make sure it uses .config/mypy.ini configuration. doc = Document(DOC_URI, ws, DOC_SOURCE) diags = plugin.pylsp_lint(ws._config, ws, doc, is_saved=False) assert len(diags) == 1 diag = diags[0] assert diag["message"] == DOC_ERR_MSG assert diag["code"] == "unreachable" def test_config_sub_paths_config_changed(tmpdir, last_diagnostics_monkeypatch): DOC_SOURCE = """ def foo(): return unreachable = 1 """ DOC_ERR_MSG = "Statement is unreachable" # Create configuration file for workspace. config_dir = tmpdir.mkdir(".config") mypy_config = config_dir.join("mypy.ini") mypy_config.write("[mypy]\nwarn_unreachable = True\ncheck_untyped_defs = True") config_sub_paths = [".config"] # Initialize workspace. ws = Workspace(uris.from_fs_path(str(tmpdir)), Mock()) ws._config = Config(ws.root_uri, {}, 0, {}) # Update settings for workspace. plugin.pylsp_settings(ws._config) ws.update_config({"pylsp": {"plugins": {"pylsp_mypy": {"config_sub_paths": config_sub_paths}}}}) # Test document to make sure it uses .config/mypy.ini configuration. doc = Document(DOC_URI, ws, DOC_SOURCE) diags = plugin.pylsp_lint(ws._config, ws, doc, is_saved=False) assert len(diags) == 1 diag = diags[0] assert diag["message"] == DOC_ERR_MSG assert diag["code"] == "unreachable" @pytest.mark.parametrize( "document_path,pattern,os_sep,pattern_matched", ( ("/workspace/my-file.py", "/someting-else", "/", False), ("/workspace/my-file.py", "^/workspace$", "/", False), ("/workspace/my-file.py", "/workspace", "/", True), ("/workspace/my-file.py", "^/workspace(.*)$", "/", True), # This is a broken regex (missing ')'), but should not choke ("/workspace/my-file.py", "/((workspace)", "/", False), # Windows paths are tricky with all those \\ and unintended escape, # characters but they should 'just' work ("d:\\a\\my-file.py", "/a", "\\", True), ( "d:\\a\\pylsp-mypy\\pylsp-mypy\\test\\test_plugin.py", "/a/pylsp-mypy/pylsp-mypy/test/test_plugin.py", "\\", True, ), ), ) def test_match_exclude_patterns(document_path, pattern, os_sep, pattern_matched): with patch("os.sep", new=os_sep): assert ( plugin.match_exclude_patterns(document_path=document_path, exclude_patterns=[pattern]) is pattern_matched ) def test_config_exclude(tmpdir, workspace): """When exclude is set in config then mypy should not run for that file.""" doc = Document(DOC_URI, workspace, DOC_TYPE_ERR) plugin.pylsp_settings(workspace._config) workspace.update_config({"pylsp": {"plugins": {"pylsp_mypy": {}}}}) diags = plugin.pylsp_lint(workspace._config, workspace, doc, is_saved=False) assert re.search(TYPE_ERR_MSG_REGEX, diags[0]["message"]) # Add the path of our document to the exclude patterns exclude_path = doc.path.replace(os.sep, "/") workspace.update_config({"pylsp": {"plugins": {"pylsp_mypy": {"exclude": [exclude_path]}}}}) diags = plugin.pylsp_lint(workspace._config, workspace, doc, is_saved=False) assert diags == []