From 6969e7a78c924eeea1fe0ddf61cc3e64bcfa4823 Mon Sep 17 00:00:00 2001
From: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Date: Wed, 4 Feb 2026 23:09:16 +0900
Subject: [PATCH] docs: fix copy doctests without `>>>` and `...` by button &
ctr+c (#2138)
* Add custom JavaScript for copying Python REPL code examples without prompts and integrate it into the docs
* Enhance JavaScript copy button functionality to support `.md-code__button` and streamline clipboard handling
* Remove unused `pycon_copy.js` references and streamline JavaScript dependencies in docs and configuration
* Enhance `stripPythonPrompts` to handle triple-quoted strings in REPL code parsing
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
docs/javascripts/pycon_copy.js | 197 ++++++++++++++++++
docs/theme/main.html | 1 +
mkdocs.yml | 1 +
.../metrics/mean_average_precision.py | 2 +-
4 files changed, 200 insertions(+), 1 deletion(-)
create mode 100644 docs/javascripts/pycon_copy.js
diff --git a/docs/javascripts/pycon_copy.js b/docs/javascripts/pycon_copy.js
new file mode 100644
index 00000000..283753d5
--- /dev/null
+++ b/docs/javascripts/pycon_copy.js
@@ -0,0 +1,197 @@
+/**
+ * Custom copy handler for Python console (pycon) code blocks.
+ * Strips >>> and ... prompts when copying code examples.
+ */
+document.addEventListener("DOMContentLoaded", function () {
+ const COPY_BUTTON_SELECTOR = ".md-clipboard, .md-code__button";
+
+ function handleCopyButtonClick(event) {
+ const copyButton = event.target.closest(COPY_BUTTON_SELECTOR);
+ if (!copyButton) return;
+
+ const codeBlock = findCodeBlockForCopyButton(copyButton);
+ if (!codeBlock) return;
+
+ const rawText = codeBlock.textContent || "";
+ if (!shouldStripPrompts(codeBlock, rawText)) return;
+
+ const strippedText = stripPythonPrompts(rawText);
+ primeClipboardButton(copyButton, strippedText);
+ }
+
+ function handleCopyButtonPointerDown(event) {
+ const copyButton = event.target.closest(COPY_BUTTON_SELECTOR);
+ if (!copyButton) return;
+
+ const codeBlock = findCodeBlockForCopyButton(copyButton);
+ if (!codeBlock) return;
+
+ const rawText = codeBlock.textContent || "";
+ if (!shouldStripPrompts(codeBlock, rawText)) return;
+
+ const strippedText = stripPythonPrompts(rawText);
+ primeClipboardButton(copyButton, strippedText);
+ }
+
+ function handleSelectionCopy(event) {
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0) return;
+
+ const range = selection.getRangeAt(0);
+ const anchorNode = range.commonAncestorContainer;
+ const codeBlock =
+ anchorNode.nodeType === Node.ELEMENT_NODE
+ ? anchorNode.closest("code")
+ : anchorNode.parentElement?.closest("code");
+
+ if (!codeBlock) return;
+
+ const rawText = selection.toString();
+ if (!shouldStripPrompts(codeBlock, rawText)) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const strippedText = stripPythonPrompts(rawText);
+ event.clipboardData?.setData("text/plain", strippedText);
+ }
+
+ function bindCopyButtons(root) {
+ root
+ .querySelectorAll(COPY_BUTTON_SELECTOR)
+ .forEach((button) => {
+ button.removeEventListener("click", handleCopyButtonClick, true);
+ button.addEventListener("click", handleCopyButtonClick, true);
+ button.removeEventListener(
+ "pointerdown",
+ handleCopyButtonPointerDown,
+ true
+ );
+ button.addEventListener(
+ "pointerdown",
+ handleCopyButtonPointerDown,
+ true
+ );
+ });
+ }
+
+ function observeDynamicCopyButtons() {
+ const observer = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ if (mutation.type !== "childList") continue;
+ mutation.addedNodes.forEach((node) => {
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
+ if (node.matches?.(COPY_BUTTON_SELECTOR)) {
+ bindCopyButtons(node.parentElement || document);
+ return;
+ }
+ if (node.querySelectorAll) {
+ const hasButtons = node.querySelectorAll(COPY_BUTTON_SELECTOR);
+ if (hasButtons.length > 0) {
+ bindCopyButtons(node);
+ }
+ }
+ });
+ }
+ });
+
+ observer.observe(document.body, { childList: true, subtree: true });
+ }
+
+ document.addEventListener("click", handleCopyButtonClick, true);
+ document.addEventListener("pointerdown", handleCopyButtonPointerDown, true);
+ document.addEventListener("copy", handleSelectionCopy, true);
+ bindCopyButtons(document);
+ observeDynamicCopyButtons();
+});
+
+function primeClipboardButton(copyButton, strippedText) {
+ copyButton.setAttribute("data-clipboard-text", strippedText);
+ copyButton.removeAttribute("data-clipboard-target");
+ copyButton.setAttribute("data-md-clipboard", "true");
+}
+
+function shouldStripPrompts(codeBlock, rawText) {
+ const hasReplPrompts = /(^|\n)[ \t]*(>>>|\.\.\.)/.test(rawText);
+ return (
+ hasReplPrompts ||
+ codeBlock.classList.contains("language-pycon") ||
+ codeBlock.closest("pre")?.classList.contains("pycon") ||
+ codeBlock.closest(".pycon") !== null ||
+ codeBlock.closest(".highlight")?.classList.contains("pycon")
+ );
+}
+
+function findCodeBlockForCopyButton(copyButton) {
+ const targetSelector = copyButton.getAttribute("data-clipboard-target");
+ if (targetSelector) {
+ const target = document.querySelector(targetSelector);
+ const targetCode = target?.querySelector?.("code") || target;
+ if (targetCode?.tagName?.toLowerCase() === "code") {
+ return targetCode;
+ }
+ }
+ return (
+ copyButton.closest("pre")?.querySelector("code") ||
+ copyButton.parentElement?.querySelector("pre code") ||
+ copyButton
+ .closest(".highlight, .codehilite, .md-typeset__scrollwrap, .md-typeset")
+ ?.querySelector("pre code") ||
+ copyButton
+ .closest(".highlight, .codehilite, .md-typeset__scrollwrap, .md-typeset")
+ ?.querySelector("code")
+ );
+}
+
+/**
+ * Strips Python REPL prompts (>>> and ...) from code text.
+ * Also removes output lines (lines that don't start with >>> or ...).
+ *
+ * NOTE: This is a best-effort parser. It preserves unprompted lines inside
+ * triple-quoted strings, but it does not fully model Python's tokenizer.
+ */
+function stripPythonPrompts(text) {
+ const lines = text.split("\n");
+ const codeLines = [];
+ let inTripleQuotedString = false;
+
+ function toggleTripleQuoteState(sourceLine) {
+ const tripleQuotePattern = /("""|''')/g;
+ const matches = sourceLine.match(tripleQuotePattern);
+ if (!matches) return;
+ if (matches.length % 2 === 1) {
+ inTripleQuotedString = !inTripleQuotedString;
+ }
+ }
+
+ for (const line of lines) {
+ const trimmedLine = line.trimEnd();
+ // Primary prompt: ">>> "
+ if (trimmedLine.startsWith(">>> ")) {
+ const stripped = trimmedLine.slice(4);
+ codeLines.push(stripped);
+ toggleTripleQuoteState(stripped);
+ }
+ // Continuation prompt: "... "
+ else if (trimmedLine.startsWith("... ")) {
+ const stripped = trimmedLine.slice(4);
+ codeLines.push(stripped);
+ toggleTripleQuoteState(stripped);
+ }
+ // Handle prompts without space after (edge case)
+ else if (trimmedLine === ">>>") {
+ codeLines.push("");
+ }
+ else if (trimmedLine === "...") {
+ codeLines.push("");
+ }
+ else if (inTripleQuotedString) {
+ codeLines.push(trimmedLine);
+ toggleTripleQuoteState(trimmedLine);
+ }
+ // Skip output lines (lines that don't start with prompts)
+ // This intentionally excludes output like "1.0" from the copied text
+ }
+
+ return codeLines.join("\n").trim();
+}
diff --git a/docs/theme/main.html b/docs/theme/main.html
index d4b99844..a19af310 100644
--- a/docs/theme/main.html
+++ b/docs/theme/main.html
@@ -12,6 +12,7 @@
{% endblock content %}
{% block extrahead %}
+{{ super() }}
{% endblock %}
diff --git a/mkdocs.yml b/mkdocs.yml
index a678b519..5223928d 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -198,6 +198,7 @@ markdown_extensions:
extra_javascript:
- "javascripts/init_kapa_widget.js"
- "javascripts/cookbooks-card.js"
+ - "javascripts/pycon_copy.js"
- "javascripts/segment.js"
- "javascripts/mathjax.js"
- "https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.8/purify.min.js"
diff --git a/src/supervision/metrics/mean_average_precision.py b/src/supervision/metrics/mean_average_precision.py
index 24c4cc49..c29b2839 100644
--- a/src/supervision/metrics/mean_average_precision.py
+++ b/src/supervision/metrics/mean_average_precision.py
@@ -94,7 +94,7 @@ class MeanAveragePrecisionResult:
Average Precision (AP) @[ IoU=0.50:0.95 | area= small | maxDets=100 ] = 0.284
Average Precision (AP) @[ IoU=0.50:0.95 | area=medium | maxDets=100 ] = 0.497
Average Precision (AP) @[ IoU=0.50:0.95 | area= large | maxDets=100 ] = 0.629
- ```
+ ```
"""
if (
self.small_objects is None