mirror of
https://github.com/langchain-ai/markdown-exec.git
synced 2026-07-01 18:25:52 -04:00
support execution of typescript codeblocks
This commit is contained in:
@@ -15,6 +15,7 @@ env:
|
||||
LC_ALL: en_US.utf-8
|
||||
PYTHONIOENCODING: UTF-8
|
||||
PYTHON_VERSIONS: ""
|
||||
JUPYTER_PLATFORM_DIRS: 1
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -32,6 +33,9 @@ jobs:
|
||||
- name: Set up Graphviz
|
||||
uses: ts-graphviz/setup-graphviz@v1
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v2
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -46,6 +50,59 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: make setup
|
||||
|
||||
- name: Install Deno Jupyter Kernel
|
||||
run: deno jupyter --install
|
||||
|
||||
- name: Fix deno path on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
export JUPYTER_PLATFORM_DIRS=0
|
||||
|
||||
# ensure jupyter can find the kernel config using the old path structure
|
||||
jupyter kernelspec list | grep -q deno || exit 1
|
||||
|
||||
ORIG_JUPYTER_KERNEL_PATH="$(jupyter --data-dir)/kernels"
|
||||
|
||||
export JUPYTER_PLATFORM_DIRS=1
|
||||
|
||||
# make the data directory for the new path structure
|
||||
mkdir -p "$(jupyter --data-dir)/kernels"
|
||||
|
||||
cp -r "$ORIG_JUPYTER_KERNEL_PATH/deno" "$(jupyter --data-dir)/kernels"
|
||||
|
||||
# fail if jupyter still can't find the kernel config
|
||||
jupyter kernelspec list | grep -q deno
|
||||
|
||||
deactivate
|
||||
|
||||
- name: Fix deno path on Windows
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
.\.venv\bin\activate.ps1
|
||||
$env:JUPYTER_PLATFORM_DIRS = "0"
|
||||
|
||||
# ensure jupyter can find the kernel config using the old path structure
|
||||
$kernelCheck = jupyter kernelspec list | Select-String -Pattern "deno"
|
||||
if (-not $kernelCheck) { exit 1 }
|
||||
|
||||
$origKernelPath = Join-Path (jupyter --data-dir) "kernels"
|
||||
|
||||
$env:JUPYTER_PLATFORM_DIRS = "1"
|
||||
|
||||
# make the data directory for the new path structure
|
||||
$newKernelPath = Join-Path (jupyter --data-dir) "kernels"
|
||||
New-Item -ItemType Directory -Force -Path $newKernelPath
|
||||
|
||||
Copy-Item -Path (Join-Path $origKernelPath "deno") -Destination $newKernelPath -Recurse -Force
|
||||
|
||||
# fail if jupyter still can't find the kernel config
|
||||
$kernelCheck = jupyter kernelspec list | Select-String -Pattern "deno"
|
||||
if (-not $kernelCheck) { exit 1 }
|
||||
|
||||
deactivate
|
||||
|
||||
- name: Check if the documentation builds correctly
|
||||
run: make check-docs
|
||||
|
||||
@@ -118,6 +175,9 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
allow-prereleases: true
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v2
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
@@ -130,5 +190,58 @@ jobs:
|
||||
UV_RESOLUTION: ${{ matrix.resolution }}
|
||||
run: make setup
|
||||
|
||||
- name: Install Deno Jupyter Kernel
|
||||
run: deno jupyter --install
|
||||
|
||||
- name: Fix deno path on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
export JUPYTER_PLATFORM_DIRS=0
|
||||
|
||||
# ensure jupyter can find the kernel config using the old path structure
|
||||
jupyter kernelspec list | grep -q deno || exit 1
|
||||
|
||||
ORIG_JUPYTER_KERNEL_PATH="$(jupyter --data-dir)/kernels"
|
||||
|
||||
export JUPYTER_PLATFORM_DIRS=1
|
||||
|
||||
# make the data directory for the new path structure
|
||||
mkdir -p "$(jupyter --data-dir)/kernels"
|
||||
|
||||
cp -r "$ORIG_JUPYTER_KERNEL_PATH/deno" "$(jupyter --data-dir)/kernels"
|
||||
|
||||
# fail if jupyter still can't find the kernel config
|
||||
jupyter kernelspec list | grep -q deno
|
||||
|
||||
deactivate
|
||||
|
||||
- name: Fix deno path on Windows
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
.\.venv\bin\activate.ps1
|
||||
$env:JUPYTER_PLATFORM_DIRS = "0"
|
||||
|
||||
# ensure jupyter can find the kernel config using the old path structure
|
||||
$kernelCheck = jupyter kernelspec list | Select-String -Pattern "deno"
|
||||
if (-not $kernelCheck) { exit 1 }
|
||||
|
||||
$origKernelPath = Join-Path (jupyter --data-dir) "kernels"
|
||||
|
||||
$env:JUPYTER_PLATFORM_DIRS = "1"
|
||||
|
||||
# make the data directory for the new path structure
|
||||
$newKernelPath = Join-Path (jupyter --data-dir) "kernels"
|
||||
New-Item -ItemType Directory -Force -Path $newKernelPath
|
||||
|
||||
Copy-Item -Path (Join-Path $origKernelPath "deno") -Destination $newKernelPath -Recurse -Force
|
||||
|
||||
# fail if jupyter still can't find the kernel config
|
||||
$kernelCheck = jupyter kernelspec list | Select-String -Pattern "deno"
|
||||
if (-not $kernelCheck) { exit 1 }
|
||||
|
||||
deactivate
|
||||
|
||||
- name: Run the test suite
|
||||
run: make test
|
||||
|
||||
@@ -4,6 +4,9 @@ on: push
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
JUPYTER_PLATFORM_DIRS: 1
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -20,6 +23,10 @@ jobs:
|
||||
python-version: "3.12"
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v2
|
||||
- name: Install Deno Jupyter Kernel
|
||||
run: deno jupyter --install
|
||||
- name: Build dists
|
||||
if: github.repository_owner == 'pawamoy-insiders'
|
||||
run: uv tool run --from build pyproject-build
|
||||
|
||||
@@ -46,7 +46,8 @@ Markdown(
|
||||
"format": formatter,
|
||||
}
|
||||
# ...one fence for each language we support:
|
||||
# bash, console, md, markdown, py, python, pycon, sh, tree
|
||||
# bash, console, md, markdown, py, python, pycon, sh, tree,
|
||||
# typescript, ts, tscon
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -65,7 +66,8 @@ markdown_extensions:
|
||||
validator: !!python/name:markdown_exec.validator
|
||||
format: !!python/name:markdown_exec.formatter
|
||||
# ...one fence for each language we support:
|
||||
# bash, console, md, markdown, py, python, pycon, sh, tree
|
||||
# bash, console, md, markdown, py, python, pycon, sh, tree, typescript, ts,
|
||||
# tscon
|
||||
```
|
||||
|
||||
...or in MkDocs configuration file, as a plugin:
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
> const name: string = "Baron";
|
||||
> console.log(name);
|
||||
Baron
|
||||
> const age: string | number = "???";
|
||||
> console.log(age);
|
||||
???
|
||||
@@ -0,0 +1,2 @@
|
||||
> console.log("I'm the result!");
|
||||
I'm not the result...
|
||||
@@ -0,0 +1,53 @@
|
||||
# TypeScript
|
||||
|
||||
## Regular TypeScript
|
||||
|
||||
TypeScript code is executed via a Jupyter kernel. Markdown Exec will attempt to
|
||||
use any kernel that lists TypeScript as a supported language, however
|
||||
Markdown Exec is tested using the Jupyter kernel provided by the Deno project.
|
||||
See Deno's [installation instructions](https://docs.deno.com/runtime/getting_started/installation/)
|
||||
and [Jupyter kernel docs](https://docs.deno.com/runtime/reference/cli/jupyter/#quickstart)
|
||||
for more information.
|
||||
|
||||
### Output capturing
|
||||
|
||||
Outputs are captured just as they are in jupyter notebooks.
|
||||
|
||||
````md exec="1" source="tabbed-left" tabs="Markdown|Rendered"
|
||||
```typescript exec="1"
|
||||
console.log("**Hello world!**");
|
||||
```
|
||||
````
|
||||
|
||||
### Type checking
|
||||
|
||||
TypeScript code blocks will be type checked using Deno's type checker.
|
||||
|
||||
````md exec="1" source="tabbed-left" tabs="Markdown|Rendered"
|
||||
```typescript exec="yes" returncode="1"
|
||||
const x: string = 1;
|
||||
```
|
||||
````
|
||||
|
||||
## TypeScript REPL console code
|
||||
|
||||
Code blocks syntax-highlighted with the `tscon` identifier are also supported.
|
||||
These code blocks will be pre-processed to keep only the lines
|
||||
starting with `> `, and the chevron (prompt) will be removed from these lines,
|
||||
so we can execute them. Continuation lines starting with `... ` are also supported
|
||||
(and the ellipses will be removed as well).
|
||||
|
||||
````md exec="1" source="tabbed-left" tabs="Markdown|Rendered"
|
||||
```tscon exec="1" source="console"
|
||||
--8<-- "usage/source.tscon"
|
||||
```
|
||||
````
|
||||
|
||||
It also means that multiple blocks of instructions will be concatenated,
|
||||
as well as their output:
|
||||
|
||||
````md exec="1" source="tabbed-left" tabs="Markdown|Rendered"
|
||||
```tscon exec="1" source="console"
|
||||
--8<-- "usage/multiple.tscon"
|
||||
```
|
||||
````
|
||||
@@ -23,6 +23,7 @@ nav:
|
||||
- usage/index.md
|
||||
- Python: usage/python.md
|
||||
- Pyodide: usage/pyodide.md
|
||||
- TypeScript: usage/typescript.md
|
||||
- Shell: usage/shell.md
|
||||
- Tree: usage/tree.md
|
||||
- Gallery: gallery.md
|
||||
|
||||
+2
-1
@@ -29,6 +29,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"jupyter-client>=8.6.3",
|
||||
"pymdown-extensions>=9",
|
||||
]
|
||||
|
||||
@@ -124,4 +125,4 @@ dev = [
|
||||
"plotly>=6.0; python_version == '3.12'",
|
||||
"pandas>=2.2; python_version == '3.12'",
|
||||
"chalk-diagrams>=0.2.2; python_version == '3.12'",
|
||||
]
|
||||
]
|
||||
|
||||
@@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Any
|
||||
if TYPE_CHECKING:
|
||||
from markdown import Markdown
|
||||
|
||||
from markdown_exec.formatters.base import default_tabs
|
||||
from markdown_exec.formatters.base import ExecutionError, default_tabs
|
||||
from markdown_exec.formatters.bash import _format_bash
|
||||
from markdown_exec.formatters.console import _format_console
|
||||
from markdown_exec.formatters.markdown import _format_markdown
|
||||
@@ -24,6 +24,8 @@ from markdown_exec.formatters.pyodide import _format_pyodide
|
||||
from markdown_exec.formatters.python import _format_python
|
||||
from markdown_exec.formatters.sh import _format_sh
|
||||
from markdown_exec.formatters.tree import _format_tree
|
||||
from markdown_exec.formatters.tscon import _format_tscon
|
||||
from markdown_exec.formatters.typescript import _format_typescript
|
||||
|
||||
__all__: list[str] = ["formatter", "validator"]
|
||||
|
||||
@@ -40,6 +42,9 @@ formatters = {
|
||||
"pyodide": _format_pyodide,
|
||||
"sh": _format_sh,
|
||||
"tree": _format_tree,
|
||||
"ts": _format_typescript,
|
||||
"typescript": _format_typescript,
|
||||
"tscon": _format_tscon,
|
||||
}
|
||||
|
||||
# negative look behind: matches only if | (pipe) if not preceded by \ (backslash)
|
||||
@@ -65,6 +70,7 @@ def validator(
|
||||
Returns:
|
||||
Success or not.
|
||||
"""
|
||||
# print(f"language: {language}")
|
||||
exec_value = language in MARKDOWN_EXEC_AUTO or _to_bool(inputs.pop("exec", "no"))
|
||||
if language not in {"tree", "pyodide"} and not exec_value:
|
||||
return False
|
||||
@@ -124,7 +130,10 @@ def formatter(
|
||||
HTML contents.
|
||||
"""
|
||||
fmt = formatters.get(language, lambda source, **kwargs: source)
|
||||
return fmt(code=source, md=md, **options) # type: ignore[operator]
|
||||
try:
|
||||
return fmt(code=source, md=md, **options) # type: ignore[operator]
|
||||
except Exception as e:
|
||||
raise ExecutionError(e) from e
|
||||
|
||||
|
||||
falsy_values = {"", "no", "off", "false", "0"}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Formatter for executing code using Jupyter kernels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import os
|
||||
import platform
|
||||
import queue
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from jupyter_client import KernelManager
|
||||
from jupyter_client.kernelspec import KernelSpecManager
|
||||
|
||||
from markdown_exec.formatters.base import ExecutionError, base_format
|
||||
from markdown_exec.rendering import code_block
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from jupyter_client.blocking.client import BlockingKernelClient
|
||||
|
||||
# Store kernel managers by session and kernel name
|
||||
_kernel_managers: dict[str, dict[tuple[str | None, str], KernelManager]] = {}
|
||||
_kernel_clients: dict[str, dict[tuple[str | None, str], BlockingKernelClient]] = {}
|
||||
|
||||
|
||||
def _get_available_kernels() -> dict[str, dict[str, Any]]:
|
||||
"""Get information about all available Jupyter kernels.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping kernel names to their information including:
|
||||
- display_name: Human readable name
|
||||
- language: The programming language
|
||||
- language_version: Version of the language (if available)
|
||||
- language_info: Additional language information
|
||||
"""
|
||||
ksm = KernelSpecManager()
|
||||
# Find all available kernel specs
|
||||
kernel_specs = ksm.get_all_specs()
|
||||
|
||||
# Get detailed information for each kernel
|
||||
return {
|
||||
name: {
|
||||
"display_name": spec["spec"]["display_name"],
|
||||
"languages": [
|
||||
spec["spec"]["language"],
|
||||
*(spec["spec"].get("aliases", [])),
|
||||
],
|
||||
"language_version": spec["spec"].get("language_version", None),
|
||||
"language_info": spec["spec"].get("language_info", {}),
|
||||
}
|
||||
for name, spec in kernel_specs.items()
|
||||
}
|
||||
|
||||
_language_to_kernel: dict[str, str] = {}
|
||||
|
||||
def _get_kernel_for_language(language: str) -> str:
|
||||
"""Get the best matching kernel for a given language name.
|
||||
|
||||
Args:
|
||||
language: Name of the programming language (case insensitive)
|
||||
|
||||
Returns:
|
||||
The kernel name to use, or None if no matching kernel is found
|
||||
"""
|
||||
if language in _language_to_kernel:
|
||||
return _language_to_kernel[language]
|
||||
language = language.lower()
|
||||
kernels = _get_available_kernels()
|
||||
for kernel in kernels:
|
||||
if language in [name.lower() for name in kernels[kernel]["languages"]]:
|
||||
_language_to_kernel[language] = kernel
|
||||
return kernel
|
||||
|
||||
no_kernel_msg = f"No jupyter kernel found that supports language '{language}'."
|
||||
|
||||
if language == "typescript":
|
||||
no_kernel_msg += "Please install the deno jupyter kernel by running `deno jupyter install`."
|
||||
if os.environ.get("JUPYTER_PLATFORM_DIRS", "") and (platform.system() == "Darwin" or platform.system() == "Windows"):
|
||||
no_kernel_msg += ("You may need additional install steps when running"
|
||||
"with JUPYTER_PLATFORM_DIRS=1. See "
|
||||
"https://github.com/denoland/deno/issues/27984 for more info.")
|
||||
else:
|
||||
no_kernel_msg += "Available kernels:\n" + "\n".join(
|
||||
f"- {info['language']}: {name} ({info['display_name']})"
|
||||
for name, info in kernels.items()
|
||||
)
|
||||
raise ExecutionError(no_kernel_msg)
|
||||
|
||||
|
||||
def _get_kernel_manager_and_client(
|
||||
language: str,
|
||||
session: str | None = None,
|
||||
) -> tuple[KernelManager, BlockingKernelClient]:
|
||||
"""Get or create a kernel manager and client for the given kernel name and session."""
|
||||
kernel_name = _get_kernel_for_language(language)
|
||||
key = (session, kernel_name)
|
||||
|
||||
if language not in _kernel_managers:
|
||||
_kernel_managers[language] = {}
|
||||
if language not in _kernel_clients:
|
||||
_kernel_clients[language] = {}
|
||||
if key not in _kernel_managers[language]:
|
||||
km = KernelManager(kernel_name=kernel_name)
|
||||
km.start_kernel()
|
||||
_kernel_managers[language][key] = km
|
||||
km = _kernel_managers[language][key]
|
||||
if key not in _kernel_clients[language]:
|
||||
kc = km.client()
|
||||
kc.start_channels()
|
||||
_kernel_clients[language][key] = kc
|
||||
kc = _kernel_clients[language][key]
|
||||
return km, kc
|
||||
|
||||
def _shutdown_kernels() -> None:
|
||||
for clients in _kernel_clients.values():
|
||||
for kc in clients.values():
|
||||
kc.stop_channels()
|
||||
_kernel_clients.clear()
|
||||
for managers in _kernel_managers.values():
|
||||
for km in managers.values():
|
||||
km.shutdown_kernel()
|
||||
_kernel_managers.clear()
|
||||
|
||||
# Because this is a language agnostic runner, it has an extra initial language
|
||||
# argument. This makes it easy to use functools.partial to create a language
|
||||
# specific runner when needed.
|
||||
def _run_jupyter(
|
||||
language: str,
|
||||
code: str,
|
||||
returncode: int | None = None,
|
||||
session: str | None = None,
|
||||
id: str | None = None, # noqa: A002, ARG001
|
||||
**extra: str,
|
||||
) -> str:
|
||||
"""Execute code using a Jupyter kernel."""
|
||||
is_named_session = session is not None and session != ""
|
||||
|
||||
# Get kernel manager and client
|
||||
km, kc = _get_kernel_manager_and_client(language, session)
|
||||
|
||||
kc.wait_for_ready()
|
||||
# Send code for execution
|
||||
msg_id = kc.execute(code, store_history=is_named_session)
|
||||
|
||||
# Collect output
|
||||
outputs = []
|
||||
error_outputs = []
|
||||
execution_completed = False
|
||||
|
||||
while not execution_completed:
|
||||
try:
|
||||
# Check for shell messages (execution replies)
|
||||
shell_msg = kc.get_shell_msg(timeout=0.1)
|
||||
if shell_msg["parent_header"].get("msg_id") == msg_id:
|
||||
execution_completed = True
|
||||
|
||||
if shell_msg["content"]["status"] == "error":
|
||||
if (
|
||||
shell_msg["content"]["traceback"] is not None
|
||||
and len(shell_msg["content"]["traceback"]) > 0
|
||||
):
|
||||
if shell_msg["content"]["traceback"][0] == "Stack trace:":
|
||||
error_outputs.extend(
|
||||
shell_msg["content"]["traceback"][1:],
|
||||
)
|
||||
else:
|
||||
error_outputs.extend(shell_msg["content"]["traceback"])
|
||||
break
|
||||
# note: abort is deprecated, but some older kernels may still send it
|
||||
# it doesn't send error info, however - so we'll need to populate a fake abort error instead
|
||||
if shell_msg["content"]["status"] == "abort":
|
||||
error_outputs.append("AbortError: Execution aborted")
|
||||
break
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
try:
|
||||
# nested while here so we consume all iopub messages for this execution before moving on.
|
||||
# this lets us consume all pending messages after execution has completed before exiting the loop
|
||||
while True:
|
||||
# Check for iopub messages (output)
|
||||
iopub_msg = kc.get_iopub_msg(timeout=0.1)
|
||||
if iopub_msg["parent_header"].get("msg_id") != msg_id:
|
||||
continue
|
||||
|
||||
msg_type = iopub_msg["header"]["msg_type"]
|
||||
content = iopub_msg["content"]
|
||||
|
||||
if msg_type == "stream":
|
||||
outputs.append(content["text"])
|
||||
elif msg_type in ("execute_result", "display_data"):
|
||||
# TODO: these can contain other MIME types (including HTML) - do we want to support them?
|
||||
outputs.append(str(content["data"].get("text/plain", "")))
|
||||
elif msg_type == "error":
|
||||
# ignore these because we already got it from the shell message
|
||||
pass
|
||||
|
||||
except queue.Empty:
|
||||
# No messages received in timeout period, check if kernel is still alive
|
||||
if not km.is_alive():
|
||||
raise ExecutionError(
|
||||
"Error: Kernel died during execution",
|
||||
) from None
|
||||
continue
|
||||
|
||||
# Check for errors
|
||||
if error_outputs:
|
||||
if returncode:
|
||||
return "\n".join(error_outputs)
|
||||
raise ExecutionError(
|
||||
code_block(language, "\n".join(error_outputs), **extra),
|
||||
)
|
||||
|
||||
return "\n".join(outputs)
|
||||
|
||||
|
||||
|
||||
def _jupyter_formatter(language: str, runner: Callable[[str], str] | None = None, **kwargs: Any) -> str:
|
||||
"""Format and execute code using Jupyter kernels."""
|
||||
return base_format(
|
||||
language=language,
|
||||
run=runner or functools.partial(_run_jupyter, language),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Formatter for executing `tscon` code."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from markdown_exec.formatters.base import base_format
|
||||
from markdown_exec.formatters.typescript import _run_typescript
|
||||
from markdown_exec.logger import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from markupsafe import Markup
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _transform_source(code: str) -> tuple[str, str]:
|
||||
typescript_lines = []
|
||||
tscon_lines = []
|
||||
for line in code.split("\n"):
|
||||
if line.startswith("> "):
|
||||
tscon_lines.append(line)
|
||||
typescript_lines.append(line[2:])
|
||||
elif line.startswith("... "):
|
||||
tscon_lines.append(line)
|
||||
typescript_lines.append(line[4:])
|
||||
typescript_code = "\n".join(typescript_lines)
|
||||
return typescript_code, "\n".join(tscon_lines)
|
||||
|
||||
|
||||
def _format_tscon(**kwargs: Any) -> Markup:
|
||||
return base_format(
|
||||
language="tscon",
|
||||
run=_run_typescript,
|
||||
transform_source=_transform_source,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Formatter for executing TypeScript code."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from markdown_exec.formatters.base import ExecutionError
|
||||
from markdown_exec.formatters.jupyter import _jupyter_formatter, _run_jupyter
|
||||
from markdown_exec.rendering import code_block
|
||||
|
||||
_sessions: dict[str, list[str]] = {}
|
||||
|
||||
|
||||
def _clean_typecheck_error(filename: str, stderr: str, session: str | None) -> str:
|
||||
cleaned_filename = (
|
||||
f"<session {session}>" if session else "<anonymous session>"
|
||||
) + ".ts"
|
||||
stderr = stderr.replace(f"at file://{filename}", f"at {cleaned_filename}")
|
||||
|
||||
# given an error line like: "at <anonymous session>.ts:1:5", subtract the number of lines of old code from the line number
|
||||
# so the line number aligns with the one in the code block
|
||||
old_lines = 0 if not session else len(_sessions[session])
|
||||
|
||||
cleaned_lines = []
|
||||
for line in stderr.split("\n"):
|
||||
if match := re.match(
|
||||
r"^(\s*at .*:)(?P<line_number>\d+):(?P<column_number>\d+)$",
|
||||
line,
|
||||
):
|
||||
line_number = int(match.group("line_number")) - old_lines
|
||||
column_number = int(match.group("column_number"))
|
||||
cleaned_lines.append(f"{match.group(1)}{line_number}:{column_number}")
|
||||
else:
|
||||
cleaned_lines.append(line)
|
||||
return "\n".join(cleaned_lines)
|
||||
|
||||
|
||||
def _check_types(
|
||||
code_history: list[str],
|
||||
session: str | None,
|
||||
returncode: int | None = None,
|
||||
**extra: str,
|
||||
) -> str | None:
|
||||
code = "\n".join(code_history)
|
||||
env = {**os.environ, "NO_COLOR": "1"}
|
||||
deno_path = shutil.which("deno")
|
||||
|
||||
if not deno_path:
|
||||
raise ExecutionError(
|
||||
"Deno not found. Please install it from "
|
||||
"https://deno.land/manual/getting_started/installation. Be sure to "
|
||||
"also install the deno jupyter kernel by running `deno jupyter "
|
||||
"install`.",
|
||||
)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".ts",
|
||||
delete=True,
|
||||
encoding="utf-8",
|
||||
mode="w",
|
||||
) as file:
|
||||
file.write(code)
|
||||
file.flush()
|
||||
with subprocess.Popen( # noqa: S603
|
||||
[deno_path, "check", "--quiet", file.name],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
encoding="utf-8",
|
||||
text=True,
|
||||
cwd=os.getcwd(),
|
||||
) as check_proc:
|
||||
check_proc.wait()
|
||||
stderr = check_proc.stderr.read() if check_proc.stderr else ""
|
||||
if check_proc.returncode != 0:
|
||||
if returncode:
|
||||
return code_block(
|
||||
"typescript",
|
||||
_clean_typecheck_error(file.name, stderr, session),
|
||||
**extra,
|
||||
)
|
||||
raise ExecutionError(
|
||||
code_block(
|
||||
"typescript",
|
||||
_clean_typecheck_error(file.name, stderr, session),
|
||||
**extra,
|
||||
),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _get_code_history(session: str | None, code: str) -> list[str]:
|
||||
if session is None or session == "":
|
||||
return code.split("\n")
|
||||
|
||||
_sessions[session] = _sessions.get(session, []) + code.split("\n")
|
||||
|
||||
return _sessions[session]
|
||||
|
||||
|
||||
def _run_typescript(
|
||||
code: str,
|
||||
returncode: int | None = None,
|
||||
session: str | None = None,
|
||||
id: str | None = None, # noqa: A002
|
||||
**extra: str,
|
||||
) -> str:
|
||||
code_history = _get_code_history(session, code)
|
||||
type_check_errors = _check_types(code_history, session, returncode, **extra)
|
||||
if type_check_errors:
|
||||
return type_check_errors
|
||||
return _run_jupyter("typescript", code, returncode, session, id, **extra)
|
||||
|
||||
|
||||
_format_typescript = functools.partial(_jupyter_formatter, "typescript", _run_typescript)
|
||||
@@ -14,6 +14,7 @@ from mkdocs.plugins import BasePlugin
|
||||
from mkdocs.utils import write_file
|
||||
|
||||
from markdown_exec import formatter, formatters, validator
|
||||
from markdown_exec.formatters.jupyter import _shutdown_kernels
|
||||
from markdown_exec.logger import patch_loggers
|
||||
from markdown_exec.rendering import MarkdownConverter, markdown_config
|
||||
|
||||
@@ -128,6 +129,8 @@ class MarkdownExecPlugin(BasePlugin[MarkdownExecPluginConfig]):
|
||||
else:
|
||||
os.environ["MKDOCS_CONFIG_DIR"] = self.mkdocs_config_dir
|
||||
|
||||
_shutdown_kernels()
|
||||
|
||||
def _add_asset(self, config: MkDocsConfig, asset_file: str, asset_type: str) -> None:
|
||||
asset_filename = f"assets/_markdown_exec_{asset_file}"
|
||||
asset_content = Path(__file__).parent.joinpath(asset_file).read_text()
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Tests for the TypeScript formatter."""
|
||||
|
||||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
from markdown import Markdown
|
||||
|
||||
from markdown_exec.formatters.base import ExecutionError
|
||||
from markdown_exec.rendering import code_block
|
||||
|
||||
|
||||
def _expect_error(md: Markdown, error_text: str, actual_html: str) -> None:
|
||||
"""Utility function to check that the error text is formatted as expected."""
|
||||
expected_exception = ExecutionError(code_block("typescript", error_text))
|
||||
expected_html = md.convert(str(expected_exception))
|
||||
assert expected_html == actual_html
|
||||
|
||||
|
||||
def test_output_markdown(md: Markdown) -> None:
|
||||
"""Test that output from code is rendered as Markdown."""
|
||||
html = md.convert(
|
||||
dedent(
|
||||
"""
|
||||
```typescript exec="yes"
|
||||
console.log("**Bold!**")
|
||||
```
|
||||
""",
|
||||
),
|
||||
)
|
||||
assert html == "<p><strong>Bold!</strong></p>"
|
||||
|
||||
|
||||
def test_session_persistence(md: Markdown) -> None:
|
||||
"""Test that session data is persisted across code blocks."""
|
||||
html = md.convert(
|
||||
dedent(
|
||||
"""
|
||||
```typescript exec="1" session="a"
|
||||
let count = 0;
|
||||
```
|
||||
|
||||
```typescript exec="1" session="a"
|
||||
count++;
|
||||
console.log(`Count: ${count}`);
|
||||
```
|
||||
""",
|
||||
),
|
||||
)
|
||||
assert "Count: 1" in html
|
||||
|
||||
|
||||
def test_runtime_error(md: Markdown, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Test that runtime errors are formatted correctly."""
|
||||
html = md.convert(
|
||||
dedent(
|
||||
"""
|
||||
```typescript exec="1" session="error-test"
|
||||
throw new Error("Intentional runtime error");
|
||||
```
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
_expect_error(
|
||||
md,
|
||||
dedent(
|
||||
"""
|
||||
Error: Intentional runtime error
|
||||
at <anonymous>:1:28
|
||||
""",
|
||||
),
|
||||
html,
|
||||
)
|
||||
|
||||
assert "Execution of typescript code block exited with errors" in caplog.text
|
||||
|
||||
|
||||
def test_type_error(md: Markdown, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Test that type errors are formatted correctly."""
|
||||
html = md.convert(
|
||||
dedent(
|
||||
"""
|
||||
```typescript exec="1"
|
||||
let count: number = "string";
|
||||
```
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
_expect_error(
|
||||
md,
|
||||
dedent(
|
||||
"""
|
||||
TS2322 [ERROR]: Type 'string' is not assignable to type 'number'.
|
||||
let count: number = "string";
|
||||
~~~~~
|
||||
at <anonymous session>.ts:1:5
|
||||
|
||||
error: Type checking failed.
|
||||
""",
|
||||
),
|
||||
html,
|
||||
)
|
||||
|
||||
assert "Execution of typescript code block exited with errors" in caplog.text
|
||||
|
||||
|
||||
def test_console_output(md: Markdown) -> None:
|
||||
"""Test that console output is rendered correctly."""
|
||||
html = md.convert(
|
||||
dedent(
|
||||
"""
|
||||
```typescript exec="1"
|
||||
console.log("Hello");
|
||||
console.error("World");
|
||||
```
|
||||
""",
|
||||
),
|
||||
)
|
||||
assert "Hello" in html
|
||||
assert "World" in html
|
||||
|
||||
|
||||
def test_session_isolation(md: Markdown) -> None:
|
||||
"""Test that sessions are isolated from each other."""
|
||||
html = md.convert(
|
||||
dedent(
|
||||
"""
|
||||
```typescript exec="1" session="session-a"
|
||||
let x = 1;
|
||||
console.log(`A: ${x}`);
|
||||
```
|
||||
|
||||
```typescript exec="1" session="session-b"
|
||||
let x = 2;
|
||||
console.log(`B: ${x}`);
|
||||
```
|
||||
|
||||
```typescript exec="1" session="session-a"
|
||||
console.log(`A2: ${x}`);
|
||||
```
|
||||
""",
|
||||
),
|
||||
)
|
||||
assert "A: 1" in html
|
||||
assert "B: 2" in html
|
||||
assert "A2: 1" in html
|
||||
|
||||
|
||||
def test_partial_execution(md: Markdown) -> None:
|
||||
"""Test that partial execution works."""
|
||||
html = md.convert(
|
||||
dedent(
|
||||
"""
|
||||
```typescript exec="1" session="partial"
|
||||
let x = 1;
|
||||
console.log(`THE NUMBER IS ${x}`);
|
||||
throw new Error("Fail");
|
||||
x = 2;
|
||||
```
|
||||
|
||||
```typescript exec="1" session="partial"
|
||||
console.log(`THE NUMBER IS ${x}`);
|
||||
```
|
||||
""",
|
||||
),
|
||||
)
|
||||
assert "THE NUMBER IS 1" in html
|
||||
assert "THE NUMBER IS 2" not in html
|
||||
|
||||
|
||||
def test_tscon_multiple_blocks(md: Markdown) -> None:
|
||||
"""Test that multiple blocks of instructions are concatenated, as well as their output."""
|
||||
html = md.convert(
|
||||
dedent(
|
||||
"""
|
||||
```tscon exec="1"
|
||||
> const name: string = "Baron";
|
||||
> console.log(name);
|
||||
Baron
|
||||
> const age: string | number = "???";
|
||||
> console.log(age);
|
||||
???
|
||||
```
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
assert "Baron" in html
|
||||
assert "???" in html
|
||||
Reference in New Issue
Block a user