diff --git a/pylsp/plugins/autopep8_format.py b/pylsp/plugins/autopep8_format.py index 8d184b7a..44f45dc2 100644 --- a/pylsp/plugins/autopep8_format.py +++ b/pylsp/plugins/autopep8_format.py @@ -13,23 +13,27 @@ @hookimpl(tryfirst=True) # Prefer autopep8 over YAPF -def pylsp_format_document(config, document, options): # pylint: disable=unused-argument - log.info("Formatting document %s with autopep8", document) - return _format(config, document) +def pylsp_format_document(config, workspace, document, options): # pylint: disable=unused-argument + with workspace.report_progress("format: autopep8"): + log.info("Formatting document %s with autopep8", document) + return _format(config, document) @hookimpl(tryfirst=True) # Prefer autopep8 over YAPF -def pylsp_format_range(config, document, range, options): # pylint: disable=redefined-builtin,unused-argument - log.info("Formatting document %s in range %s with autopep8", document, range) - - # First we 'round' the range up/down to full lines only - range['start']['character'] = 0 - range['end']['line'] += 1 - range['end']['character'] = 0 - - # Add 1 for 1-indexing vs LSP's 0-indexing - line_range = (range['start']['line'] + 1, range['end']['line'] + 1) - return _format(config, document, line_range=line_range) +def pylsp_format_range( + config, workspace, document, range, options +): # pylint: disable=redefined-builtin,unused-argument + with workspace.report_progress("format_range: autopep8"): + log.info("Formatting document %s in range %s with autopep8", document, range) + + # First we 'round' the range up/down to full lines only + range['start']['character'] = 0 + range['end']['line'] += 1 + range['end']['character'] = 0 + + # Add 1 for 1-indexing vs LSP's 0-indexing + line_range = (range['start']['line'] + 1, range['end']['line'] + 1) + return _format(config, document, line_range=line_range) def _format(config, document, line_range=None): diff --git a/pylsp/plugins/definition.py b/pylsp/plugins/definition.py index 98265fdb..bf707b76 100644 --- a/pylsp/plugins/definition.py +++ b/pylsp/plugins/definition.py @@ -8,24 +8,25 @@ @hookimpl -def pylsp_definitions(config, document, position): - settings = config.plugin_settings('jedi_definition') - code_position = _utils.position_to_jedi_linecolumn(document, position) - definitions = document.jedi_script(use_document_path=True).goto( - follow_imports=settings.get('follow_imports', True), - follow_builtin_imports=settings.get('follow_builtin_imports', True), - **code_position) +def pylsp_definitions(config, workspace, document, position): + with workspace.report_progress("go to definitions"): + settings = config.plugin_settings('jedi_definition') + code_position = _utils.position_to_jedi_linecolumn(document, position) + definitions = document.jedi_script(use_document_path=True).goto( + follow_imports=settings.get('follow_imports', True), + follow_builtin_imports=settings.get('follow_builtin_imports', True), + **code_position) - return [ - { - 'uri': uris.uri_with(document.uri, path=str(d.module_path)), - 'range': { - 'start': {'line': d.line - 1, 'character': d.column}, - 'end': {'line': d.line - 1, 'character': d.column + len(d.name)}, + return [ + { + 'uri': uris.uri_with(document.uri, path=str(d.module_path)), + 'range': { + 'start': {'line': d.line - 1, 'character': d.column}, + 'end': {'line': d.line - 1, 'character': d.column + len(d.name)}, + } } - } - for d in definitions if d.is_definition() and _not_internal_definition(d) - ] + for d in definitions if d.is_definition() and _not_internal_definition(d) + ] def _not_internal_definition(definition): diff --git a/pylsp/plugins/flake8_lint.py b/pylsp/plugins/flake8_lint.py index b1a321d3..94a3c2af 100644 --- a/pylsp/plugins/flake8_lint.py +++ b/pylsp/plugins/flake8_lint.py @@ -30,57 +30,58 @@ def pylsp_settings(): @hookimpl def pylsp_lint(workspace, document): - config = workspace._config - settings = config.plugin_settings('flake8', document_path=document.path) - log.debug("Got flake8 settings: %s", settings) - - ignores = settings.get("ignore", []) - per_file_ignores = settings.get("perFileIgnores") - - if per_file_ignores: - prev_file_pat = None - for path in per_file_ignores: - try: - file_pat, errors = path.split(":") - prev_file_pat = file_pat - except ValueError: - # It's legal to just specify another error type for the same - # file pattern: - if prev_file_pat is None: - log.warning( - "skipping a Per-file-ignore with no file pattern") - continue - file_pat = prev_file_pat - errors = path - if PurePath(document.path).match(file_pat): - ignores.extend(errors.split(",")) - - opts = { - 'config': settings.get('config'), - 'exclude': settings.get('exclude'), - 'filename': settings.get('filename'), - 'hang-closing': settings.get('hangClosing'), - 'ignore': ignores or None, - 'max-complexity': settings.get('maxComplexity'), - 'max-line-length': settings.get('maxLineLength'), - 'indent-size': settings.get('indentSize'), - 'select': settings.get('select'), - } - - # flake takes only absolute path to the config. So we should check and - # convert if necessary - if opts.get('config') and not os.path.isabs(opts.get('config')): - opts['config'] = os.path.abspath(os.path.expanduser(os.path.expandvars( - opts.get('config') - ))) - log.debug("using flake8 with config: %s", opts['config']) - - # Call the flake8 utility then parse diagnostics from stdout - flake8_executable = settings.get('executable', 'flake8') - - args = build_args(opts) - output = run_flake8(flake8_executable, args, document) - return parse_stdout(document, output) + with workspace.report_progress("lint: flake8"): + config = workspace._config + settings = config.plugin_settings('flake8', document_path=document.path) + log.debug("Got flake8 settings: %s", settings) + + ignores = settings.get("ignore", []) + per_file_ignores = settings.get("perFileIgnores") + + if per_file_ignores: + prev_file_pat = None + for path in per_file_ignores: + try: + file_pat, errors = path.split(":") + prev_file_pat = file_pat + except ValueError: + # It's legal to just specify another error type for the same + # file pattern: + if prev_file_pat is None: + log.warning( + "skipping a Per-file-ignore with no file pattern") + continue + file_pat = prev_file_pat + errors = path + if PurePath(document.path).match(file_pat): + ignores.extend(errors.split(",")) + + opts = { + 'config': settings.get('config'), + 'exclude': settings.get('exclude'), + 'filename': settings.get('filename'), + 'hang-closing': settings.get('hangClosing'), + 'ignore': ignores or None, + 'max-complexity': settings.get('maxComplexity'), + 'max-line-length': settings.get('maxLineLength'), + 'indent-size': settings.get('indentSize'), + 'select': settings.get('select'), + } + + # flake takes only absolute path to the config. So we should check and + # convert if necessary + if opts.get('config') and not os.path.isabs(opts.get('config')): + opts['config'] = os.path.abspath(os.path.expanduser(os.path.expandvars( + opts.get('config') + ))) + log.debug("using flake8 with config: %s", opts['config']) + + # Call the flake8 utility then parse diagnostics from stdout + flake8_executable = settings.get('executable', 'flake8') + + args = build_args(opts) + output = run_flake8(flake8_executable, args, document) + return parse_stdout(document, output) def run_flake8(flake8_executable, args, document): diff --git a/pylsp/plugins/jedi_rename.py b/pylsp/plugins/jedi_rename.py index c1edc75f..64949bb0 100644 --- a/pylsp/plugins/jedi_rename.py +++ b/pylsp/plugins/jedi_rename.py @@ -9,39 +9,44 @@ @hookimpl -def pylsp_rename(config, workspace, document, position, new_name): # pylint: disable=unused-argument - log.debug('Executing rename of %s to %s', document.word_at_position(position), new_name) - kwargs = _utils.position_to_jedi_linecolumn(document, position) - kwargs['new_name'] = new_name - try: - refactoring = document.jedi_script().rename(**kwargs) - except NotImplementedError as exc: - raise Exception('No support for renaming in Python 2/3.5 with Jedi. ' - 'Consider using the rope_rename plugin instead') from exc - log.debug('Finished rename: %s', refactoring.get_diff()) - changes = [] - for file_path, changed_file in refactoring.get_changed_files().items(): - uri = uris.from_fs_path(str(file_path)) - doc = workspace.get_maybe_document(uri) - changes.append({ - 'textDocument': { - 'uri': uri, - 'version': doc.version if doc else None - }, - 'edits': [ - { - 'range': { - 'start': {'line': 0, 'character': 0}, - 'end': { - 'line': _num_lines(changed_file.get_new_code()), - 'character': 0, +def pylsp_rename(config, workspace, document, position, new_name): # pylint: disable=unused-argument,too-many-locals + with workspace.report_progress("rename", percentage=0) as report_progress: + log.debug('Executing rename of %s to %s', document.word_at_position(position), new_name) + kwargs = _utils.position_to_jedi_linecolumn(document, position) + kwargs['new_name'] = new_name + report_progress("refactoring") + try: + refactoring = document.jedi_script().rename(**kwargs) + except NotImplementedError as exc: + raise Exception('No support for renaming in Python 2/3.5 with Jedi. ' + 'Consider using the rope_rename plugin instead') from exc + log.debug('Finished rename: %s', refactoring.get_diff()) + changes = [] + + changed_files = refactoring.get_changed_files() + for n, (file_path, changed_file) in enumerate(changed_files.items()): + report_progress(changed_file, percentage=n/len(changed_files)*100) + uri = uris.from_fs_path(str(file_path)) + doc = workspace.get_maybe_document(uri) + changes.append({ + 'textDocument': { + 'uri': uri, + 'version': doc.version if doc else None + }, + 'edits': [ + { + 'range': { + 'start': {'line': 0, 'character': 0}, + 'end': { + 'line': _num_lines(changed_file.get_new_code()), + 'character': 0, + }, }, - }, - 'newText': changed_file.get_new_code(), - } - ], - }) - return {'documentChanges': changes} + 'newText': changed_file.get_new_code(), + } + ], + }) + return {'documentChanges': changes} def _num_lines(file_contents): diff --git a/pylsp/plugins/mccabe_lint.py b/pylsp/plugins/mccabe_lint.py index 77ff3a05..41182e4c 100644 --- a/pylsp/plugins/mccabe_lint.py +++ b/pylsp/plugins/mccabe_lint.py @@ -13,30 +13,31 @@ @hookimpl -def pylsp_lint(config, document): - threshold = config.plugin_settings('mccabe', document_path=document.path).get(THRESHOLD, DEFAULT_THRESHOLD) - log.debug("Running mccabe lint with threshold: %s", threshold) - - try: - tree = compile(document.source, document.path, "exec", ast.PyCF_ONLY_AST) - except SyntaxError: - # We'll let the other linters point this one out - return None - - visitor = mccabe.PathGraphingAstVisitor() - visitor.preorder(tree, visitor) - - diags = [] - for graph in visitor.graphs.values(): - if graph.complexity() >= threshold: - diags.append({ - 'source': 'mccabe', - 'range': { - 'start': {'line': graph.lineno - 1, 'character': graph.column}, - 'end': {'line': graph.lineno - 1, 'character': len(document.lines[graph.lineno])}, - }, - 'message': 'Cyclomatic complexity too high: %s (threshold %s)' % (graph.complexity(), threshold), - 'severity': lsp.DiagnosticSeverity.Warning - }) - - return diags +def pylsp_lint(config, workspace, document): + with workspace.report_progress("lint: mccabe"): + threshold = config.plugin_settings('mccabe', document_path=document.path).get(THRESHOLD, DEFAULT_THRESHOLD) + log.debug("Running mccabe lint with threshold: %s", threshold) + + try: + tree = compile(document.source, document.path, "exec", ast.PyCF_ONLY_AST) + except SyntaxError: + # We'll let the other linters point this one out + return None + + visitor = mccabe.PathGraphingAstVisitor() + visitor.preorder(tree, visitor) + + diags = [] + for graph in visitor.graphs.values(): + if graph.complexity() >= threshold: + diags.append({ + 'source': 'mccabe', + 'range': { + 'start': {'line': graph.lineno - 1, 'character': graph.column}, + 'end': {'line': graph.lineno - 1, 'character': len(document.lines[graph.lineno])}, + }, + 'message': 'Cyclomatic complexity too high: %s (threshold %s)' % (graph.complexity(), threshold), + 'severity': lsp.DiagnosticSeverity.Warning + }) + + return diags diff --git a/pylsp/plugins/pycodestyle_lint.py b/pylsp/plugins/pycodestyle_lint.py index 30aeb67a..3702fdb9 100644 --- a/pylsp/plugins/pycodestyle_lint.py +++ b/pylsp/plugins/pycodestyle_lint.py @@ -22,30 +22,31 @@ @hookimpl def pylsp_lint(workspace, document): - config = workspace._config - settings = config.plugin_settings('pycodestyle', document_path=document.path) - log.debug("Got pycodestyle settings: %s", settings) - - opts = { - 'exclude': settings.get('exclude'), - 'filename': settings.get('filename'), - 'hang_closing': settings.get('hangClosing'), - 'ignore': settings.get('ignore'), - 'max_line_length': settings.get('maxLineLength'), - 'indent_size': settings.get('indentSize'), - 'select': settings.get('select'), - } - kwargs = {k: v for k, v in opts.items() if v} - styleguide = pycodestyle.StyleGuide(kwargs) - - c = pycodestyle.Checker( - filename=document.uri, lines=document.lines, options=styleguide.options, - report=PyCodeStyleDiagnosticReport(styleguide.options) - ) - c.check_all() - diagnostics = c.report.diagnostics - - return diagnostics + with workspace.report_progress("lint: pycodestyle"): + config = workspace._config + settings = config.plugin_settings('pycodestyle', document_path=document.path) + log.debug("Got pycodestyle settings: %s", settings) + + opts = { + 'exclude': settings.get('exclude'), + 'filename': settings.get('filename'), + 'hang_closing': settings.get('hangClosing'), + 'ignore': settings.get('ignore'), + 'max_line_length': settings.get('maxLineLength'), + 'indent_size': settings.get('indentSize'), + 'select': settings.get('select'), + } + kwargs = {k: v for k, v in opts.items() if v} + styleguide = pycodestyle.StyleGuide(kwargs) + + c = pycodestyle.Checker( + filename=document.uri, lines=document.lines, options=styleguide.options, + report=PyCodeStyleDiagnosticReport(styleguide.options) + ) + c.check_all() + diagnostics = c.report.diagnostics + + return diagnostics class PyCodeStyleDiagnosticReport(pycodestyle.BaseReport): diff --git a/pylsp/plugins/pydocstyle_lint.py b/pylsp/plugins/pydocstyle_lint.py index 7a986aa6..7f4e0723 100644 --- a/pylsp/plugins/pydocstyle_lint.py +++ b/pylsp/plugins/pydocstyle_lint.py @@ -27,60 +27,61 @@ def pylsp_settings(): @hookimpl -def pylsp_lint(config, document): - settings = config.plugin_settings('pydocstyle', document_path=document.path) - log.debug("Got pydocstyle settings: %s", settings) - - # Explicitly passing a path to pydocstyle means it doesn't respect the --match flag, so do it ourselves - filename_match_re = re.compile(settings.get('match', DEFAULT_MATCH_RE) + '$') - if not filename_match_re.match(os.path.basename(document.path)): - return [] - - # Likewise with --match-dir - dir_match_re = re.compile(settings.get('matchDir', DEFAULT_MATCH_DIR_RE) + '$') - if not dir_match_re.match(os.path.basename(os.path.dirname(document.path))): - return [] - - args = [document.path] - - if settings.get('convention'): - args.append('--convention=' + settings['convention']) - - if settings.get('addSelect'): - args.append('--add-select=' + ','.join(settings['addSelect'])) - if settings.get('addIgnore'): - args.append('--add-ignore=' + ','.join(settings['addIgnore'])) - - elif settings.get('select'): - args.append('--select=' + ','.join(settings['select'])) - elif settings.get('ignore'): - args.append('--ignore=' + ','.join(settings['ignore'])) - - log.info("Using pydocstyle args: %s", args) - - conf = pydocstyle.config.ConfigurationParser() - with _patch_sys_argv(args): - # TODO(gatesn): We can add more pydocstyle args here from our pylsp config - conf.parse() - - # Will only yield a single filename, the document path - diags = [] - for filename, checked_codes, ignore_decorators in conf.get_files_to_check(): - errors = pydocstyle.checker.ConventionChecker().check_source( - document.source, filename, ignore_decorators=ignore_decorators - ) - - try: - for error in errors: - if error.code not in checked_codes: - continue - diags.append(_parse_diagnostic(document, error)) - except pydocstyle.parser.ParseError: - # In the case we cannot parse the Python file, just continue - pass - - log.debug("Got pydocstyle errors: %s", diags) - return diags +def pylsp_lint(config, workspace, document): + with workspace.report_progress("lint: pydocstyle"): + settings = config.plugin_settings('pydocstyle', document_path=document.path) + log.debug("Got pydocstyle settings: %s", settings) + + # Explicitly passing a path to pydocstyle means it doesn't respect the --match flag, so do it ourselves + filename_match_re = re.compile(settings.get('match', DEFAULT_MATCH_RE) + '$') + if not filename_match_re.match(os.path.basename(document.path)): + return [] + + # Likewise with --match-dir + dir_match_re = re.compile(settings.get('matchDir', DEFAULT_MATCH_DIR_RE) + '$') + if not dir_match_re.match(os.path.basename(os.path.dirname(document.path))): + return [] + + args = [document.path] + + if settings.get('convention'): + args.append('--convention=' + settings['convention']) + + if settings.get('addSelect'): + args.append('--add-select=' + ','.join(settings['addSelect'])) + if settings.get('addIgnore'): + args.append('--add-ignore=' + ','.join(settings['addIgnore'])) + + elif settings.get('select'): + args.append('--select=' + ','.join(settings['select'])) + elif settings.get('ignore'): + args.append('--ignore=' + ','.join(settings['ignore'])) + + log.info("Using pydocstyle args: %s", args) + + conf = pydocstyle.config.ConfigurationParser() + with _patch_sys_argv(args): + # TODO(gatesn): We can add more pydocstyle args here from our pylsp config + conf.parse() + + # Will only yield a single filename, the document path + diags = [] + for filename, checked_codes, ignore_decorators in conf.get_files_to_check(): + errors = pydocstyle.checker.ConventionChecker().check_source( + document.source, filename, ignore_decorators=ignore_decorators + ) + + try: + for error in errors: + if error.code not in checked_codes: + continue + diags.append(_parse_diagnostic(document, error)) + except pydocstyle.parser.ParseError: + # In the case we cannot parse the Python file, just continue + pass + + log.debug("Got pydocstyle errors: %s", diags) + return diags def _parse_diagnostic(document, error): diff --git a/pylsp/plugins/pyflakes_lint.py b/pylsp/plugins/pyflakes_lint.py index da0ee66b..72e16a2e 100644 --- a/pylsp/plugins/pyflakes_lint.py +++ b/pylsp/plugins/pyflakes_lint.py @@ -21,10 +21,11 @@ @hookimpl -def pylsp_lint(document): - reporter = PyflakesDiagnosticReport(document.lines) - pyflakes_api.check(document.source.encode('utf-8'), document.path, reporter=reporter) - return reporter.diagnostics +def pylsp_lint(workspace, document): + with workspace.report_progress("lint: pyflakes"): + reporter = PyflakesDiagnosticReport(document.lines) + pyflakes_api.check(document.source.encode('utf-8'), document.path, reporter=reporter) + return reporter.diagnostics class PyflakesDiagnosticReport: diff --git a/pylsp/plugins/pylint_lint.py b/pylsp/plugins/pylint_lint.py index f33cfcd8..459afbe3 100644 --- a/pylsp/plugins/pylint_lint.py +++ b/pylsp/plugins/pylint_lint.py @@ -201,18 +201,19 @@ def pylsp_settings(): @hookimpl -def pylsp_lint(config, document, is_saved): +def pylsp_lint(config, workspace, document, is_saved): """Run pylint linter.""" - settings = config.plugin_settings('pylint') - log.debug("Got pylint settings: %s", settings) - # pylint >= 2.5.0 is required for working through stdin and only - # available with python3 - if settings.get('executable') and sys.version_info[0] >= 3: - flags = build_args_stdio(settings) - pylint_executable = settings.get('executable', 'pylint') - return pylint_lint_stdin(pylint_executable, document, flags) - flags = _build_pylint_flags(settings) - return PylintLinter.lint(document, is_saved, flags=flags) + with workspace.report_progress("lint: pylint"): + settings = config.plugin_settings('pylint') + log.debug("Got pylint settings: %s", settings) + # pylint >= 2.5.0 is required for working through stdin and only + # available with python3 + if settings.get('executable') and sys.version_info[0] >= 3: + flags = build_args_stdio(settings) + pylint_executable = settings.get('executable', 'pylint') + return pylint_lint_stdin(pylint_executable, document, flags) + flags = _build_pylint_flags(settings) + return PylintLinter.lint(document, is_saved, flags=flags) def build_args_stdio(settings): diff --git a/pylsp/plugins/references.py b/pylsp/plugins/references.py index 4ef2072a..9873d7e1 100644 --- a/pylsp/plugins/references.py +++ b/pylsp/plugins/references.py @@ -8,19 +8,20 @@ @hookimpl -def pylsp_references(document, position, exclude_declaration=False): - code_position = _utils.position_to_jedi_linecolumn(document, position) - usages = document.jedi_script().get_references(**code_position) +def pylsp_references(document, workspace, position, exclude_declaration=False): + with workspace.report_progress("references"): + code_position = _utils.position_to_jedi_linecolumn(document, position) + usages = document.jedi_script().get_references(**code_position) - if exclude_declaration: - # Filter out if the usage is the actual declaration of the thing - usages = [d for d in usages if not d.is_definition()] + if exclude_declaration: + # Filter out if the usage is the actual declaration of the thing + usages = [d for d in usages if not d.is_definition()] - # Filter out builtin modules - return [{ - 'uri': uris.uri_with(document.uri, path=str(d.module_path)) if d.module_path else document.uri, - 'range': { - 'start': {'line': d.line - 1, 'character': d.column}, - 'end': {'line': d.line - 1, 'character': d.column + len(d.name)} - } - } for d in usages if not d.in_builtin_module()] + # Filter out builtin modules + return [{ + 'uri': uris.uri_with(document.uri, path=str(d.module_path)) if d.module_path else document.uri, + 'range': { + 'start': {'line': d.line - 1, 'character': d.column}, + 'end': {'line': d.line - 1, 'character': d.column + len(d.name)} + } + } for d in usages if not d.in_builtin_module()] diff --git a/pylsp/plugins/rope_rename.py b/pylsp/plugins/rope_rename.py index d9ebab5c..eccbbbec 100644 --- a/pylsp/plugins/rope_rename.py +++ b/pylsp/plugins/rope_rename.py @@ -19,41 +19,42 @@ def pylsp_settings(): @hookimpl def pylsp_rename(config, workspace, document, position, new_name): - rope_config = config.settings(document_path=document.path).get('rope', {}) - rope_project = workspace._rope_project_builder(rope_config) - - rename = Rename( - rope_project, - libutils.path_to_resource(rope_project, document.path), - document.offset_at_position(position) - ) - - log.debug("Executing rename of %s to %s", document.word_at_position(position), new_name) - changeset = rename.get_changes(new_name, in_hierarchy=True, docs=True) - log.debug("Finished rename: %s", changeset.changes) - changes = [] - for change in changeset.changes: - uri = uris.from_fs_path(change.resource.path) - doc = workspace.get_maybe_document(uri) - changes.append({ - 'textDocument': { - 'uri': uri, - 'version': doc.version if doc else None - }, - 'edits': [ - { - 'range': { - 'start': {'line': 0, 'character': 0}, - 'end': { - 'line': _num_lines(change.resource), - 'character': 0, + with workspace.report_progress("rename"): + rope_config = config.settings(document_path=document.path).get('rope', {}) + rope_project = workspace._rope_project_builder(rope_config) + + rename = Rename( + rope_project, + libutils.path_to_resource(rope_project, document.path), + document.offset_at_position(position) + ) + + log.debug("Executing rename of %s to %s", document.word_at_position(position), new_name) + changeset = rename.get_changes(new_name, in_hierarchy=True, docs=True) + log.debug("Finished rename: %s", changeset.changes) + changes = [] + for change in changeset.changes: + uri = uris.from_fs_path(change.resource.path) + doc = workspace.get_maybe_document(uri) + changes.append({ + 'textDocument': { + 'uri': uri, + 'version': doc.version if doc else None + }, + 'edits': [ + { + 'range': { + 'start': {'line': 0, 'character': 0}, + 'end': { + 'line': _num_lines(change.resource), + 'character': 0, + }, }, - }, - 'newText': change.new_contents, - } - ] - }) - return {'documentChanges': changes} + 'newText': change.new_contents, + } + ] + }) + return {'documentChanges': changes} def _num_lines(resource): diff --git a/pylsp/plugins/yapf_format.py b/pylsp/plugins/yapf_format.py index 827aeb2d..4d296eaa 100644 --- a/pylsp/plugins/yapf_format.py +++ b/pylsp/plugins/yapf_format.py @@ -16,26 +16,28 @@ @hookimpl -def pylsp_format_document(document, options): - return _format(document, options=options) +def pylsp_format_document(workspace, document, options): + with workspace.report_progress("format: yapf"): + return _format(document, options=options) @hookimpl -def pylsp_format_range(document, range, options): # pylint: disable=redefined-builtin - # First we 'round' the range up/down to full lines only - range['start']['character'] = 0 - range['end']['line'] += 1 - range['end']['character'] = 0 - - # From Yapf docs: - # lines: (list of tuples of integers) A list of tuples of lines, [start, end], - # that we want to format. The lines are 1-based indexed. It can be used by - # third-party code (e.g., IDEs) when reformatting a snippet of code rather - # than a whole file. - - # Add 1 for 1-indexing vs LSP's 0-indexing - lines = [(range['start']['line'] + 1, range['end']['line'] + 1)] - return _format(document, lines=lines, options=options) +def pylsp_format_range(workspace, document, range, options): # pylint: disable=redefined-builtin + with workspace.report_progress("format_range: yapf"): + # First we 'round' the range up/down to full lines only + range['start']['character'] = 0 + range['end']['line'] += 1 + range['end']['character'] = 0 + + # From Yapf docs: + # lines: (list of tuples of integers) A list of tuples of lines, [start, end], + # that we want to format. The lines are 1-based indexed. It can be used by + # third-party code (e.g., IDEs) when reformatting a snippet of code rather + # than a whole file. + + # Add 1 for 1-indexing vs LSP's 0-indexing + lines = [(range['start']['line'] + 1, range['end']['line'] + 1)] + return _format(document, lines=lines, options=options) def get_style_config(document_path, options=None): diff --git a/pylsp/workspace.py b/pylsp/workspace.py index 914e76ba..5647a90a 100644 --- a/pylsp/workspace.py +++ b/pylsp/workspace.py @@ -3,11 +3,13 @@ import io import logging +from contextlib import contextmanager import os import re +import uuid import functools +from typing import Optional, Generator, Callable from threading import RLock -from typing import Optional import jedi @@ -34,6 +36,7 @@ def wrapper(self, *args, **kwargs): class Workspace: M_PUBLISH_DIAGNOSTICS = 'textDocument/publishDiagnostics' + M_PROGRESS = '$/progress' M_APPLY_EDIT = 'workspace/applyEdit' M_SHOW_MESSAGE = 'window/showMessage' @@ -125,6 +128,85 @@ def apply_edit(self, edit): def publish_diagnostics(self, doc_uri, diagnostics): self._endpoint.notify(self.M_PUBLISH_DIAGNOSTICS, params={'uri': doc_uri, 'diagnostics': diagnostics}) + @contextmanager + def report_progress( + self, + title: str, + message: Optional[str] = None, + percentage: Optional[int] = None, + ) -> Generator[Callable[[str, Optional[int]], None], None, None]: + token = self._progress_begin(title, message, percentage) + + def progress_message(message: str, percentage: Optional[int] = None) -> None: + self._progress_report(token, message, percentage) + + try: + yield progress_message + finally: + self._progress_end(token) + + def _progress_begin( + self, + title: str, + message: Optional[str] = None, + percentage: Optional[int] = None, + ) -> str: + token = str(uuid.uuid4()) + value = { + "kind": "begin", + "title": title, + } + if message: + value["message"] = message + if percentage: + value["percentage"] = percentage + + self._endpoint.notify( + self.M_PROGRESS, + params={ + "token": token, + "value": value, + }, + ) + return token + + def _progress_report( + self, + token: str, + message: Optional[str] = None, + percentage: Optional[int] = None, + ) -> None: + value = { + "kind": "report", + } + if message: + value["message"] = message + if percentage: + value["percentage"] = percentage + + self._endpoint.notify( + self.M_PROGRESS, + params={ + "token": token, + "value": value, + }, + ) + + def _progress_end(self, token: str, message: Optional[str] = None) -> None: + value = { + "kind": "end", + } + if message: + value["message"] = message + + self._endpoint.notify( + self.M_PROGRESS, + params={ + "token": token, + "value": value, + }, + ) + def show_message(self, message, msg_type=lsp.MessageType.Info): self._endpoint.notify(self.M_SHOW_MESSAGE, params={'type': msg_type, 'message': message}) diff --git a/test/fixtures.py b/test/fixtures.py index ad1f8ce3..5763d462 100644 --- a/test/fixtures.py +++ b/test/fixtures.py @@ -3,8 +3,9 @@ import os from io import StringIO -from unittest.mock import Mock +from unittest.mock import MagicMock import pytest +from pylsp_jsonrpc.endpoint import Endpoint from pylsp import uris from pylsp.config.config import Config @@ -62,20 +63,30 @@ def pylsp_w_workspace_folders(tmpdir): return (ls, workspace_folders) +@pytest.fixture() +def consumer(): + return MagicMock() + + +@pytest.fixture() +def endpoint(consumer): # pylint: disable=redefined-outer-name + return Endpoint({}, consumer, id_generator=lambda: "id") + + @pytest.fixture -def workspace(tmpdir): +def workspace(tmpdir, endpoint): # pylint: disable=redefined-outer-name """Return a workspace.""" - ws = Workspace(uris.from_fs_path(str(tmpdir)), Mock()) + ws = Workspace(uris.from_fs_path(str(tmpdir)), endpoint) ws._config = Config(ws.root_uri, {}, 0, {}) yield ws ws.close() @pytest.fixture -def workspace_other_root_path(tmpdir): +def workspace_other_root_path(tmpdir, endpoint): # pylint: disable=redefined-outer-name """Return a workspace with a root_path other than tmpdir.""" ws_path = str(tmpdir.mkdir('test123').mkdir('test456')) - ws = Workspace(uris.from_fs_path(ws_path), Mock()) + ws = Workspace(uris.from_fs_path(ws_path), endpoint) ws._config = Config(ws.root_uri, {}, 0, {}) return ws diff --git a/test/plugins/test_autopep8_format.py b/test/plugins/test_autopep8_format.py index 19a8cbb6..6fac7cf1 100644 --- a/test/plugins/test_autopep8_format.py +++ b/test/plugins/test_autopep8_format.py @@ -41,7 +41,7 @@ def func(): def test_format(config, workspace): doc = Document(DOC_URI, workspace, DOC) - res = pylsp_format_document(config, doc, options=None) + res = pylsp_format_document(config, workspace, doc, options=None) assert len(res) == 1 assert res[0]['newText'] == "a = 123\n\n\ndef func():\n pass\n" @@ -54,7 +54,7 @@ def test_range_format(config, workspace): 'start': {'line': 0, 'character': 0}, 'end': {'line': 2, 'character': 0} } - res = pylsp_format_range(config, doc, def_range, options=None) + res = pylsp_format_range(config, workspace, doc, def_range, options=None) assert len(res) == 1 @@ -64,12 +64,12 @@ def test_range_format(config, workspace): def test_no_change(config, workspace): doc = Document(DOC_URI, workspace, GOOD_DOC) - assert not pylsp_format_document(config, doc, options=None) + assert not pylsp_format_document(config, workspace, doc, options=None) def test_hanging_indentation(config, workspace): doc = Document(DOC_URI, workspace, INDENTED_DOC) - res = pylsp_format_document(config, doc, options=None) + res = pylsp_format_document(config, workspace, doc, options=None) assert len(res) == 1 assert res[0]['newText'] == CORRECT_INDENTED_DOC @@ -78,6 +78,6 @@ def test_hanging_indentation(config, workspace): @pytest.mark.parametrize('newline', ['\r\n', '\r']) def test_line_endings(config, workspace, newline): doc = Document(DOC_URI, workspace, f'import os;import sys{2 * newline}dict(a=1)') - res = pylsp_format_document(config, doc, options=None) + res = pylsp_format_document(config, workspace, doc, options=None) assert res[0]['newText'] == f'import os{newline}import sys{2 * newline}dict(a=1){newline}' diff --git a/test/plugins/test_definitions.py b/test/plugins/test_definitions.py index 488f5452..bcc76482 100644 --- a/test/plugins/test_definitions.py +++ b/test/plugins/test_definitions.py @@ -35,7 +35,7 @@ def test_definitions(config, workspace): } doc = Document(DOC_URI, workspace, DOC) - assert [{'uri': DOC_URI, 'range': def_range}] == pylsp_definitions(config, doc, cursor_pos) + assert [{'uri': DOC_URI, 'range': def_range}] == pylsp_definitions(config, workspace, doc, cursor_pos) def test_builtin_definition(config, workspace): @@ -44,7 +44,7 @@ def test_builtin_definition(config, workspace): # No go-to def for builtins doc = Document(DOC_URI, workspace, DOC) - assert not pylsp_definitions(config, doc, cursor_pos) + assert not pylsp_definitions(config, workspace, doc, cursor_pos) def test_assignment(config, workspace): @@ -58,7 +58,7 @@ def test_assignment(config, workspace): } doc = Document(DOC_URI, workspace, DOC) - assert [{'uri': DOC_URI, 'range': def_range}] == pylsp_definitions(config, doc, cursor_pos) + assert [{'uri': DOC_URI, 'range': def_range}] == pylsp_definitions(config, workspace, doc, cursor_pos) def test_document_path_definitions(config, workspace_other_root_path, tmpdir): @@ -91,4 +91,6 @@ def foo(): module_path = str(p) module_uri = uris.from_fs_path(module_path) - assert [{'uri': module_uri, 'range': def_range}] == pylsp_definitions(config, doc, cursor_pos) + assert [{"uri": module_uri, "range": def_range}] == pylsp_definitions( + config, workspace_other_root_path, doc, cursor_pos + ) diff --git a/test/plugins/test_mccabe_lint.py b/test/plugins/test_mccabe_lint.py index c85a9965..975415e1 100644 --- a/test/plugins/test_mccabe_lint.py +++ b/test/plugins/test_mccabe_lint.py @@ -19,7 +19,7 @@ def test_mccabe(config, workspace): try: config.update({'plugins': {'mccabe': {'threshold': 1}}}) doc = Document(DOC_URI, workspace, DOC) - diags = mccabe_lint.pylsp_lint(config, doc) + diags = mccabe_lint.pylsp_lint(config, workspace, doc) assert all(d['source'] == 'mccabe' for d in diags) @@ -36,4 +36,4 @@ def test_mccabe(config, workspace): def test_mccabe_syntax_error(config, workspace): doc = Document(DOC_URI, workspace, DOC_SYNTAX_ERR) - assert mccabe_lint.pylsp_lint(config, doc) is None + assert mccabe_lint.pylsp_lint(config, workspace, doc) is None diff --git a/test/plugins/test_pydocstyle_lint.py b/test/plugins/test_pydocstyle_lint.py index c6d8fa11..c3232d20 100644 --- a/test/plugins/test_pydocstyle_lint.py +++ b/test/plugins/test_pydocstyle_lint.py @@ -20,7 +20,7 @@ def hello(): def test_pydocstyle(config, workspace): doc = Document(DOC_URI, workspace, DOC) - diags = pydocstyle_lint.pylsp_lint(config, doc) + diags = pydocstyle_lint.pylsp_lint(config, workspace, doc) assert all(d['source'] == 'pydocstyle' for d in diags) @@ -40,19 +40,19 @@ def test_pydocstyle(config, workspace): def test_pydocstyle_test_document(config, workspace): # The default --match argument excludes test_* documents. doc = Document(TEST_DOC_URI, workspace, "") - diags = pydocstyle_lint.pylsp_lint(config, doc) + diags = pydocstyle_lint.pylsp_lint(config, workspace, doc) assert not diags def test_pydocstyle_empty_source(config, workspace): doc = Document(DOC_URI, workspace, "") - diags = pydocstyle_lint.pylsp_lint(config, doc) + diags = pydocstyle_lint.pylsp_lint(config, workspace, doc) assert diags[0]['message'] == 'D100: Missing docstring in public module' assert len(diags) == 1 def test_pydocstyle_invalid_source(config, workspace): doc = Document(DOC_URI, workspace, "bad syntax") - diags = pydocstyle_lint.pylsp_lint(config, doc) + diags = pydocstyle_lint.pylsp_lint(config, workspace, doc) # We're unable to parse the file, so can't get any pydocstyle diagnostics assert not diags diff --git a/test/plugins/test_pyflakes_lint.py b/test/plugins/test_pyflakes_lint.py index aa2086ce..ce8713d0 100644 --- a/test/plugins/test_pyflakes_lint.py +++ b/test/plugins/test_pyflakes_lint.py @@ -30,7 +30,7 @@ def hello(): def test_pyflakes(workspace): doc = Document(DOC_URI, workspace, DOC) - diags = pyflakes_lint.pylsp_lint(doc) + diags = pyflakes_lint.pylsp_lint(workspace, doc) # One we're expecting is: msg = '\'sys\' imported but unused' @@ -42,7 +42,7 @@ def test_pyflakes(workspace): def test_syntax_error_pyflakes(workspace): doc = Document(DOC_URI, workspace, DOC_SYNTAX_ERR) - diag = pyflakes_lint.pylsp_lint(doc)[0] + diag = pyflakes_lint.pylsp_lint(workspace, doc)[0] if sys.version_info[:2] >= (3, 10): assert diag['message'] == "expected ':'" @@ -54,7 +54,7 @@ def test_syntax_error_pyflakes(workspace): def test_undefined_name_pyflakes(workspace): doc = Document(DOC_URI, workspace, DOC_UNDEFINED_NAME_ERR) - diag = pyflakes_lint.pylsp_lint(doc)[0] + diag = pyflakes_lint.pylsp_lint(workspace, doc)[0] assert diag['message'] == 'undefined name \'b\'' assert diag['range']['start'] == {'line': 0, 'character': 4} @@ -63,7 +63,7 @@ def test_undefined_name_pyflakes(workspace): def test_unicode_encoding(workspace): doc = Document(DOC_URI, workspace, DOC_ENCODING) - diags = pyflakes_lint.pylsp_lint(doc) + diags = pyflakes_lint.pylsp_lint(workspace, doc) assert len(diags) == 1 assert diags[0]['message'] == '\'sys\' imported but unused' diff --git a/test/plugins/test_pylint_lint.py b/test/plugins/test_pylint_lint.py index b6c0329e..4e637819 100644 --- a/test/plugins/test_pylint_lint.py +++ b/test/plugins/test_pylint_lint.py @@ -42,7 +42,7 @@ def write_temp_doc(document, contents): def test_pylint(config, workspace): with temp_document(DOC, workspace) as doc: - diags = pylint_lint.pylsp_lint(config, doc, True) + diags = pylint_lint.pylsp_lint(config, workspace, doc, True) msg = '[unused-import] Unused import sys' unused_import = [d for d in diags if d['message'] == msg][0] @@ -53,7 +53,7 @@ def test_pylint(config, workspace): # test running pylint in stdin config.plugin_settings('pylint')['executable'] = 'pylint' - diags = pylint_lint.pylsp_lint(config, doc, True) + diags = pylint_lint.pylsp_lint(config, workspace, doc, True) msg = 'Unused import sys (unused-import)' unused_import = [d for d in diags if d['message'] == msg][0] @@ -67,7 +67,7 @@ def test_pylint(config, workspace): def test_syntax_error_pylint(config, workspace): with temp_document(DOC_SYNTAX_ERR, workspace) as doc: - diag = pylint_lint.pylsp_lint(config, doc, True)[0] + diag = pylint_lint.pylsp_lint(config, workspace, doc, True)[0] assert diag['message'].startswith("[syntax-error]") assert diag['message'].count("expected ':'") or diag['message'].count('invalid syntax') @@ -78,7 +78,7 @@ def test_syntax_error_pylint(config, workspace): # test running pylint in stdin config.plugin_settings('pylint')['executable'] = 'pylint' - diag = pylint_lint.pylsp_lint(config, doc, True)[0] + diag = pylint_lint.pylsp_lint(config, workspace, doc, True)[0] assert diag['message'].count("expected ':'") or diag['message'].count('invalid syntax') # Pylint doesn't give column numbers for invalid syntax. @@ -91,7 +91,7 @@ def test_lint_free_pylint(config, workspace): # match pylint's naming requirements. We should be keeping this file clean # though, so it works for a test of an empty lint. assert not pylint_lint.pylsp_lint( - config, Document(uris.from_fs_path(__file__), workspace), True) + config, workspace, Document(uris.from_fs_path(__file__), workspace), True) def test_lint_caching(workspace): @@ -125,7 +125,7 @@ def test_lint_caching(workspace): def test_per_file_caching(config, workspace): # Ensure that diagnostics are cached per-file. with temp_document(DOC, workspace) as doc: - assert pylint_lint.pylsp_lint(config, doc, True) + assert pylint_lint.pylsp_lint(config, workspace, doc, True) assert not pylint_lint.pylsp_lint( - config, Document(uris.from_fs_path(__file__), workspace), False) + config, workspace, Document(uris.from_fs_path(__file__), workspace), False) diff --git a/test/plugins/test_references.py b/test/plugins/test_references.py index c1df037b..79cd7a0e 100644 --- a/test/plugins/test_references.py +++ b/test/plugins/test_references.py @@ -40,13 +40,13 @@ def test_references(tmp_workspace): # pylint: disable=redefined-outer-name DOC1_URI = uris.from_fs_path(os.path.join(tmp_workspace.root_path, DOC1_NAME)) doc1 = Document(DOC1_URI, tmp_workspace) - refs = pylsp_references(doc1, position) + refs = pylsp_references(doc1, tmp_workspace, position) # Definition, the import and the instantiation assert len(refs) == 3 # Briefly check excluding the definitions (also excludes imports, only counts uses) - no_def_refs = pylsp_references(doc1, position, exclude_declaration=True) + no_def_refs = pylsp_references(doc1, tmp_workspace, position, exclude_declaration=True) assert len(no_def_refs) == 1 # Make sure our definition is correctly located @@ -70,7 +70,7 @@ def test_references_builtin(tmp_workspace): # pylint: disable=redefined-outer-n doc2_uri = uris.from_fs_path(os.path.join(str(tmp_workspace.root_path), DOC2_NAME)) doc2 = Document(doc2_uri, tmp_workspace) - refs = pylsp_references(doc2, position) + refs = pylsp_references(doc2, tmp_workspace, position) assert len(refs) >= 1 expected = {'start': {'line': 4, 'character': 7}, diff --git a/test/plugins/test_yapf_format.py b/test/plugins/test_yapf_format.py index 92bd8ed5..0e989c0d 100644 --- a/test/plugins/test_yapf_format.py +++ b/test/plugins/test_yapf_format.py @@ -29,7 +29,7 @@ def test_format(workspace): doc = Document(DOC_URI, workspace, DOC) - res = pylsp_format_document(doc, None) + res = pylsp_format_document(workspace, doc, None) assert apply_text_edits(doc, res) == "A = ['h', 'w', 'a']\n\nB = ['h', 'w']\n" @@ -41,7 +41,7 @@ def test_range_format(workspace): 'start': {'line': 0, 'character': 0}, 'end': {'line': 4, 'character': 10} } - res = pylsp_format_range(doc, def_range, None) + res = pylsp_format_range(workspace, doc, def_range, None) # Make sure B is still badly formatted assert apply_text_edits(doc, res) == "A = ['h', 'w', 'a']\n\nB = ['h',\n\n\n'w']\n" @@ -49,7 +49,7 @@ def test_range_format(workspace): def test_no_change(workspace): doc = Document(DOC_URI, workspace, GOOD_DOC) - assert not pylsp_format_document(doc, options=None) + assert not pylsp_format_document(workspace, doc, options=None) def test_config_file(tmpdir, workspace): @@ -59,7 +59,7 @@ def test_config_file(tmpdir, workspace): src = tmpdir.join('test.py') doc = Document(uris.from_fs_path(src.strpath), workspace, DOC) - res = pylsp_format_document(doc, options=None) + res = pylsp_format_document(workspace, doc, options=None) # A was split on multiple lines because of column_limit from config file assert apply_text_edits(doc, res) == "A = [\n 'h', 'w',\n 'a'\n]\n\nB = ['h', 'w']\n" @@ -68,28 +68,28 @@ def test_config_file(tmpdir, workspace): @pytest.mark.parametrize('newline', ['\r\n']) def test_line_endings(workspace, newline): doc = Document(DOC_URI, workspace, f'import os;import sys{2 * newline}dict(a=1)') - res = pylsp_format_document(doc, options=None) + res = pylsp_format_document(workspace, doc, options=None) assert apply_text_edits(doc, res) == f'import os{newline}import sys{2 * newline}dict(a=1){newline}' def test_format_with_tab_size_option(workspace): doc = Document(DOC_URI, workspace, FOUR_SPACE_DOC) - res = pylsp_format_document(doc, {"tabSize": "8"}) + res = pylsp_format_document(workspace, doc, {"tabSize": "8"}) assert apply_text_edits(doc, res) == FOUR_SPACE_DOC.replace(" ", " ") def test_format_with_insert_spaces_option(workspace): doc = Document(DOC_URI, workspace, FOUR_SPACE_DOC) - res = pylsp_format_document(doc, {"insertSpaces": False}) + res = pylsp_format_document(workspace, doc, {"insertSpaces": False}) assert apply_text_edits(doc, res) == FOUR_SPACE_DOC.replace(" ", "\t") def test_format_with_yapf_specific_option(workspace): doc = Document(DOC_URI, workspace, FOUR_SPACE_DOC) - res = pylsp_format_document(doc, {"USE_TABS": True}) + res = pylsp_format_document(workspace, doc, {"USE_TABS": True}) assert apply_text_edits(doc, res) == FOUR_SPACE_DOC.replace(" ", "\t") @@ -99,7 +99,7 @@ def test_format_returns_text_edit_per_line(workspace): log("x") log("hi")""" doc = Document(DOC_URI, workspace, single_space_indent) - res = pylsp_format_document(doc, options=None) + res = pylsp_format_document(workspace, doc, options=None) # two removes and two adds assert len(res) == 4 diff --git a/test/test_workspace.py b/test/test_workspace.py index 44d754b2..6699b4b8 100644 --- a/test/test_workspace.py +++ b/test/test_workspace.py @@ -293,3 +293,73 @@ def test_settings_of_added_workspace(pylsp, tmpdir): workspace1_object = pylsp.workspaces[workspace1['uri']] workspace1_jedi_settings = workspace1_object._config.plugin_settings('jedi') assert workspace1_jedi_settings == server_settings['pylsp']['plugins']['jedi'] + + +def test_progress_simple(workspace, consumer): + with workspace.report_progress("some_title"): + pass + + # same method for all calls + assert all(call[0][0]["method"] == "$/progress" for call in consumer.call_args_list) + + # same token used in all calls + assert len({call[0][0]["params"]["token"] for call in consumer.call_args_list}) == 1 + + assert [call[0][0]["params"]["value"] for call in consumer.call_args_list] == [ + {"kind": "begin", "title": "some_title"}, + {"kind": "end"}, + ] + + +def test_progress_with_percent(workspace, consumer): + with workspace.report_progress( + "some_title", "initial message", percentage=1 + ) as progress_message: + progress_message("ten", 10) + progress_message("fifty", 50) + progress_message("ninety", 90) + + # same method for all calls + assert all(call[0][0]["method"] == "$/progress" for call in consumer.call_args_list) + + # same token used in all calls + assert len({call[0][0]["params"]["token"] for call in consumer.call_args_list}) == 1 + + assert [call[0][0]["params"]["value"] for call in consumer.call_args_list] == [ + { + "kind": "begin", + "message": "initial message", + "percentage": 1, + "title": "some_title", + }, + {"kind": "report", "message": "ten", "percentage": 10}, + {"kind": "report", "message": "fifty", "percentage": 50}, + {"kind": "report", "message": "ninety", "percentage": 90}, + {"kind": "end"}, + ] + + +def test_progress_with_exception(workspace, consumer): + class DummyError(Exception): + pass + + try: + with workspace.report_progress("some_title"): + raise DummyError("something") + except DummyError: + # we're using a specific exception class here so + # any other exceptions happening in progress + # reporting would correctly be raised in the + # test. + pass + + # same method for all calls + assert all(call[0][0]["method"] == "$/progress" for call in consumer.call_args_list) + + # same token used in all calls + assert len({call[0][0]["params"]["token"] for call in consumer.call_args_list}) == 1 + + assert [call[0][0]["params"]["value"] for call in consumer.call_args_list] == [ + {"kind": "begin", "title": "some_title"}, + {"kind": "end"}, + ]