From d246171bafd8ea14834bfbb0db1635755db45365 Mon Sep 17 00:00:00 2001 From: Mateusz Kasprowicz Date: Wed, 1 Jul 2026 18:49:37 +0200 Subject: [PATCH 1/4] [ENH] Enhance HTML representation and documentation links for BaseObject This commit introduces several improvements to the HTML representation of BaseObject instances, including: - Added support for documentation links via a new mixin, `_HTMLDocumentationLinkMixin`, allowing for dynamic generation of API documentation links. - Enhanced the `_object_html_repr` function to include parameter documentation links and improved styling for parameter tables. - Introduced new CSS and JavaScript files to support the updated HTML representation features. - Added a new notebook for comparing HTML representations between skbase and scikit-learn. - Updated tests to cover the new functionality and ensure proper rendering of HTML representations. These changes aim to improve usability and accessibility of object documentation in Jupyter notebooks. --- .pre-commit-config.yaml | 2 +- pyproject.toml | 4 + skbase/base/_meta.py | 4 +- .../_pretty_printing/_object_html_repr.css | 428 +++++++++++ .../_pretty_printing/_object_html_repr.js | 105 +++ .../_pretty_printing/_object_html_repr.py | 682 ++++++++++++------ .../tests/test_object_html_repr.py | 193 ++++- skbase/tests/conftest.py | 16 +- 8 files changed, 1212 insertions(+), 222 deletions(-) create mode 100644 skbase/base/_pretty_printing/_object_html_repr.css create mode 100644 skbase/base/_pretty_printing/_object_html_repr.js diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 003aefcc..c34bc94e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -70,7 +70,7 @@ repos: additional_dependencies: [black>=22.3.0] - id: nbqa-flake8 args: [--nbqa-dont-skip-bad-cells, "--extend-ignore=E402,E203"] - additional_dependencies: [flake8==3.8.3] + additional_dependencies: [flake8==7.3.0] - repo: https://github.com/PyCQA/bandit rev: 1.9.4 diff --git a/pyproject.toml b/pyproject.toml index d18d3a08..ef29e9d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,6 +144,10 @@ exclude_dirs = ["*/tests/*", "*/testing/*"] zip-safe = true [tool.setuptools.package-data] +skbase = [ + "base/_pretty_printing/*.css", + "base/_pretty_printing/*.js", +] sktime = [ "*.csv", "*.csv.gz", diff --git a/skbase/base/_meta.py b/skbase/base/_meta.py index cb3021c5..9209d751 100644 --- a/skbase/base/_meta.py +++ b/skbase/base/_meta.py @@ -39,7 +39,7 @@ class has values that follow the named object specification. For example, # _steps_attr points to the attribute of self # which contains the heterogeneous set of estimators # this must be an iterable of (name: str, estimator) pairs for the default - _tags = {"named_object_parameters": "steps"} + _tags = {"named_object_parameters": "steps", "visual_block_kind": "serial"} def is_composite(self): """Check if the object is composite. @@ -776,7 +776,7 @@ def _get_name(name, obj): names = [_get_name(name, est) for name, est in named_objects] name_details = [str(obj) for obj in objs] return _VisualBlock( - "serial", + self.get_tag("visual_block_kind", tag_value_default="serial"), objs, names=names, name_details=name_details, diff --git a/skbase/base/_pretty_printing/_object_html_repr.css b/skbase/base/_pretty_printing/_object_html_repr.css new file mode 100644 index 00000000..2a387a69 --- /dev/null +++ b/skbase/base/_pretty_printing/_object_html_repr.css @@ -0,0 +1,428 @@ +#$id { + --skbase-color-text: #111; + --skbase-color-text-muted: #666; + --skbase-color-line: #777; + --skbase-color-background: #fff; + --skbase-color-border-box: #111; + --skbase-color-icon: #696969; + --skbase-color-table-row-even: rgba(15, 23, 42, 0.04); + --skbase-color-table-row-hover: rgba(37, 99, 235, 0.14); + --skbase-color-param-user-set: #0f5ed7; + --skbase-color-param-icon: #334155; + --skbase-color-level-0: #f0f8ff; + --skbase-color-level-1: #d4ebff; + --skbase-color-level-2: #b3dbfd; + --skbase-color-level-3: #2563eb; + --skbase-color-fitted-level-0: #eff6ff; + --skbase-color-fitted-level-1: #dbeafe; + --skbase-color-fitted-level-2: #bfdbfe; + --skbase-color-fitted-level-3: #2563eb; +} + +#$id.dark { + --skbase-color-text: #f5f5f5; + --skbase-color-text-muted: #bbb; + --skbase-color-background: #111; + --skbase-color-border-box: #f5f5f5; + --skbase-color-icon: #aaa; + --skbase-color-table-row-even: rgba(255, 255, 255, 0.08); + --skbase-color-table-row-hover: rgba(147, 197, 253, 0.20); + --skbase-color-param-user-set: #93c5fd; + --skbase-color-param-icon: #dbeafe; + --skbase-color-level-0: #0f172a; + --skbase-color-level-1: #172554; + --skbase-color-level-2: #1e3a8a; + --skbase-color-level-3: #93c5fd; + --skbase-color-fitted-level-0: #0f172a; + --skbase-color-fitted-level-1: #172554; + --skbase-color-fitted-level-2: #1e3a8a; + --skbase-color-fitted-level-3: #93c5fd; +} + +#$id { + color: var(--skbase-color-text); +} + +#$id pre { + padding: 0; +} + +#$id input.sk-hidden--visually { + border: 0; + clip: rect(1px 1px 1px 1px); + clip: rect(1px, 1px, 1px, 1px); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +#$id div.sk-container { + display: inline-block !important; + position: relative; +} + +#$id div.sk-text-repr-fallback { + display: none; +} + +#$id div.sk-item, +#$id div.sk-serial, +#$id div.sk-parallel-item { + background-image: linear-gradient(var(--skbase-color-line), var(--skbase-color-line)); + background-position: center center; + background-repeat: no-repeat; + background-size: 2px 100%; +} + +#$id div.sk-item { + z-index: 1; +} + +#$id div.sk-dashed-wrapped { + background-color: var(--skbase-color-background); + border: 1px dashed var(--skbase-color-line); + box-sizing: border-box; + margin: 0 0.4em 0.5em 0.4em; + padding-bottom: 0.4em; +} + +#$id div.sk-serial { + align-items: center; + background-color: var(--skbase-color-background); + display: flex; + flex-direction: column; + padding-left: 1em; + padding-right: 1em; +} + +#$id div.sk-parallel { + align-items: stretch; + background-color: var(--skbase-color-background); + display: flex; + justify-content: center; + position: relative; +} + +#$id div.sk-parallel-item { + display: flex; + flex-direction: column; +} + +#$id div.sk-parallel-item::after { + border-bottom: 2px solid var(--skbase-color-line); + content: ""; + flex-grow: 1; + width: 100%; +} + +#$id div.sk-parallel-item:first-child::after { + align-self: flex-end; + width: 50%; +} + +#$id div.sk-parallel-item:last-child::after { + align-self: flex-start; + width: 50%; +} + +#$id div.sk-parallel-item:only-child::after { + width: 0; +} + +#$id div.sk-toggleable { + background-color: var(--skbase-color-background); +} + +#$id label.sk-toggleable__label { + align-items: center; + box-sizing: border-box; + cursor: pointer; + display: flex; + gap: 0.5em; + justify-content: center; + margin-bottom: 0; + padding: 0.5em; + text-align: center; + width: 100%; +} + +#$id label.sk-toggleable__label .caption { + color: var(--skbase-color-text-muted); + font-size: 0.6rem; + font-weight: lighter; +} + +#$id label.sk-toggleable__label-arrow::before { + color: var(--skbase-color-icon); + content: "▸"; + float: left; + margin-right: 0.25em; +} + +#$id label.sk-toggleable__label-arrow:hover::before { + color: var(--skbase-color-text); +} + +#$id div.sk-toggleable__content { + background-color: var(--skbase-color-level-0); + display: none; + text-align: left; +} + +#$id div.sk-toggleable__content.fitted { + background-color: var(--skbase-color-fitted-level-0); +} + +#$id div.sk-toggleable__content pre { + background-color: var(--skbase-color-level-0); + border-radius: 0.25em; + color: var(--skbase-color-text); + margin: 0.2em; +} + +#$id div.sk-toggleable__content.fitted pre { + background-color: var(--skbase-color-fitted-level-0); +} + +#$id input.sk-toggleable__control:checked ~ div.sk-toggleable__content { + display: block; + overflow: visible; + width: 100%; +} + +#$id input.sk-toggleable__control:checked ~ label.sk-toggleable__label-arrow::before { + content: "▾"; +} + +#$id div.sk-label input.sk-toggleable__control:checked ~ label.sk-toggleable__label, +#$id div.sk-estimator input.sk-toggleable__control:checked ~ label.sk-toggleable__label, +#$id div.sk-label:hover label.sk-toggleable__label, +#$id div.sk-estimator:hover { + background-color: var(--skbase-color-level-2); + color: var(--skbase-color-text); +} + +#$id div.sk-label.fitted input.sk-toggleable__control:checked ~ label.sk-toggleable__label, +#$id div.sk-estimator.fitted input.sk-toggleable__control:checked ~ label.sk-toggleable__label, +#$id div.sk-label.fitted:hover label.sk-toggleable__label.fitted, +#$id div.sk-estimator.fitted:hover { + background-color: var(--skbase-color-fitted-level-2); +} + +#$id div.sk-label label { + color: var(--skbase-color-text); + display: inline-block; + font-family: monospace; + font-weight: bold; + line-height: 1.2em; +} + +#$id div.sk-label-container { + text-align: center; +} + +#$id div.sk-estimator { + background-color: var(--skbase-color-level-0); + border: 1px dotted var(--skbase-color-border-box); + border-radius: 0.25em; + box-sizing: border-box; + font-family: monospace; + margin-bottom: 0.5em; +} + +#$id div.sk-estimator.fitted { + background-color: var(--skbase-color-fitted-level-0); +} + +#$id .sk-estimator-doc-link, +#$id a:link.sk-estimator-doc-link, +#$id a:visited.sk-estimator-doc-link { + background-color: var(--skbase-color-level-0); + border: var(--skbase-color-level-3) 1pt solid; + border-radius: 1em; + color: var(--skbase-color-level-3); + float: right; + font-family: monospace; + font-size: smaller; + height: 1em; + line-height: 1em; + margin-left: 0.5em; + text-align: center; + text-decoration: none !important; + width: 1em; +} + +#$id .sk-estimator-doc-link.fitted, +#$id a:link.sk-estimator-doc-link.fitted, +#$id a:visited.sk-estimator-doc-link.fitted { + background-color: var(--skbase-color-fitted-level-0); + border: var(--skbase-color-fitted-level-3) 1pt solid; + color: var(--skbase-color-fitted-level-3); +} + +#$id .sk-estimator-doc-link:hover { + background-color: var(--skbase-color-level-3); + border-color: var(--skbase-color-level-0); + color: var(--skbase-color-level-0); + text-decoration: none; +} + +#$id .sk-estimator-doc-link.fitted:hover { + background-color: var(--skbase-color-fitted-level-3); + border-color: var(--skbase-color-fitted-level-0); + color: var(--skbase-color-fitted-level-0); +} + +#$id .sk-estimator-doc-link span { + background: var(--skbase-color-level-0); + border: 0.5pt solid var(--skbase-color-level-3); + box-shadow: 2pt 2pt 4pt #999; + color: var(--skbase-color-text); + display: none; + font-weight: normal; + margin: 0.5ex; + max-width: 50ex; + min-width: 20ex; + padding: 0.5ex; + position: relative; + right: 0.2ex; + width: min-content; + z-index: 9999; +} + +#$id .sk-estimator-doc-link.fitted span { + background: var(--skbase-color-fitted-level-0); + border-color: var(--skbase-color-fitted-level-3); +} + +#$id .sk-estimator-doc-link:hover span { + display: block; +} + +#$id .estimator-table { + font-family: monospace; +} + +#$id .estimator-table summary { + cursor: pointer; + padding: 0.5rem; +} + +#$id .estimator-table summary::marker { + font-size: 0.7rem; +} + +#$id .estimator-table details[open] { + padding: 0 0.1rem 0.3rem 0.1rem; +} + +#$id .estimator-table .parameters-table { + margin-left: auto !important; + margin-right: auto !important; + margin-top: 0; +} + +#$id .estimator-table .parameters-table tr:nth-child(odd) { + background-color: var(--skbase-color-background); +} + +#$id .estimator-table .parameters-table tr:nth-child(even) { + background-color: var(--skbase-color-table-row-even); +} + +#$id .estimator-table .parameters-table tr:hover { + background-color: var(--skbase-color-table-row-hover); +} + +#$id .estimator-table table td { + border: 1px solid rgba(106, 105, 104, 0.23); +} + +#$id .estimator-table table td.param { + padding: 0; + position: relative; + text-align: left; +} + +#$id .estimator-table table td.value { + max-width: 32em; + overflow-wrap: anywhere; +} + +#$id .user-set td, +#$id .user-set td.value { + color: var(--skbase-color-param-user-set); + text-align: left !important; +} + +#$id .default td { + color: var(--skbase-color-text); + text-align: left !important; +} + +#$id .user-set td i, +#$id .default td i { + color: var(--skbase-color-param-icon); +} + +#$id a.param-doc-link, +#$id a.param-doc-link:link, +#$id a.param-doc-link:visited { + color: inherit; + display: block; + padding: 0.5em; + text-decoration: underline dashed; + text-underline-offset: 0.3em; +} + +#$id a.param-doc-link::before { + content: ""; + inset: 0; + position: absolute; +} + +#$id .param-doc-description { + background: var(--skbase-color-level-0); + border: thin solid var(--skbase-color-level-3); + box-shadow: 0.3em 0.3em 0.4em #999; + color: var(--skbase-color-text); + display: none; + left: 0; + margin-left: 1.5em; + max-height: 10em; + max-width: 36em; + overflow-y: auto; + padding: 0.5ex; + position: absolute; + text-align: left; + width: max-content; + z-index: 9999; +} + +#$id .fitted .param-doc-description { + background: var(--skbase-color-fitted-level-0); + border-color: var(--skbase-color-fitted-level-3); +} + +#$id .param-doc-link:hover .param-doc-description { + display: block; +} + +#$id .copy-paste-icon { + background-color: currentColor; + cursor: pointer; + display: inline-block; + height: 14px; + mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NDggNTEyIj48cGF0aCBkPSJNMjA4IDBMMzMyLjEgMGMxMi43IDAgMjQuOSA1LjEgMzMuOSAxNC4xbDY3LjkgNjcuOWM5IDkgMTQuMSAyMS4yIDE0LjEgMzMuOUw0NDggMzM2YzAgMjYuNS0yMS41IDQ4LTQ4IDQ4bC0xOTIgMGMtMjYuNSAwLTQ4LTIxLjUtNDgtNDhsMC0yODhjMC0yNi41IDIxLjUtNDggNDgtNDh6TTQ4IDEyOGw4MCAwIDAgNjQtNjQgMCAwIDI1NiAxOTIgMCAwLTMyIDY0IDAgMCA0OGMwIDI2LjUtMjEuNSA0OC00OCA0OEw0OCA1MTJjLTI2LjUgMC00OC0yMS41LTQ4LTQ4TDAgMTc2YzAtMjYuNSAyMS41LTQ4IDQ4LTQ4eiIvPjwvc3ZnPg==); + mask-position: center; + mask-repeat: no-repeat; + mask-size: 14px 14px; + -webkit-mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NDggNTEyIj48cGF0aCBkPSJNMjA4IDBMMzMyLjEgMGMxMi43IDAgMjQuOSA1LjEgMzMuOSAxNC4xbDY3LjkgNjcuOWM5IDkgMTQuMSAyMS4yIDE0LjEgMzMuOUw0NDggMzM2YzAgMjYuNS0yMS41IDQ4LTQ4IDQ4bC0xOTIgMGMtMjYuNSAwLTQ4LTIxLjUtNDgtNDhsMC0yODhjMC0yNi41IDIxLjUtNDggNDgtNDh6TTQ4IDEyOGw4MCAwIDAgNjQtNjQgMCAwIDI1NiAxOTIgMCAwLTMyIDY0IDAgMCA0OGMwIDI2LjUtMjEuNSA0OC00OCA0OEw0OCA1MTJjLTI2LjUgMC00OC0yMS41LTQ4LTQ4TDAgMTc2YzAtMjYuNSAyMS41LTQ4IDQ4LTQ4eiIvPjwvc3ZnPg==); + -webkit-mask-position: center; + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: 14px 14px; + width: 14px; +} diff --git a/skbase/base/_pretty_printing/_object_html_repr.js b/skbase/base/_pretty_printing/_object_html_repr.js new file mode 100644 index 00000000..66d5f0b6 --- /dev/null +++ b/skbase/base/_pretty_printing/_object_html_repr.js @@ -0,0 +1,105 @@ +function skbaseCopyToClipboard(text, element) { + const toggleableContent = element.closest('.sk-toggleable__content'); + const paramPrefix = toggleableContent ? toggleableContent.dataset.paramPrefix : ''; + const fullParamName = paramPrefix ? `${paramPrefix}${text}` : text; + const originalHTML = element.innerHTML.replace('Copied!', '').replace('Failed!', ''); + const originalWidth = window.getComputedStyle(element).width; + const restore = () => { + element.innerHTML = originalHTML; + element.style.width = ''; + element.style.color = ''; + }; + const markCopied = () => { + element.style.width = originalWidth; + element.style.color = 'green'; + element.innerHTML = 'Copied!'; + setTimeout(restore, 2000); + }; + const markFailed = () => { + element.style.color = 'red'; + element.innerHTML = 'Failed!'; + setTimeout(restore, 2000); + }; + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(fullParamName).then(markCopied).catch(markFailed); + } else { + const textArea = document.createElement('textarea'); + textArea.value = fullParamName; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand('copy') ? markCopied() : markFailed(); + } catch (error) { + markFailed(); + } finally { + document.body.removeChild(textArea); + } + } + return false; +} + +function skbaseDetectTheme(element) { + const body = document.querySelector('body'); + + if (body !== null) { + const themeKindAttr = body.getAttribute('data-vscode-theme-kind'); + const themeNameAttr = body.getAttribute('data-vscode-theme-name'); + + if (themeKindAttr && themeNameAttr) { + const themeKind = themeKindAttr.toLowerCase(); + const themeName = themeNameAttr.toLowerCase(); + + if (themeKind.includes('dark') || themeName.includes('dark')) { + return 'dark'; + } + if (themeKind.includes('light') || themeName.includes('light')) { + return 'light'; + } + } + + if (body.getAttribute('data-jp-theme-light') === 'false') { + return 'dark'; + } + if (body.getAttribute('data-jp-theme-light') === 'true') { + return 'light'; + } + } + + const color = window.getComputedStyle(element.parentNode, null).getPropertyValue('color'); + const match = color.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)\s*$/i); + if (match) { + const red = parseFloat(match[1]); + const green = parseFloat(match[2]); + const blue = parseFloat(match[3]); + const luma = 0.299 * red + 0.587 * green + 0.114 * blue; + + if (luma > 180) { + return 'dark'; + } + if (luma < 75) { + return 'light'; + } + } + + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function skbaseForceTheme(elementId) { + const estimatorElement = document.getElementById(elementId); + if (estimatorElement !== null) { + estimatorElement.classList.add(skbaseDetectTheme(estimatorElement)); + } + + document.querySelectorAll(`#${elementId} .copy-paste-icon`).forEach(function (element) { + const toggleableContent = element.closest('.sk-toggleable__content'); + const paramPrefix = toggleableContent ? toggleableContent.dataset.paramPrefix : ''; + const paramCell = element.parentElement.nextElementSibling; + const paramName = paramCell ? paramCell.textContent.trim().split(' ')[0] : ''; + const fullParamName = paramPrefix ? `${paramPrefix}${paramName}` : paramName; + element.setAttribute('title', fullParamName); + }); +} diff --git a/skbase/base/_pretty_printing/_object_html_repr.py b/skbase/base/_pretty_printing/_object_html_repr.py index bb5448fe..6806eb18 100644 --- a/skbase/base/_pretty_printing/_object_html_repr.py +++ b/skbase/base/_pretty_printing/_object_html_repr.py @@ -3,18 +3,50 @@ # Many elements of this code were developed in scikit-learn. These elements # are copyrighted by the scikit-learn developers, BSD-3-Clause License. For # conditions see https://github.com/scikit-learn/scikit-learn/blob/main/COPYING -"""Functionality to represent instance of BaseObject as html.""" +"""Functionality to represent instances of BaseObject as HTML.""" import html +import inspect +import re +import reprlib import uuid from contextlib import closing +from functools import lru_cache +from importlib import resources from inspect import isclass from io import StringIO from string import Template +from urllib.parse import quote __author__ = ["RNKuhns"] +class _HTMLDocumentationLinkMixin: + """Mixin for generating API documentation links in object HTML diagrams. + + Classes can opt in by inheriting from this mixin or by defining compatible + class/instance attributes: + + - ``_doc_link_module``: root module that is allowed to receive doc links. + Defaults to ``"skbase"``. Set to ``None`` to allow any root module. + - ``_doc_link_template``: format string used to build the link. The default + is a generic skbase ReadTheDocs API reference URL. + - ``_doc_link_url_param_generator``: optional callable returning a dict of + additional format parameters for custom templates. + """ + + _doc_link_module = "skbase" + _doc_link_template = ( + "https://skbase.readthedocs.io/en/latest/api_reference/auto_generated/" + "{object_module}.{object_name}.html" + ) + _doc_link_url_param_generator = None + + def _get_doc_link(self): + """Generate a documentation URL for this object, or ``""`` if disabled.""" + return _get_doc_link(self) + + class _VisualBlock: """HTML Representation of BaseObject. @@ -23,7 +55,7 @@ class _VisualBlock: kind : {'serial', 'parallel', 'single'} kind of HTML block - estimators : list of ``BaseObject``s or ``_VisualBlock`s or a single ``BaseObject`` + estimators : list of ``BaseObject``s or ``_VisualBlock``s or a single ``BaseObject`` If ``kind != 'single'``, then ``estimators`` is a list of ``BaseObjects``. If ``kind == 'single'``, then ``estimators`` is a single ``BaseObject``. @@ -37,17 +69,36 @@ class _VisualBlock: If ``kind == 'single'``, then ``name_details`` is a single string corresponding to the single ``BaseObject``. + name_caption : str, default=None + The caption below the name. ``None`` stands for no caption. + Only active when ``kind == 'single'``. + + doc_link_label : str, default=None + The label for the documentation link. If provided, the label is + "Documentation for {doc_link_label}". Otherwise it uses ``names``. + Only active when ``kind == 'single'``. + dash_wrapped : bool, default=True If true, wrapped HTML element will be wrapped with a dashed border. Only active when ``kind != 'single'``. """ def __init__( - self, kind, estimators, *, names=None, name_details=None, dash_wrapped=True + self, + kind, + estimators, + *, + names=None, + name_details=None, + name_caption=None, + doc_link_label=None, + dash_wrapped=True, ): self.kind = kind self.estimators = estimators self.dash_wrapped = dash_wrapped + self.name_caption = name_caption + self.doc_link_label = doc_link_label if self.kind in ("parallel", "serial"): if names is None: @@ -62,31 +113,324 @@ def _sk_visual_block_(self): return self +@lru_cache +def _get_resource(name): + """Read a packaged pretty-printing resource.""" + return ( + resources.files("skbase.base._pretty_printing") + .joinpath(name) + .read_text(encoding="utf-8") + ) + + +def _get_css_style(): + """Return CSS used by the HTML representation.""" + return _get_resource("_object_html_repr.css") + + +def _get_js(): + """Return JavaScript used by the HTML representation.""" + return _get_resource("_object_html_repr.js") + + +@lru_cache +def _get_param_doc_descriptions(docstring): + """Parse a small subset of NumPy-style parameter docs. + + The parser intentionally stays lightweight to avoid a hard numpydoc + dependency in skbase. It extracts parameter type lines and indented + description text from a ``Parameters`` section. + """ + if not docstring: + return {} + + lines = inspect.cleandoc(docstring).splitlines() + in_parameters = False + params = {} + current = None + + for idx, line in enumerate(lines): + stripped = line.strip() + next_line = lines[idx + 1].strip() if idx + 1 < len(lines) else "" + + if not in_parameters: + if stripped == "Parameters" and set(next_line) <= {"-"} and next_line: + in_parameters = True + continue + + if stripped and set(stripped) <= {"-"}: + continue + + # A non-indented heading followed by an underline starts the next section. + if line == stripped and stripped and set(next_line) <= {"-"} and next_line: + break + + match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$", stripped) + if match: + current = match.group(1) + params[current] = {"type": match.group(2), "desc": []} + continue + + if current is not None: + if not stripped: + continue + params[current]["desc"].append(stripped) + + return params + + +def _generate_link_to_param_doc(object_class, param_name, doc_link): + """Generate a text-fragment URL to a parameter docstring entry.""" + docstring = inspect.getdoc(object_class) + param_map = _get_param_doc_descriptions(docstring) + param_doc = param_map.get(param_name) + + if param_doc is None: + return None + + text_fragment = f"{quote(param_name)},-{quote(param_doc['type'])}" + return f"{doc_link}#:~:text={text_fragment}" + + +def _get_doc_link(base_object): + """Return the configured documentation link for ``base_object``.""" + if base_object is None or isinstance(base_object, str) or isclass(base_object): + return "" + + direct_link = getattr(base_object, "_html_repr_doc_link", None) + if direct_link: + return direct_link + + get_doc_link = getattr(base_object, "_get_doc_link", None) + mixin_get_doc_link = _HTMLDocumentationLinkMixin._get_doc_link + is_mixin_doc_link = getattr(get_doc_link, "__func__", None) is mixin_get_doc_link + if get_doc_link is not None and not is_mixin_doc_link: + try: + return get_doc_link() + except Exception: + return "" + + template = getattr(base_object, "_doc_link_template", None) + if not template: + return "" + + module_name = base_object.__class__.__module__ + root_module = module_name.split(".")[0] + doc_link_module = getattr(base_object, "_doc_link_module", None) + if doc_link_module is not None and root_module != doc_link_module: + return "" + + generator = getattr(base_object, "_doc_link_url_param_generator", None) + if generator is None: + object_name = base_object.__class__.__name__ + params = { + "object_module": module_name, + "object_name": object_name, + "estimator_module": module_name, + "estimator_name": object_name, + } + else: + try: + params = generator() + except Exception: + return "" + + try: + return template.format(**params) + except Exception: + return "" + + +def _get_fitted_status(base_object): + """Return CSS/status icon for BaseEstimator-like fitted state.""" + if not hasattr(base_object, "is_fitted"): + return "", "" + + try: + is_fitted = bool(base_object.is_fitted) + except Exception: + is_fitted = False + + status_label = "Fitted" if is_fitted else "Not fitted" + css_class = "fitted" if is_fitted else "" + status_icon = ( + f'' + f"i{status_label}" + ) + + return css_class, status_icon + + +def _changed_param_names(base_object): + """Return shallow parameter names with non-default values.""" + try: + from skbase.base._pretty_printing._pprint import _changed_params + + return tuple(_changed_params(base_object)) + except Exception: + return () + + +def _read_param(name, value, non_default_params): + """Categorize and format a parameter value for HTML display.""" + repr_instance = reprlib.Repr() + repr_instance.maxdict = 2 + repr_instance.maxlist = 2 + repr_instance.maxset = 2 + repr_instance.maxstring = 50 + repr_instance.maxtuple = 1 + repr_instance.maxother = 80 + + return { + "param_type": "user-set" if name in non_default_params else "default", + "param_type_extra": ( + "sk-param-row--changed" + if name in non_default_params + else "sk-param-row--default" + ), + "param_name": html.escape(name), + "param_value": html.escape(repr_instance.repr(value)), + } + + +def _params_html_repr(base_object, doc_link=""): + """Generate HTML table with shallow parameters for ``base_object``.""" + if not hasattr(base_object, "get_params") or isclass(base_object): + return "" + + try: + params = base_object.get_params(deep=False) + except Exception: + return "" + + if not isinstance(params, dict) or not params: + return "" + + non_default_params = _changed_param_names(base_object) + object_class = base_object.__class__ + param_doc_map = _get_param_doc_descriptions(inspect.getdoc(object_class)) + + rows = [] + for param_name, value in params.items(): + param = _read_param(param_name, value, non_default_params) + param_display = param["param_name"] + + param_doc = param_doc_map.get(param_name) + param_link = ( + _generate_link_to_param_doc(object_class, param_name, doc_link) + if doc_link + else None + ) + if param_link and param_doc: + param_link = html.escape(param_link, quote=True) + description = ( + f"{html.escape(param_name)}: {html.escape(param_doc['type'])}" + "

" + f"{'
'.join(html.escape(x) for x in param_doc['desc'])}" + ) + param_display = ( + '{}{}' + ).format(param_link, param["param_name"], description) + + rows.append( + '' + '' + '{param_display}' + '{param_value}' + "".format( + raw_name=html.escape(param_name, quote=True), + param_display=param_display, + **param, + ) + ) + + return ( + '
' + "
" + "Parameters" + '' + f"{''.join(rows)}" + "
" + "
" + "
" + ) + + def _write_label_html( out, name, name_details, + params="", + name_caption=None, + doc_link_label=None, outer_class="sk-label-container", inner_class="sk-label", checked=False, + doc_link="", + is_fitted_css_class="", + is_fitted_icon="", + param_prefix="", ): - """Write labeled html with or without a dropdown with named details.""" - out.write(f'
') - name = html.escape(name) - - if name_details is not None: - name_details = html.escape(str(name_details)) - label_class = "sk-toggleable__label sk-toggleable__label-arrow" + """Write labeled HTML with or without a dropdown with named details.""" + out.write( + '
'.format( + outer_class, + inner_class, + is_fitted_css_class, + ) + ) + raw_name = str(name) + name = html.escape(raw_name) + if name_details is not None or params: checked_str = "checked" if checked else "" - est_id = uuid.uuid4() + est_id = "sk-estimator-id-" + str(uuid.uuid4()) + + if doc_link: + label = html.escape( + str(doc_link_label) if doc_link_label is not None else raw_name + ) + doc_link = ( + '?Documentation for {}' + ).format(is_fitted_css_class, html.escape(doc_link, quote=True), label) + + name_caption_div = ( + "" + if name_caption is None + else f'
{html.escape(str(name_caption))}
' + ) + name_caption_div = f"
{name}
{name_caption_div}
" + links_div = ( + f"
{doc_link}{is_fitted_icon}
" + if doc_link or is_fitted_icon + else "" + ) + label_html = ( + '' + ).format(est_id, is_fitted_css_class, name_caption_div, links_div) + out.write( '' - f"" - f'
{name_details}'
-            "
" + 'id="{}" type="checkbox" {}>{}' + '
'.format( + est_id, + checked_str, + label_html, + is_fitted_css_class, + html.escape(param_prefix, quote=True), + ) ) + if params: + out.write(params) + elif name_details is not None: + out.write(f"
{html.escape(str(name_details))}
") + out.write("
") else: out.write(f"") out.write("
") # outer_class inner_class @@ -95,7 +439,15 @@ def _write_label_html( def _get_visual_block(base_object): """Generate information about how to display a BaseObject.""" if hasattr(base_object, "_sk_visual_block_"): - return base_object._sk_visual_block_() + try: + return base_object._sk_visual_block_() + except Exception: + return _VisualBlock( + "single", + base_object, + names=base_object.__class__.__name__, + name_details=str(base_object), + ) if isinstance(base_object, str): return _VisualBlock( @@ -104,15 +456,25 @@ def _get_visual_block(base_object): elif base_object is None: return _VisualBlock("single", base_object, names="None", name_details="None") - # collect BaseObject instances in the first layer to display in parallel - if hasattr(base_object, "get_params"): + # collect BaseObject-like instances in the first layer to display in parallel + if hasattr(base_object, "get_params") and not isclass(base_object): base_objects = [] - for key, value in base_object.get_params().items(): - # Recurse to nested BaseObject instances in the first layer (not classes) - if "__" not in key and hasattr(value, "get_params") and not isclass(value): - base_objects.append(value) - if len(base_objects): - return _VisualBlock("parallel", base_objects, names=None) + try: + params = base_object.get_params(deep=False) + except Exception: + params = {} + + for key, value in params.items(): + # Recurse to nested BaseObject-like instances in the first layer only. + if hasattr(value, "get_params") and not isclass(value): + base_objects.append((key, value)) + if base_objects: + return _VisualBlock( + "parallel", + [obj for _, obj in base_objects], + names=[f"{key}: {obj.__class__.__name__}" for key, obj in base_objects], + name_details=[str(obj) for _, obj in base_objects], + ) return _VisualBlock( "single", @@ -122,11 +484,29 @@ def _get_visual_block(base_object): ) +def _param_prefix_for_child(param_prefix, name): + """Return nested parameter prefix for a child label.""" + if not isinstance(name, str): + return param_prefix + + child_name = name.split(":", 1)[0] + if not child_name: + return param_prefix + return f"{param_prefix}{child_name}__" + + def _write_base_object_html( - out, base_object, base_object_label, base_object_label_details, first_call=False + out, + base_object, + base_object_label, + base_object_label_details, + first_call=False, + param_prefix="", ): - """Write BaseObject to html in serial, parallel, or by itself (single).""" + """Write BaseObject to HTML in serial, parallel, or by itself (single).""" est_block = _get_visual_block(base_object) + doc_link = _get_doc_link(base_object) + is_fitted_css_class, is_fitted_icon = _get_fitted_status(base_object) if est_block.kind in ("serial", "parallel"): dashed_wrapped = first_call or est_block.dash_wrapped @@ -134,211 +514,65 @@ def _write_base_object_html( out.write(f'
') if base_object_label: - _write_label_html(out, base_object_label, base_object_label_details) + params = _params_html_repr(base_object, doc_link=doc_link) + _write_label_html( + out, + base_object_label, + base_object_label_details, + params=params, + doc_link=doc_link, + is_fitted_css_class=is_fitted_css_class, + is_fitted_icon=is_fitted_icon, + param_prefix=param_prefix, + ) kind = est_block.kind out.write(f'
') est_infos = zip(est_block.estimators, est_block.names, est_block.name_details) for est, name, name_details in est_infos: + new_prefix = _param_prefix_for_child(param_prefix, name) if kind == "serial": - _write_base_object_html(out, est, name, name_details) + _write_base_object_html( + out, + est, + name, + name_details, + param_prefix=new_prefix, + ) else: # parallel out.write('
') # wrap element in a serial visualblock serial_block = _VisualBlock("serial", [est], dash_wrapped=False) - _write_base_object_html(out, serial_block, name, name_details) + _write_base_object_html( + out, + serial_block, + name, + name_details, + param_prefix=new_prefix, + ) out.write("
") # sk-parallel-item out.write("
") elif est_block.kind == "single": + params = _params_html_repr(base_object, doc_link=doc_link) _write_label_html( out, est_block.names, est_block.name_details, + params=params, + name_caption=est_block.name_caption, + doc_link_label=est_block.doc_link_label, outer_class="sk-item", inner_class="sk-estimator", checked=first_call, + doc_link=doc_link, + is_fitted_css_class=is_fitted_css_class, + is_fitted_icon=is_fitted_icon, + param_prefix=param_prefix, ) -_STYLE = """ -#$id { - color: black; - background-color: white; -} -#$id pre{ - padding: 0; -} -#$id div.sk-toggleable { - background-color: white; -} -#$id label.sk-toggleable__label { - cursor: pointer; - display: block; - width: 100%; - margin-bottom: 0; - padding: 0.3em; - box-sizing: border-box; - text-align: center; -} -#$id label.sk-toggleable__label-arrow:before { - content: "▸"; - float: left; - margin-right: 0.25em; - color: #696969; -} -#$id label.sk-toggleable__label-arrow:hover:before { - color: black; -} -#$id div.sk-estimator:hover label.sk-toggleable__label-arrow:before { - color: black; -} -#$id div.sk-toggleable__content { - max-height: 0; - max-width: 0; - overflow: hidden; - text-align: left; - background-color: #f0f8ff; -} -#$id div.sk-toggleable__content pre { - margin: 0.2em; - color: black; - border-radius: 0.25em; - background-color: #f0f8ff; -} -#$id input.sk-toggleable__control:checked~div.sk-toggleable__content { - max-height: 200px; - max-width: 100%; - overflow: auto; -} -#$id input.sk-toggleable__control:checked~label.sk-toggleable__label-arrow:before { - content: "▾"; -} -#$id div.sk-estimator input.sk-toggleable__control:checked~label.sk-toggleable__label { - background-color: #d4ebff; -} -#$id div.sk-label input.sk-toggleable__control:checked~label.sk-toggleable__label { - background-color: #d4ebff; -} -#$id input.sk-hidden--visually { - border: 0; - clip: rect(1px 1px 1px 1px); - clip: rect(1px, 1px, 1px, 1px); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; -} -#$id div.sk-estimator { - font-family: monospace; - background-color: #f0f8ff; - border: 1px dotted black; - border-radius: 0.25em; - box-sizing: border-box; - margin-bottom: 0.5em; -} -#$id div.sk-estimator:hover { - background-color: #d4ebff; -} -#$id div.sk-parallel-item::after { - content: ""; - width: 100%; - border-bottom: 1px solid gray; - flex-grow: 1; -} -#$id div.sk-label:hover label.sk-toggleable__label { - background-color: #d4ebff; -} -#$id div.sk-serial::before { - content: ""; - position: absolute; - border-left: 1px solid gray; - box-sizing: border-box; - top: 2em; - bottom: 0; - left: 50%; -} -#$id div.sk-serial { - display: flex; - flex-direction: column; - align-items: center; - background-color: white; - padding-right: 0.2em; - padding-left: 0.2em; -} -#$id div.sk-item { - z-index: 1; -} -#$id div.sk-parallel { - display: flex; - align-items: stretch; - justify-content: center; - background-color: white; -} -#$id div.sk-parallel::before { - content: ""; - position: absolute; - border-left: 1px solid gray; - box-sizing: border-box; - top: 2em; - bottom: 0; - left: 50%; -} -#$id div.sk-parallel-item { - display: flex; - flex-direction: column; - position: relative; - background-color: white; -} -#$id div.sk-parallel-item:first-child::after { - align-self: flex-end; - width: 50%; -} -#$id div.sk-parallel-item:last-child::after { - align-self: flex-start; - width: 50%; -} -#$id div.sk-parallel-item:only-child::after { - width: 0; -} -#$id div.sk-dashed-wrapped { - border: 1px dashed gray; - margin: 0 0.4em 0.5em 0.4em; - box-sizing: border-box; - padding-bottom: 0.4em; - background-color: white; - position: relative; -} -#$id div.sk-label label { - font-family: monospace; - font-weight: bold; - background-color: white; - display: inline-block; - line-height: 1.2em; -} -#$id div.sk-label-container { - position: relative; - z-index: 2; - text-align: center; -} -#$id div.sk-container { - /* jupyter's `normalize.less` sets `[hidden] { display: none; }` - but bootstrap.min.css set `[hidden] { display: none !important; }` - so we also need the `!important` here to be able to override the - default hidden behavior on the sphinx rendered scikit-learn.org. - See: https://github.com/scikit-learn/scikit-learn/issues/21755 */ - display: inline-block !important; - position: relative; -} -#$id div.sk-text-repr-fallback { - display: none; -} -""".replace(" ", "").replace("\n", "") # noqa - - def _object_html_repr(base_object): """Build a HTML representation of a BaseObject. @@ -353,8 +587,8 @@ def _object_html_repr(base_object): HTML representation of BaseObject. """ with closing(StringIO()) as out: - container_id = "sk-" + str(uuid.uuid4()) - style_template = Template(_STYLE) + container_id = "sk-container-id-" + str(uuid.uuid4()) + style_template = Template(_get_css_style()) style_with_id = style_template.substitute(id=container_id) base_object_str = str(base_object) @@ -368,15 +602,25 @@ def _object_html_repr(base_object): # The reverse logic applies to HTML repr div.sk-container. # div.sk-container is hidden by default and the loading the CSS displays it. fallback_msg = ( - "Please rerun this cell to show the HTML repr or trust the notebook." + "In a Jupyter environment, please rerun this cell to show the HTML " + "representation or trust the notebook.
On GitHub, the HTML " + "representation is unable to render, please try loading this page " + "with nbviewer.org." ) out.write( - f"" - f'
' - '
' - f"
{html.escape(base_object_str)}
{fallback_msg}" - "
" - '
".format( + script, + container_id, + ) + ) html_output = out.getvalue() return html_output diff --git a/skbase/base/_pretty_printing/tests/test_object_html_repr.py b/skbase/base/_pretty_printing/tests/test_object_html_repr.py index 6f37602d..3b62db1e 100644 --- a/skbase/base/_pretty_printing/tests/test_object_html_repr.py +++ b/skbase/base/_pretty_printing/tests/test_object_html_repr.py @@ -1,9 +1,16 @@ # -*- coding: utf-8 -*- -"""Tests for HTML representation of meta-objects (regression for #160/#163).""" +"""Tests for HTML representation of BaseObjects.""" + +import re from skbase.base import BaseObject from skbase.base._meta import BaseMetaObject -from skbase.base._pretty_printing._object_html_repr import _object_html_repr +from skbase.base._pretty_printing._object_html_repr import ( + _generate_link_to_param_doc, + _HTMLDocumentationLinkMixin, + _object_html_repr, + _read_param, +) class ComponentDummy(BaseObject): @@ -26,6 +33,25 @@ def __eq__(self, other): __hash__ = None +class DocumentedDummy(BaseObject): + """BaseObject with documented parameters. + + Parameters + ---------- + alpha : int + Documentation for alpha. + beta : str + Documentation for beta. + """ + + _html_repr_doc_link = "https://example.org/DocumentedDummy.html" + + def __init__(self, alpha=1, beta="default"): + self.alpha = alpha + self.beta = beta + super().__init__() + + class MetaObjectForHtml(BaseMetaObject): def __init__(self, steps=None): self.steps = steps @@ -40,6 +66,47 @@ def __init__(self, nested_cls): super().__init__() +class HtmlParamObject(BaseObject): + """Object with parameters used to test HTML parameter rendering. + + Parameters + ---------- + alpha : int + Controls a numeric setting. + beta : str + Controls a text setting. + payload : object + Arbitrary payload. + """ + + _doc_link_template = "https://example.test/{object_module}.{object_name}.html" + + def __init__(self, alpha=1, beta="default", payload=None): + self.alpha = alpha + self.beta = beta + self.payload = payload + super().__init__() + + +class HtmlParentObject(BaseObject): + """Object containing a nested BaseObject.""" + + def __init__(self, child=None): + self.child = child + super().__init__() + + +class HtmlLinkedMixinObject(_HTMLDocumentationLinkMixin, BaseObject): + """Object that receives docs links from the mixin defaults.""" + + _doc_link_module = __name__.split(".")[0] + _doc_link_template = "https://docs.example/{object_module}.{object_name}.html" + + def __init__(self, alpha=1): + self.alpha = alpha + super().__init__() + + def test_html_repr_with_baseobject_class_param(): """HTML diagram repr must not call get_params on a BaseObject class param. @@ -67,3 +134,125 @@ def test_meta_object_html_repr_does_not_raise(): # should include the class name and at least one html tag assert meta.__class__.__name__ in html_repr assert "Parameters" in html_repr + assert "parameters-table" in html_repr + assert "user-set" in html_repr + assert "onclick=\"skbaseCopyToClipboard('alpha'" in html_repr + assert "<script>alert('xss')</script>" in html_repr + assert "