diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6f6325f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: end-of-file-fixer + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-added-large-files + - id: check-case-conflict + - id: check-toml + - id: check-yaml + - id: debug-statements + - id: check-builtin-literals + - id: trailing-whitespace + - id: check-merge-conflict + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.2.0 + hooks: + - id: ruff + args: ["--fix"] + - id: ruff-format diff --git a/README.md b/README.md index 5fc507b..1e36165 100644 --- a/README.md +++ b/README.md @@ -1 +1,24 @@ -# ipython-markdown-inspector \ No newline at end of file +# ipython-markdown-inspector + + +## Installation + +``` +pip install ipython-markdown-inspector +``` + +## Usage + +To load an extension while IPython is running, use the `%load_ext` magic: + +``` +%load_ext ipython_markdown_inspector +``` + +To load it each time IPython starts, list it in your [configuration file](https://ipython.readthedocs.io/en/stable/config/intro.html): + +```python +c.InteractiveShellApp.extensions = [ + 'ipython_markdown_inspector' +] +``` diff --git a/ipython_markdown_inspector/__init__.py b/ipython_markdown_inspector/__init__.py new file mode 100644 index 0000000..6dc747e --- /dev/null +++ b/ipython_markdown_inspector/__init__.py @@ -0,0 +1,53 @@ +from functools import partial +from typing import Any, Optional, Union + +from IPython.core.interactiveshell import InteractiveShell +from IPython.core.oinspect import OInfo + +from ._hook_data import InspectorHookData +from .formatter import as_markdown + + +def hook( + obj_or_data: Union[InspectorHookData, Any], + info: Optional[OInfo] = None, + *_, + ipython: InteractiveShell, +) -> str: + if isinstance(obj_or_data, InspectorHookData): + data = obj_or_data + else: + # fallback for IPython 8.21 + obj = obj_or_data + detail_level = 0 + omit_sections = [] + info_dict = ipython.inspector.info( + obj, "", info=info, detail_level=detail_level + ) + data = InspectorHookData( + obj=obj, + info=info, + info_dict=info_dict, + detail_level=detail_level, + omit_sections=omit_sections, + ) + return as_markdown(data) + + +ORIGINAL_HOOK = None + + +def load_ipython_extension(ipython: InteractiveShell): + global ORIGINAL_HOOK + ORIGINAL_HOOK = ipython.inspector.mime_hooks.get("text/markdown", None) + ipython.inspector.mime_hooks["text/markdown"] = partial(hook, ipython=ipython) + + +def unload_ipython_extension(ipython: InteractiveShell): + if ORIGINAL_HOOK is None: + del ipython.inspector.mime_hooks["text/markdown"] + else: + ipython.inspector.mime_hooks["text/markdown"] = ORIGINAL_HOOK + + +__all__ = [] diff --git a/ipython_markdown_inspector/_hook_data.py b/ipython_markdown_inspector/_hook_data.py new file mode 100644 index 0000000..156001a --- /dev/null +++ b/ipython_markdown_inspector/_hook_data.py @@ -0,0 +1,9 @@ +try: + from IPython.core.oinspect import InspectorHookData +except ImportError: + # TODO: remove once we require a version which includes + # https://github.com/ipython/ipython/pull/14342 + from ._ipython_patch import InspectorHookData + + +__all__ = ["InspectorHookData"] diff --git a/ipython_markdown_inspector/_ipython_patch.py b/ipython_markdown_inspector/_ipython_patch.py new file mode 100644 index 0000000..3391098 --- /dev/null +++ b/ipython_markdown_inspector/_ipython_patch.py @@ -0,0 +1,40 @@ +from typing import Any, TypedDict, Optional +from dataclasses import dataclass + +from IPython.core.oinspect import OInfo + + +class InfoDict(TypedDict): + type_name: Optional[str] + base_class: Optional[str] + string_form: Optional[str] + namespace: Optional[str] + length: Optional[str] + file: Optional[str] + definition: Optional[str] + docstring: Optional[str] + source: Optional[str] + init_definition: Optional[str] + class_docstring: Optional[str] + init_docstring: Optional[str] + call_def: Optional[str] + call_docstring: Optional[str] + subclasses: Optional[str] + # These won't be printed but will be used to determine how to + # format the object + ismagic: bool + isalias: bool + isclass: bool + found: bool + name: str + + +@dataclass +class InspectorHookData: + """Data passed to the mime hook""" + + obj: Any + info: Optional[OInfo] + info_dict: InfoDict + detail_level: int + omit_sections: list[str] diff --git a/ipython_markdown_inspector/formatter.py b/ipython_markdown_inspector/formatter.py new file mode 100644 index 0000000..f79ec08 --- /dev/null +++ b/ipython_markdown_inspector/formatter.py @@ -0,0 +1,104 @@ +from typing import Dict, List + +import docstring_to_markdown +from IPython.core.oinspect import is_simple_callable + +from ._hook_data import InspectorHookData +from .models import Field, CodeField, DocField, RowField + + +FIELDS: Dict[str, List[Field]] = { + "alias": [ + CodeField(label="Repr", key="string_form"), + ], + "magic": [ + DocField(label="Docstring", key="docstring"), + CodeField(label="Source", key="source", min_level=1), + RowField(label="File", key="file"), + ], + "class_or_callable": [ + # Functions, methods, classes + CodeField(label="Signature", key="definition"), + CodeField(label="Init signature", key="init_definition"), + DocField(label="Docstring", key="docstring"), + CodeField(label="Source", key="source", min_level=1), + DocField(label="Init docstring", key="init_docstring"), + RowField(label="File", key="file"), + RowField(label="Type", key="type_name"), + RowField(label="Subclasses", key="subclasses"), + ], + "default": [ + # General Python objects + CodeField(label="Signature", key="definition"), + CodeField(label="Call signature", key="call_def"), + RowField(label="Type", key="type_name"), + RowField(label="String form", key="string_form"), + RowField(label="Namespace", key="namespace"), + RowField(label="Length", key="length"), + RowField(label="File", key="file"), + DocField(label="Docstring", key="docstring"), + CodeField(label="Source", key="source", min_level=1), + DocField(label="Class docstring", key="class_docstring"), + DocField(label="Init docstring", key="init_docstring"), + DocField(label="Call docstring", key="call_docstring"), + ], +} + + +TABLE_STARTER = """\ +| | | +|----------|----------|\ +""" + + +def markdown_formatter(text: str): + try: + converted = docstring_to_markdown.convert(text) + return converted + except docstring_to_markdown.UnknownFormatError: + return f"
{text}
" + + +def code_formatter(code, language="python"): + return f"```{language}\n{code}\n```" + + +def as_markdown(data: InspectorHookData) -> str: + if data.info and not data.info.found: + return str(data.info) + + info_dict = data.info_dict + + if info_dict["namespace"] == "Interactive": + info_dict["namespace"] = None + + # TODO: maybe remove docstring from source? + # info_dict["source"] = remove_docstring(source) + + if info_dict["isalias"]: + fields = FIELDS["alias"] + elif info_dict["ismagic"]: + fields = FIELDS["magic"] + if info_dict["isclass"] or is_simple_callable(data.obj): + fields = FIELDS["class_or_callable"] + else: + fields = FIELDS["default"] + + chunks = [] + + in_table = False + for field in fields: + value = info_dict.get(field.key) + if value is None: + continue + if field.kind == "row": + if not in_table: + in_table = True + chunks.append(TABLE_STARTER) + chunks[-1] += f"\n| {field.label} | `{value}` |" + if field.kind == "code": + chunks.append(f"#### {field.label}\n\n" + code_formatter(value)) + if field.kind == "doc": + chunks.append(f"#### {field.label}\n\n" + markdown_formatter(value)) + + return "\n\n".join(chunks) diff --git a/ipython_markdown_inspector/models.py b/ipython_markdown_inspector/models.py new file mode 100644 index 0000000..1f50d90 --- /dev/null +++ b/ipython_markdown_inspector/models.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from typing import Literal + + +@dataclass +class Field: + key: str + label: str + kind: str + min_level: int = 0 + + +@dataclass +class RowField(Field): + kind: Literal["row"] = "row" + + +@dataclass +class CodeField(Field): + kind: Literal["code"] = "code" + + +@dataclass +class DocField(Field): + kind: Literal["doc"] = "doc" diff --git a/ipython_markdown_inspector/tests/test_as_markdown.py b/ipython_markdown_inspector/tests/test_as_markdown.py new file mode 100644 index 0000000..f5198cd --- /dev/null +++ b/ipython_markdown_inspector/tests/test_as_markdown.py @@ -0,0 +1,50 @@ +from IPython.core.interactiveshell import InteractiveShell +from IPython import get_ipython +import pytest + +from ipython_markdown_inspector.formatter import as_markdown +from ipython_markdown_inspector._hook_data import InspectorHookData + + +def simple_func(arg): + """Calls :py:func:`bool` on ``arg``""" + return bool(arg) + + +class SimpleClass: + pass + + +@pytest.mark.parametrize( + "object_name, part", + [ + ["%%python", "Run cells with python"], + ["simple_func", "Calls `bool` on ``arg``"], + ["simple_func", "| Type | `function` |"], + ["test_int", "| Type | `int` |"], + ["test_int", "| String form | `1` |"], + ["test_str", "| Type | `str` |"], + ["test_str", "| String form | `a` |"], + ["test_str", "| Length | `1` |"], + ["simple_cls", "| Type | `type` |"], + ["simple_instance", "| Type | `SimpleClass` |"], + ], +) +def test_result_contains(object_name, part): + ip: InteractiveShell = get_ipython() # type: ignore + ip.user_ns["test_str"] = "a" + ip.user_ns["test_int"] = 1 + ip.user_ns["simple_func"] = simple_func + ip.user_ns["simple_cls"] = SimpleClass + ip.user_ns["simple_instance"] = SimpleClass() + oinfo = ip._object_find(object_name) + detail_level = 0 + info_dict = ip.inspector.info(oinfo.obj, object_name) + data = InspectorHookData( + obj=oinfo.obj, + info=oinfo, + info_dict=info_dict, + detail_level=detail_level, + omit_sections=[], + ) + assert part in as_markdown(data) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7113fda --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ipython-markdown-inspector" +version = "0.0.0" +dependencies = [ + "ipython>=8.21.0", + "docstring-to-markdown>=0.13,<1.0" +] +requires-python = ">=3.8" +authors = [ + {name = "MichaƂ Krassowski"} +] +description = "" +readme = "README.md" +license = {file = "LICENSE"} +keywords = ["IPython", "markdown", "inpsector", "docstring"] +classifiers = [ + "Framework :: IPython", + "Framework :: Jupyter", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", +] + +[project.urls] +Repository = "https://github.com/krassowski/ipython-markdown-inspector.git" +"Bug Tracker" = "https://github.com/krassowski/ipython-markdown-inspector/issues" +Changelog = "https://github.com/krassowski/ipython-markdown-inspector/blob/main/CHANGELOG.md" + +[project.optional-dependencies] +test = [ + "pytest" +] +dev = [ + "build", + "pre-commit", + "ruff==0.2.0" +]