diff --git a/.vscodeignore b/.vscodeignore index b202465..f16ff34 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -22,4 +22,5 @@ noxfile.py .pylintrc **/requirements.txt **/requirements.in -**/tool/_debug_server.py \ No newline at end of file +**/tool/_debug_server.py +media/** \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 58846c6..9fe2333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### ✨ New features and improvements +- Implement initial PythonTA LSP server - Add initial PythonTA implementation that simply calls the CLI ### 🐛 Bug fixes diff --git a/README.md b/README.md index 4a6656c..b487132 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,36 @@ This project is an extension for running [PythonTA](https://www.cs.toronto.edu/~ ### Setup -1. Install Python dependencies: `uv sync`. -2. Install Javascript dependencies: `pnpm install`. +1. Install the extension's dependencies into the bundled libraries directory: +```bash +uv pip install --target bundled/libs -r pyproject.toml +``` +2. Install Python dependencies: `uv sync`. +3. Install Javascript dependencies: `pnpm install`. -To start the extension, use the `Debug Extension and Python` configuration in VS Code. +### Testing the Extension Locally + +1. To start the extension, use the `Debug Extension and Python` (shortcut `F5`) configuration in VS Code. +2. In the new VS Code window that appears, open a folder containing Python files, or create a new file. +3. Open a file with a snippet or write a snippet of Python code that violates standard PythonTA rule. For example: + ```python + def add_numbers(a, b): + return a + c + ``` +4. Save the file, and look for the diagnostic messages and rule codes generated by PythonTA. +5. You can view the extension output under the Output tab, and select "PythonTA VS Code Extension" + +### Troubleshooting: Server Crashes on File Reload + +If you are actively testing the extension using the debugger (`F5`), you may notice that closing and reopening a Python file causes the server to crash repeatedly with a `ConnectionRefusedError`. + +**This is expected behavior in the development environment and will not happen in the published extension.** It occurs because the VS Code Extension Development Host wraps the language server in a `debugpy` instance that fails to cleanly release its port when the file reloads. + +If you want to avoid this during testing, and you do not need breakpoints, launch the Extension Development Host using **Debug Extension Only** (via the dropdown in the Run and Debug menu) instead of `F5`. + +### Demo Video + + ### Running tests diff --git a/bundled/tool/_debug_server.py b/bundled/tool/_debug_server.py index 3474195..06d8893 100644 --- a/bundled/tool/_debug_server.py +++ b/bundled/tool/_debug_server.py @@ -30,7 +30,7 @@ def update_sys_path(path_to_add: str) -> None: # This will ensure that execution is paused as soon as the debugger # connects to VS Code. If you don't want to pause here comment this # line and set breakpoints as appropriate. - debugpy.breakpoint() + # debugpy.breakpoint() SERVER_PATH = os.fspath(pathlib.Path(__file__).parent / "lsp_server.py") # NOTE: Set breakpoint in `lsp_server.py` before continuing. diff --git a/bundled/tool/lsp_server.py b/bundled/tool/lsp_server.py index c9ac9e1..a8841d6 100644 --- a/bundled/tool/lsp_server.py +++ b/bundled/tool/lsp_server.py @@ -37,6 +37,7 @@ def update_sys_path(path_to_add: str, strategy: str) -> None: # pylint: disable=wrong-import-position,import-error import lsp_jsonrpc as jsonrpc import lsp_utils as utils +from lsprotocol import converters from lsprotocol import types as lsp from pygls import uris, workspace from pygls.lsp.server import LanguageServer @@ -80,12 +81,10 @@ def update_sys_path(path_to_add: str, strategy: str) -> None: # Black: https://github.com/microsoft/vscode-black-formatter/blob/main/bundled/tool # isort: https://github.com/microsoft/vscode-isort/blob/main/bundled/tool -TOOL_MODULE = "python-ta" +TOOL_MODULE = "python_ta" TOOL_DISPLAY = "PythonTA" - -TOOL_ARGS = [] # default arguments always passed to your tool. - +TOOL_ARGS = ["--output-format", "pyta-lsp", "--exit-zero"] # default arguments always passed to your tool # TODO: If your tool is a linter then update this section. # Delete "Linting features" section if your tool is NOT a linter. @@ -239,140 +238,47 @@ def _linting_helper(document: workspace.Document) -> list[lsp.Diagnostic]: # support linting over stdin to be effective. Read, and update # _run_tool_on_document and _run_tool functions as needed for your project. result = _run_tool_on_document(document) - return _parse_output_using_regex(result.stdout) if result.stdout else [] - - -# TODO: If your linter outputs in a known format like JSON, then parse -# accordingly. But incase you need to parse the output using RegEx here -# is a helper you can work with. -# flake8 example: -# If you use following format argument with flake8 you can use the regex below to parse it. -# TOOL_ARGS += ["--format='%(row)d,%(col)d,%(code).1s,%(code)s:%(text)s'"] -# DIAGNOSTIC_RE = -# r"(?P\d+),(?P-?\d+),(?P\w+),(?P\w+\d+):(?P[^\r\n]*)" -DIAGNOSTIC_RE = re.compile(r"") - - -def _parse_output_using_regex(content: str) -> list[lsp.Diagnostic]: - lines: list[str] = content.splitlines() - diagnostics: list[lsp.Diagnostic] = [] - - # TODO: Determine if your linter reports line numbers starting at 1 (True) or 0 (False). - line_at_1 = True - # TODO: Determine if your linter reports column numbers starting at 1 (True) or 0 (False). - column_at_1 = True - - line_offset = 1 if line_at_1 else 0 - col_offset = 1 if column_at_1 else 0 - for line in lines: - if line.startswith("'") and line.endswith("'"): - line = line[1:-1] - match = DIAGNOSTIC_RE.match(line) - if match: - data = match.groupdict() - position = lsp.Position( - line=max([int(data["line"]) - line_offset, 0]), - character=int(data["column"]) - col_offset, - ) - diagnostic = lsp.Diagnostic( - range=lsp.Range( - start=position, - end=position, - ), - message=data.get("message"), - severity=_get_severity(data["code"], data["type"]), - code=data["code"], - source=TOOL_MODULE, - ) - diagnostics.append(diagnostic) - - return diagnostics - - -# TODO: if you want to handle setting specific severity for your linter -# in a user configurable way, then look at look at how it is implemented -# for `pylint` extension from our team. -# Pylint: https://github.com/microsoft/vscode-pylint -# Follow the flow of severity from the settings in package.json to the server. -def _get_severity(*_codes: list[str]) -> lsp.DiagnosticSeverity: - # TODO: All reported issues from linter are treated as warning. - # change it as appropriate for your linter. - return lsp.DiagnosticSeverity.Warning - - -# ********************************************************** -# Linting features end here -# ********************************************************** - -# TODO: If your tool is a formatter then update this section. -# Delete "Formatting features" section if your tool is NOT a -# formatter. -# ********************************************************** -# Formatting features start here -# ********************************************************** -# Sample implementations: -# Black: https://github.com/microsoft/vscode-black-formatter/blob/main/bundled/tool - + if result and result.stdout: + return _parse_json_output(result.stdout, document.uri) + return [] -@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_FORMATTING) -def formatting(params: lsp.DocumentFormattingParams) -> list[lsp.TextEdit] | None: - """LSP handler for textDocument/formatting request.""" - # If your tool is a formatter you can use this handler to provide - # formatting support on save. You have to return an array of lsp.TextEdit - # objects, to provide your formatted results. - - document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) - edits = _formatting_helper(document) - if edits: - return edits - # NOTE: If you provide [] array, VS Code will clear the file of all contents. - # To indicate no changes to file return None. - return None +def _parse_json_output(content: str, doc_uri: str) -> list[lsp.Diagnostic]: + """Parses PythonTA's JSON output and maps it to LSP Diagnostics.""" + json_start = content.find("[") + if json_start == -1: + return [] + content = content[json_start:] + raw_results = json.loads(content) + diagnostics_data = [] + for file_result in raw_results: + file_uri = file_result.get("uri") + if file_uri and _normalize_uri_path(file_uri) == _normalize_uri_path(doc_uri): + diagnostics_data = file_result.get("diagnostics", []) + break -def _formatting_helper(document: workspace.TextDocument) -> list[lsp.TextEdit] | None: - # TODO: For formatting on save support the formatter you use must support - # formatting via stdin. - # Read, and update_run_tool_on_document and _run_tool functions as needed - # for your formatter. - result = _run_tool_on_document(document, use_stdin=True) - if result.stdout: - new_source = _match_line_endings(document, result.stdout) - return [ - lsp.TextEdit( - range=lsp.Range( - start=lsp.Position(line=0, character=0), - end=lsp.Position(line=len(document.lines), character=0), - ), - new_text=new_source, - ) - ] - return None + for diag in diagnostics_data: + if "severity" in diag and isinstance(diag["severity"], int): + diag["severity"] = lsp.DiagnosticSeverity(diag["severity"]) + converter = converters.get_converter() + return converter.structure(diagnostics_data, list[lsp.Diagnostic]) -def _get_line_endings(lines: list[str]) -> str: - """Returns line endings used in the text.""" - try: - if lines[0][-2:] == "\r\n": - return "\r\n" - return "\n" - except Exception: # pylint: disable=broad-except - return None - -def _match_line_endings(document: workspace.TextDocument, text: str) -> str: - """Ensures that the edited text line endings matches the document line endings.""" - expected = _get_line_endings(document.source.splitlines(keepends=True)) - actual = _get_line_endings(text.splitlines(keepends=True)) - if actual == expected or actual is None or expected is None: - return text - return text.replace(actual, expected) - - -# ********************************************************** -# Formatting features ends here -# ********************************************************** +def _normalize_uri_path(uri: str) -> str: + """ + Normalizes a file URI to a comparable filesystem path. + On Windows, `pathlib.Path.resolve()` uppercases drive letters, while + VS Code always sends `document.uri` with a lowercase drive letter. + Comparing raw URI strings therefore never matches on Windows. + """ + fs_path = uris.to_fs_path(uri) + + if fs_path is None: + return "" + + return os.path.normcase(os.path.normpath(fs_path)) # ********************************************************** diff --git a/media/demo.mp4 b/media/demo.mp4 new file mode 100644 index 0000000..4b08f2e Binary files /dev/null and b/media/demo.mp4 differ diff --git a/package.json b/package.json index 578b199..38293a8 100644 --- a/package.json +++ b/package.json @@ -64,13 +64,6 @@ "vsce-package": "vsce package -o python-ta.vsix" }, "contributes": { - "keybindings": [ - { - "command": "python-ta.check", - "key": "ctrl+shift+t", - "when": "editorTextFocus && editorLangId == 'python'" - } - ], "configuration": { "properties": { "python-ta.configPath": { @@ -151,11 +144,6 @@ "title": "Restart Server", "category": "PythonTA VS Code Extension", "command": "python-ta.restart" - }, - { - "title": "Run PythonTA", - "category": "PythonTA VS Code Extension", - "command": "python-ta.check" } ] }, diff --git a/pyproject.toml b/pyproject.toml index fb4d550..fdb9677 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "pygls>=2.1.1", - "python-ta", + "python-ta @ git+https://github.com/pyta-uoft/pyta.git#subdirectory=packages/python-ta", ] [dependency-groups] diff --git a/src/extension.ts b/src/extension.ts index 1cb0798..3048325 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -15,44 +15,9 @@ import { LS_SERVER_RESTART_DELAY } from './common/constants'; import { getLSClientTraceLevel } from './common/utilities'; import { createOutputChannel, onDidChangeConfiguration, registerCommand } from './common/vscodeapi'; -import { spawn } from 'child_process'; -import * as path from 'path'; - let lsClient: LanguageClient | undefined; let isRestarting = false; let restartTimer: NodeJS.Timeout | undefined; - -let diagnosticCollection: vscode.DiagnosticCollection; -let statusBarItem: vscode.StatusBarItem; -const serverId = 'python-ta'; - -interface LspRange { - start: { line: number; character: number }; - end: { line: number; character: number }; -} - -interface LspDiagnostic { - range: LspRange; - message: string; - severity: number; - code?: string; - source?: string; -} - -interface PublishDiagnosticsParams { - uri: string; - diagnostics: LspDiagnostic[]; -} - -function lspSeverityToVscode(severity: number): vscode.DiagnosticSeverity { - switch (severity) { - case 1: return vscode.DiagnosticSeverity.Error; - case 3: return vscode.DiagnosticSeverity.Information; - case 4: return vscode.DiagnosticSeverity.Hint; - default: return vscode.DiagnosticSeverity.Warning; - } -} - export async function activate(context: vscode.ExtensionContext): Promise { // This is required to get server name and module. This should be // the first thing that we do in this extension. @@ -78,57 +43,46 @@ export async function activate(context: vscode.ExtensionContext): Promise }), ); - diagnosticCollection = vscode.languages.createDiagnosticCollection('python-ta'); - statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); - - context.subscriptions.push( - diagnosticCollection, - statusBarItem, - vscode.commands.registerCommand(`${serverId}.check`, runPythonTA) - ); - // Log Server information traceLog(`Name: ${serverInfo.name}`); traceLog(`Module: ${serverInfo.module}`); traceVerbose(`Full Server Info: ${JSON.stringify(serverInfo)}`); const runServer = async () => { - // TODO: Comment back this code when LSP is ready to be implemented - - // if (isRestarting) { - // if (restartTimer) { - // clearTimeout(restartTimer); - // } - // restartTimer = setTimeout(runServer, LS_SERVER_RESTART_DELAY); - // return; - // } - // isRestarting = true; - // try { - // const interpreter = getInterpreterFromSetting(serverId); - // if (interpreter && interpreter.length > 0) { - // if (checkVersion(await resolveInterpreter(interpreter))) { - // traceVerbose(`Using interpreter from ${serverInfo.module}.interpreter: ${interpreter.join(' ')}`); - // lsClient = await restartServer(serverId, serverName, outputChannel, lsClient); - // } - // return; - // } + if (isRestarting) { + if (restartTimer) { + clearTimeout(restartTimer); + } + restartTimer = setTimeout(runServer, LS_SERVER_RESTART_DELAY); + return; + } + isRestarting = true; + try { + const interpreter = getInterpreterFromSetting(serverId); + if (interpreter && interpreter.length > 0) { + if (checkVersion(await resolveInterpreter(interpreter))) { + traceVerbose(`Using interpreter from ${serverInfo.module}.interpreter: ${interpreter.join(' ')}`); + lsClient = await restartServer(serverId, serverName, outputChannel, lsClient); + } + return; + } - // const interpreterDetails = await getInterpreterDetails(); - // if (interpreterDetails.path) { - // traceVerbose(`Using interpreter from Python extension: ${interpreterDetails.path.join(' ')}`); - // lsClient = await restartServer(serverId, serverName, outputChannel, lsClient); - // return; - // } + const interpreterDetails = await getInterpreterDetails(); + if (interpreterDetails.path) { + traceVerbose(`Using interpreter from Python extension: ${interpreterDetails.path.join(' ')}`); + lsClient = await restartServer(serverId, serverName, outputChannel, lsClient); + return; + } - // traceError( - // 'Python interpreter missing:\r\n' + - // '[Option 1] Select python interpreter using the ms-python.python.\r\n' + - // `[Option 2] Set an interpreter using "${serverId}.interpreter" setting.\r\n` + - // 'Please use Python 3.10 or greater.', - // ); - // } finally { - // isRestarting = false; - // } + traceError( + 'Python interpreter missing:\r\n' + + '[Option 1] Select python interpreter using the ms-python.python.\r\n' + + `[Option 2] Set an interpreter using "${serverId}.interpreter" setting.\r\n` + + 'Please use Python 3.10 or greater.', + ); + } finally { + isRestarting = false; + } }; context.subscriptions.push( @@ -166,116 +120,3 @@ export async function deactivate(): Promise { } } } - -async function runPythonTA(): Promise { - const editor = vscode.window.activeTextEditor; - if (!editor || editor.document.languageId !== 'python') { - vscode.window.showWarningMessage('PythonTA: Open a Python file first.'); - return; - } - - if (editor.document.isUntitled) { - vscode.window.showWarningMessage('PythonTA: Please save the file before running the linter.'); - return; - } - - const filePath = editor.document.uri.fsPath; - - statusBarItem.text = '$(loading~spin) Running PythonTA...'; - statusBarItem.show(); - - const serverId = 'python-ta'; - let pythonPath: string | undefined; - - const settingsInterpreter = getInterpreterFromSetting(serverId); - if (settingsInterpreter && settingsInterpreter.length > 0) { - pythonPath = settingsInterpreter[0]; - } else { - const interpreterDetails = await getInterpreterDetails(editor.document.uri); - if (interpreterDetails.path && interpreterDetails.path.length > 0) { - pythonPath = interpreterDetails.path[0]; - } - } - - const python = pythonPath || 'python'; - - const env = Object.assign({}, process.env); - if (python !== 'python') { - const pythonDir = path.dirname(python); - const venvDir = path.dirname(pythonDir); - env.PATH = `${pythonDir}${path.delimiter}${env.PATH || ''}`; - env.VIRTUAL_ENV = venvDir; - delete env.PYTHONHOME; - } - - const configPath = vscode.workspace.getConfiguration('python-ta').get('configPath'); - const args = ['-m', 'python_ta', '--output-format', 'pyta-lsp', filePath]; - - if (configPath) { - args.push('--config', configPath); - } - - const workspaceFolder = vscode.workspace.getWorkspaceFolder(editor.document.uri); - const cwd = workspaceFolder ? workspaceFolder.uri.fsPath : undefined; - - const proc = spawn(python, args, { cwd: cwd, env: env }); - - let stdout = ''; - let stderr = ''; - let spawnFailed = false; - - proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); - proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); - - proc.on('error', () => { - spawnFailed = true; - statusBarItem.hide(); - vscode.window.showErrorMessage(`PythonTA: Failed to start process. Could not find executable '${python}'.`); - }); - - proc.on('close', (code: number | null) => { - statusBarItem.hide(); - - if (spawnFailed) { - return; - } - - if (code !== 0 && stdout.trim() === '') { - const detail = stderr.trim() ? `\nDetails: ${stderr.trim()}` : ''; - vscode.window.showErrorMessage(`PythonTA: Process failed (exit ${code}).${detail}`); - return; - } - - let results: PublishDiagnosticsParams[]; - try { - results = JSON.parse(stdout) as PublishDiagnosticsParams[]; - } catch { - if (stdout.includes('[INFO] Your PythonTA report is being opened')) { - vscode.window.showInformationMessage('PythonTA generated a web report instead of LSP data.'); - diagnosticCollection.set(editor.document.uri, []); - } else { - vscode.window.showErrorMessage('PythonTA: Received non-JSON output.'); - console.error("Raw Output:", stdout, stderr); - } - return; - } - - diagnosticCollection.set(editor.document.uri, []); - for (const { uri, diagnostics } of results) { - const vscodeDiags = diagnostics.map((d) => { - const diag = new vscode.Diagnostic( - new vscode.Range( - d.range.start.line, d.range.start.character, - d.range.end.line, d.range.end.character - ), - d.message, - lspSeverityToVscode(d.severity) - ); - diag.code = d.code; - diag.source = d.source ?? 'python-ta'; - return diag; - }); - diagnosticCollection.set(vscode.Uri.parse(uri), vscodeDiags); - } - }); -} \ No newline at end of file