Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

### ✨ New features and improvements

- Implement initial PythonTA LSP server
- Add initial PythonTA implementation that simply calls the CLI

### 🐛 Bug fixes
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@ This project is an extension for running [PythonTA](https://www.cs.toronto.edu/~

1. Install Python dependencies: `uv sync`.
2. Install Javascript dependencies: `pnpm install`.
3. Clone the [`pyta`](https://github.com/pyta-uoft/pyta) repository locally. The most recent release does not include the `pyta-lsp` output format needed for the VSCode extension.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't necessary. Modify the pyproject.toml to specify the GitHub URL for installing PythonTA.

For the bundled/libs issue, I think you can modify step 1 above to replace the uv sync with a uv pip install --target bundled/libs -r pyproject.toml

4. Install the following modules into `bundles/libs`
```bash
uv pip install path/to/local/pyta/packages/python-ta pygls --target bundled/libs
```

To start the extension, use the `Debug Extension and Python` configuration in VS Code.
To start the extension, use the `Debug Extension and Python` (shortcut `F5`) configuration in VS Code.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So overall I'm able to start the extension now and I don't think I see any errors, but I don't see anything happening, either. Please provide instructions in the README on what to do after the extension window opens. Add a video to the PR description as well.


### Running tests

Expand Down
2 changes: 1 addition & 1 deletion bundled/tool/_debug_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
167 changes: 25 additions & 142 deletions bundled/tool/lsp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -239,140 +238,31 @@ 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 []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revert this change (keep the blank line)


# 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<line>\d+),(?P<column>-?\d+),(?P<type>\w+),(?P<code>\w+\d+):(?P<message>[^\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.
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:]

document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
edits = _formatting_helper(document)
if edits:
return edits
raw_results = json.loads(content)
diagnostics_data = []
for file_result in raw_results:
if file_result.get("uri") == doc_uri:
diagnostics_data = file_result.get("diagnostics", [])
break

# NOTE: If you provide [] array, VS Code will clear the file of all contents.
# To indicate no changes to file return None.
return None
for diag in diagnostics_data:
if "severity" in diag and isinstance(diag["severity"], int):
diag["severity"] = lsp.DiagnosticSeverity(diag["severity"])


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


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
# **********************************************************
converter = converters.get_converter()
return converter.structure(diagnostics_data, list[lsp.Diagnostic])


# **********************************************************
Expand Down Expand Up @@ -579,17 +469,10 @@ def _run_tool_on_document(
# 'path' setting takes priority over everything.
use_path = True
argv = settings["path"]
elif settings["interpreter"] and not utils.is_current_interpreter(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see you deleted some of the code you added, but it also seems like we should then restore this block here.

settings["interpreter"][0]
):
# If there is a different interpreter set use JSON-RPC to the subprocess
# running under that interpreter.
argv = [TOOL_MODULE]
use_rpc = True
else:
# if the interpreter is same as the interpreter running this
# process then run as module.
# Run under subprocess since python_ta calls sys.exit
argv = [TOOL_MODULE]
use_rpc = True

argv += TOOL_ARGS + settings["args"] + extra_args

Expand Down
12 changes: 0 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
}
]
},
Expand Down
Loading