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