diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f737b86 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,44 @@ +name: tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + strategy: + matrix: + os: [ubuntu-latest] + python-version: ['3.10', '3.11', '3.12'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install test dependencies + run: | + python -m pip install --upgrade pip wheel + - name: Temporary installation + run: python -m pip install -e .[dev,test] + - name: Test with pytest + run: | + ipython -m pytest + - name: Check types with mypy + run: | + mypy ipython_markdown_inspector + - name: Build package + run: | + python -m build + - name: Install package + run: python -m pip install --find-links=dist --ignore-installed ipython_markdown_inspector + - name: Pip check + run: python -m pip check + - name: Publish builds + uses: actions/upload-artifact@v4 + with: + name: ipython_markdown_inspector dist Python${{ matrix.python-version }} run ${{ github.run_number }} + path: ./dist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68bc17f --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ 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/LICENSE b/LICENSE new file mode 100644 index 0000000..cc3e579 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2024, Michał Krassowski + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0bba769 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# ipython-markdown-inspector + +Provides IPython inspector outputs as Markdown, enabling better integration with Jupyter Notebook and JupyterLab. Depends on [`docstring-to-markdown`](https://github.com/python-lsp/docstring-to-markdown). + +![](https://raw.githubusercontent.com/krassowski/ipython-markdown-inspector/main/docs/demo.gif) + +## Installation + +Requires `IPython` 8.21 or newer (which requires Python 3.10 or newer). + +``` +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/docs/demo.png b/docs/demo.png new file mode 100644 index 0000000..8e68c37 Binary files /dev/null and b/docs/demo.png differ diff --git a/ipython_markdown_inspector/__init__.py b/ipython_markdown_inspector/__init__.py new file mode 100644 index 0000000..d462c7c --- /dev/null +++ b/ipython_markdown_inspector/__init__.py @@ -0,0 +1,53 @@ +from functools import partial +from typing import Any, List, 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: List[str] = [] + 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__: List[str] = [] diff --git a/ipython_markdown_inspector/_hook_data.py b/ipython_markdown_inspector/_hook_data.py new file mode 100644 index 0000000..67f75f0 --- /dev/null +++ b/ipython_markdown_inspector/_hook_data.py @@ -0,0 +1,9 @@ +try: + from IPython.core.oinspect import InspectorHookData # type: ignore +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..c4b30d6 --- /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.14.0,<1.0.0" +] +requires-python = ">=3.10" +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" + +[project.optional-dependencies] +test = [ + "pytest", + "mypy" +] +dev = [ + "build", + "pre-commit", + "ruff==0.2.0" +]