From 6c39917d474fa4b4dac07c0246ae2555a8bbd3c4 Mon Sep 17 00:00:00 2001 From: woutdenolf Date: Wed, 21 Oct 2026 10:15:02 +0200 Subject: [PATCH 1/3] filter tasks on tags Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + doc/index.md | 20 ++++ pyproject.toml | 2 +- src/ewokssphinx/__init__.py | 8 +- src/ewokssphinx/ewoks_task_directive.py | 14 +++ src/ewokssphinx/ewoks_task_utils.py | 20 ++++ src/ewokssphinx/ewokssphinx.css | 27 ++++- src/ewokssphinx/ewokssphinx.js | 146 ++++++++++++++++++++++++ src/ewokssphinx/sphinx_task_utils.py | 14 ++- src/ewokssphinx/tests/test_task_tags.py | 43 +++++++ 10 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 src/ewokssphinx/ewokssphinx.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e9fa7a4..72263cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `ewokstasks` directive displays task tags. +- `ewokstasks` directive: `include-tags` and `exclude-tags` options. +- Filter tasks on tags in HTML pages. ## [2.2.1] - 2026-04-21 diff --git a/doc/index.md b/doc/index.md index 750a05f..21b8b8d 100644 --- a/doc/index.md +++ b/doc/index.md @@ -76,6 +76,26 @@ The [Examples page](./examples) contains real examples with Python code, RST syn :ignore-import-error: ``` +- `:include-tags:` and `:exclude-tags:` (comma-separated tags) + + Generates documentation only for tasks with at least one of the included tags + (when given) and none of the excluded tags. + + _Example_: + + ``` + .. ewokstasks:: ewoksxrpd.tasks.* + :include-tags: integration, xrpd + :exclude-tags: deprecated + ``` + +#### Tag filter + +The tags of a task are shown in its documentation. In HTML pages with tagged tasks, +readers can filter the tasks by clicking on tags: a first click only shows tasks with +that tag, a second click hides tasks with that tag and a third click resets it. +The filter is kept in the `include_tags` and `exclude_tags` URL query parameters. + ```{toctree} :hidden: diff --git a/pyproject.toml b/pyproject.toml index ae36f91..36d57b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ package-dir = { "" = "src" } where = ["src"] [tool.setuptools.package-data] -ewokssphinx = ["*.css"] +ewokssphinx = ["*.css", "*.js"] [tool.coverage.run] omit = ['*/tests/*'] diff --git a/src/ewokssphinx/__init__.py b/src/ewokssphinx/__init__.py index 268ecc3..a2bfcca 100644 --- a/src/ewokssphinx/__init__.py +++ b/src/ewokssphinx/__init__.py @@ -28,7 +28,8 @@ def setup(app: Sphinx): ) app.add_css_file("ewokssphinx.css") - app.connect("build-finished", _copy_css) + app.add_js_file("ewokssphinx.js") + app.connect("build-finished", _copy_static_files) return { "version": "1.0", @@ -37,8 +38,9 @@ def setup(app: Sphinx): } -def _copy_css(app, exception): +def _copy_static_files(app, exception): if app.builder.name != "html" or exception: return static_dir = Path(app.builder.outdir) / "_static" - copyfile(Path(__file__).parent / "ewokssphinx.css", static_dir / "ewokssphinx.css") + for filename in ("ewokssphinx.css", "ewokssphinx.js"): + copyfile(Path(__file__).parent / filename, static_dir / filename) diff --git a/src/ewokssphinx/ewoks_task_directive.py b/src/ewokssphinx/ewoks_task_directive.py index 221174b..99c7ab2 100644 --- a/src/ewokssphinx/ewoks_task_directive.py +++ b/src/ewokssphinx/ewoks_task_directive.py @@ -5,6 +5,7 @@ from .ewoks_task_utils import cached_tasks from .ewoks_task_utils import discover_tasks +from .ewoks_task_utils import filter_tasks_by_tags from .sphinx_task_utils import additional_model_nodes from .sphinx_task_utils import task_nodes @@ -20,6 +21,8 @@ class EwoksTaskDirective(SphinxDirective): option_spec = { "task-type": _task_type_option, "ignore-import-error": directives.flag, + "include-tags": directives.unchanged, + "exclude-tags": directives.unchanged, } def run(self): @@ -41,9 +44,20 @@ def run(self): module_pattern, task_type, ) + tasks = filter_tasks_by_tags( + tasks, + include_tags=_parse_tags(self.options.get("include-tags")), + exclude_tags=_parse_tags(self.options.get("exclude-tags")), + ) task_sections = task_nodes(self, tasks) additional_model_section = additional_model_nodes(self, tasks) if additional_model_section is not None: return [*task_sections, additional_model_section] return task_sections + + +def _parse_tags(argument: str | None) -> list[str]: + if not argument: + return [] + return [tag.strip() for tag in argument.split(",") if tag.strip()] diff --git a/src/ewokssphinx/ewoks_task_utils.py b/src/ewokssphinx/ewoks_task_utils.py index 2ac1491..4bb2984 100644 --- a/src/ewokssphinx/ewoks_task_utils.py +++ b/src/ewokssphinx/ewoks_task_utils.py @@ -6,6 +6,7 @@ from typing import Annotated from typing import Any from typing import Iterator +from typing import Sequence from typing import Type from typing import get_args from typing import get_origin @@ -94,6 +95,25 @@ def cached_tasks( return module_pattern_dict[task_type] +def filter_tasks_by_tags( + tasks: Sequence[dict[str, Any]], + include_tags: Sequence[str] | None = None, + exclude_tags: Sequence[str] | None = None, +) -> list[dict[str, Any]]: + """Keep tasks with at least one of `include_tags` (when given) and none of `exclude_tags`.""" + include = set(include_tags or ()) + exclude = set(exclude_tags or ()) + filtered = [] + for task in tasks: + tags = set(task.get("tags", ())) + if include and not (tags & include): + continue + if tags & exclude: + continue + filtered.append(task) + return filtered + + def _get_task_name(identifier: str, task_type: str) -> str: if task_type == "ppfmethod": # ppfmethods are all named `run` so use the module name as task name. diff --git a/src/ewokssphinx/ewokssphinx.css b/src/ewokssphinx/ewokssphinx.css index adb2d77..6233bc2 100644 --- a/src/ewokssphinx/ewokssphinx.css +++ b/src/ewokssphinx/ewokssphinx.css @@ -24,7 +24,32 @@ margin: 0; } -.ewokssphinx-task .ewokssphinx-tag { +.ewokssphinx-task .ewokssphinx-tag, +.ewokssphinx-tag-filter .ewokssphinx-tag { + cursor: pointer; border-radius: 0.75em; padding: 0.1em 0.6em; } + +.ewokssphinx-tag.ewokssphinx-tag-include { + background-color: var(--sd-color-success, green); + color: var(--sd-color-success-text, white); +} + +.ewokssphinx-tag.ewokssphinx-tag-exclude { + background-color: var(--sd-color-danger, red); + color: var(--sd-color-danger-text, white); + text-decoration: line-through; +} + +.ewokssphinx-tag-filter { + margin-bottom: 1em; + display: flex; + flex-wrap: wrap; + gap: 0.4em; + align-items: center; +} + +.ewokssphinx-tag-filter-count { + font-style: italic; +} diff --git a/src/ewokssphinx/ewokssphinx.js b/src/ewokssphinx/ewokssphinx.js new file mode 100644 index 0000000..25efc72 --- /dev/null +++ b/src/ewokssphinx/ewokssphinx.js @@ -0,0 +1,146 @@ +// Tag filter for Ewoks task sections. Clicking a tag cycles it through +// "include" -> "exclude" -> neutral. A task is shown when it has at least one +// included tag (if any) and no excluded tag. The state is kept in the URL query +// (`include_tags` and `exclude_tags`) so it survives navigation and can be shared. +(function () { + "use strict"; + + const STATES = ["", "include", "exclude"]; + + function taskTags(section) { + return Array.from(section.querySelectorAll(".ewokssphinx-tags .ewokssphinx-tag")).map( + (el) => el.textContent.trim() + ); + } + + function readState() { + const params = new URLSearchParams(window.location.search); + const state = new Map(); + for (const key of ["include", "exclude"]) { + const value = params.get(key + "_tags"); + if (!value) continue; + for (const tag of value.split(",")) { + if (tag) state.set(tag, key); + } + } + return state; + } + + function writeState(state) { + const url = new URL(window.location.href); + for (const key of ["include", "exclude"]) { + const tags = Array.from(state.entries()) + .filter(([, value]) => value === key) + .map(([tag]) => tag); + if (tags.length) url.searchParams.set(key + "_tags", tags.join(",")); + else url.searchParams.delete(key + "_tags"); + } + window.history.replaceState(null, "", url); + } + + function init() { + const sections = Array.from(document.querySelectorAll("section.ewokssphinx-task")); + if (!sections.length) return; + + const sectionTags = new Map(sections.map((s) => [s, taskTags(s)])); + const allTags = Array.from(new Set(Array.from(sectionTags.values()).flat())).sort(); + if (!allTags.length) return; + + // Model sections and the task sections using them + const modelSections = Array.from(document.querySelectorAll("section.ewokssphinx-model")); + const modelsSections = Array.from(document.querySelectorAll("section.ewokssphinx-models")); + const modelTasks = new Map( + modelSections.map((m) => { + const cls = Array.from(m.classList).find( + (c) => c.startsWith("ewokssphinx-model-") + ); + return [m, cls ? sections.filter((s) => s.classList.contains(cls)) : []]; + }) + ); + + // Table-of-contents entries pointing to task and model sections + const tocLinks = new Map( + [...sections, ...modelSections, ...modelsSections].map((s) => [ + s, + s.id + ? Array.from(document.querySelectorAll(`a[href="#${CSS.escape(s.id)}"]`)) + .filter((a) => !s.contains(a)) + .map((a) => a.closest("li") || a) + : [], + ]) + ); + + function setVisible(section, show) { + section.style.display = show ? "" : "none"; + for (const link of tocLinks.get(section)) link.style.display = show ? "" : "none"; + } + + const state = readState(); + + const bar = document.createElement("div"); + bar.className = "ewokssphinx-tag-filter"; + const label = document.createElement("span"); + label.textContent = "Filter on tags:"; + bar.appendChild(label); + for (const tag of allTags) { + const chip = document.createElement("code"); + chip.className = "ewokssphinx-tag"; + chip.textContent = tag; + bar.appendChild(chip); + } + const count = document.createElement("span"); + count.className = "ewokssphinx-tag-filter-count"; + bar.appendChild(count); + sections[0].parentNode.insertBefore(bar, sections[0]); + + function apply() { + const include = new Set(); + const exclude = new Set(); + for (const [tag, value] of state) { + if (value === "include") include.add(tag); + else if (value === "exclude") exclude.add(tag); + } + let visible = 0; + for (const [section, tags] of sectionTags) { + const show = + (!include.size || tags.some((t) => include.has(t))) && + !tags.some((t) => exclude.has(t)); + setVisible(section, show); + if (show) visible += 1; + } + for (const [model, tasks] of modelTasks) { + setVisible(model, tasks.some((t) => t.style.display !== "none")); + } + for (const models of modelsSections) { + const children = Array.from(models.querySelectorAll(":scope > section.ewokssphinx-model")); + setVisible(models, children.some((m) => m.style.display !== "none")); + } + for (const chip of document.querySelectorAll(".ewokssphinx-tag")) { + const value = state.get(chip.textContent.trim()) || ""; + chip.classList.toggle("ewokssphinx-tag-include", value === "include"); + chip.classList.toggle("ewokssphinx-tag-exclude", value === "exclude"); + } + count.textContent = + include.size || exclude.size ? `${visible} of ${sections.length} tasks` : ""; + } + + document.addEventListener("click", (event) => { + const chip = event.target.closest(".ewokssphinx-tag"); + if (!chip) return; + const tag = chip.textContent.trim(); + const next = STATES[(STATES.indexOf(state.get(tag) || "") + 1) % STATES.length]; + if (next) state.set(tag, next); + else state.delete(tag); + writeState(state); + apply(); + }); + + apply(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/src/ewokssphinx/sphinx_task_utils.py b/src/ewokssphinx/sphinx_task_utils.py index 153f811..153364d 100644 --- a/src/ewokssphinx/sphinx_task_utils.py +++ b/src/ewokssphinx/sphinx_task_utils.py @@ -28,7 +28,7 @@ def _task_section(directive: SphinxDirective, task: dict[str, Any]) -> nodes.sec section = nodes.section( "", ids=[task["task_identifier"]], - classes=["ewokssphinx-task"], + classes=["ewokssphinx-task", *map(_model_class, task["submodels"])], ) # Title @@ -233,7 +233,11 @@ def additional_model_nodes( model_sections = [] for task in tasks: for model_name, model_fields in task["submodels"].items(): - section = nodes.section("", ids=[model_name]) + section = nodes.section( + "", + ids=[model_name], + classes=["ewokssphinx-model", _model_class(model_name)], + ) section.append(nodes.title("", model_name.split(".")[-1])) section.append(_field_list(directive, {"fields": model_fields})) @@ -247,4 +251,10 @@ def additional_model_nodes( nodes.title("", "Additional models"), *model_sections, ids=["Additional models"], + classes=["ewokssphinx-models"], ) + + +def _model_class(model_name: str) -> str: + """CSS class linking a model section to the task sections that use the model.""" + return "ewokssphinx-model-" + nodes.make_id(model_name) diff --git a/src/ewokssphinx/tests/test_task_tags.py b/src/ewokssphinx/tests/test_task_tags.py index 32f9a97..4ae6b66 100644 --- a/src/ewokssphinx/tests/test_task_tags.py +++ b/src/ewokssphinx/tests/test_task_tags.py @@ -1,6 +1,9 @@ +import pytest from docutils import nodes from sphinx.testing import restructuredtext +from ..ewoks_task_utils import filter_tasks_by_tags + def test_ewokstasks_tags(app): parsed_nodes = restructuredtext.parse( @@ -17,6 +20,46 @@ def test_ewokstasks_tags(app): } +@pytest.mark.parametrize( + "options,expected", + [ + (":include-tags: xrf", {"XrfTask"}), + (":include-tags: xrf, xas", {"XrfTask", "XasTask"}), + (":exclude-tags: xrf", {"XasTask", "UntaggedTask"}), + (":include-tags: xrf, xas\n :exclude-tags: imaging", {"XasTask"}), + ], +) +def test_ewokstasks_filter_tags(app, options, expected): + parsed_nodes = restructuredtext.parse( + app, + f""".. ewokstasks:: ewokssphinx.tests.dummy_tasks_tags + :task-type: class + {options} + """, + ) + + assert set(_task_tags(parsed_nodes)) == expected + + +@pytest.mark.parametrize( + "include_tags,exclude_tags,expected", + [ + (None, None, ["a", "b", "c"]), + (["x"], None, ["a", "b"]), + (None, ["y"], ["a", "c"]), + (["x"], ["y"], ["a"]), + ], +) +def test_filter_tasks_by_tags(include_tags, exclude_tags, expected): + tasks = [ + {"task_name": "a", "tags": ["x"]}, + {"task_name": "b", "tags": ["x", "y"]}, + {"task_name": "c"}, + ] + filtered = filter_tasks_by_tags(tasks, include_tags, exclude_tags) + assert [task["task_name"] for task in filtered] == expected + + def _task_tags(document: nodes.document) -> dict[str, list[str]]: """Map task names to their tags.""" tags = {} From e2df75d8b8559cdbc17c7e2d2b5140e8efea0e35 Mon Sep 17 00:00:00 2001 From: woutdenolf Date: Thu, 22 Oct 2026 09:59:36 +0200 Subject: [PATCH 2/3] tag filter bar per group of tasks Co-Authored-By: Claude Opus 5.5 --- src/ewokssphinx/ewokssphinx.js | 81 +++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/src/ewokssphinx/ewokssphinx.js b/src/ewokssphinx/ewokssphinx.js index 25efc72..2ca231e 100644 --- a/src/ewokssphinx/ewokssphinx.js +++ b/src/ewokssphinx/ewokssphinx.js @@ -2,6 +2,9 @@ // "include" -> "exclude" -> neutral. A task is shown when it has at least one // included tag (if any) and no excluded tag. The state is kept in the URL query // (`include_tags` and `exclude_tags`) so it survives navigation and can be shared. +// Each group of sibling task sections with tags gets its own filter bar, so a bar +// is always next to the tasks it filters. All bars share the same state. Groups +// without tags are not filtered. (function () { "use strict"; @@ -39,23 +42,35 @@ } function init() { - const sections = Array.from(document.querySelectorAll("section.ewokssphinx-task")); - if (!sections.length) return; + // Group task sections by parent and keep the groups with tags + const groups = new Map(); + for (const section of document.querySelectorAll("section.ewokssphinx-task")) { + if (!groups.has(section.parentNode)) groups.set(section.parentNode, []); + groups.get(section.parentNode).push(section); + } + const taggedGroups = Array.from(groups.values()) + .map((groupSections) => ({ + sections: groupSections, + tags: new Map(groupSections.map((s) => [s, taskTags(s)])), + })) + .filter((group) => Array.from(group.tags.values()).some((tags) => tags.length)); + if (!taggedGroups.length) return; - const sectionTags = new Map(sections.map((s) => [s, taskTags(s)])); - const allTags = Array.from(new Set(Array.from(sectionTags.values()).flat())).sort(); - if (!allTags.length) return; + const sections = taggedGroups.flatMap((group) => group.sections); + const sectionTags = new Map(taggedGroups.flatMap((group) => Array.from(group.tags))); // Model sections and the task sections using them - const modelSections = Array.from(document.querySelectorAll("section.ewokssphinx-model")); - const modelsSections = Array.from(document.querySelectorAll("section.ewokssphinx-models")); const modelTasks = new Map( - modelSections.map((m) => { - const cls = Array.from(m.classList).find( - (c) => c.startsWith("ewokssphinx-model-") - ); - return [m, cls ? sections.filter((s) => s.classList.contains(cls)) : []]; - }) + Array.from(document.querySelectorAll("section.ewokssphinx-model")) + .map((m) => { + const cls = Array.from(m.classList).find((c) => c.startsWith("ewokssphinx-model-")); + return [m, cls ? sections.filter((s) => s.classList.contains(cls)) : []]; + }) + .filter(([, tasks]) => tasks.length) + ); + const modelSections = Array.from(modelTasks.keys()); + const modelsSections = Array.from( + new Set(modelSections.map((m) => m.closest("section.ewokssphinx-models")).filter(Boolean)) ); // Table-of-contents entries pointing to task and model sections @@ -77,21 +92,24 @@ const state = readState(); - const bar = document.createElement("div"); - bar.className = "ewokssphinx-tag-filter"; - const label = document.createElement("span"); - label.textContent = "Filter on tags:"; - bar.appendChild(label); - for (const tag of allTags) { - const chip = document.createElement("code"); - chip.className = "ewokssphinx-tag"; - chip.textContent = tag; - bar.appendChild(chip); + for (const group of taggedGroups) { + const groupTags = Array.from(new Set(Array.from(group.tags.values()).flat())).sort(); + const bar = document.createElement("div"); + bar.className = "ewokssphinx-tag-filter"; + const label = document.createElement("span"); + label.textContent = "Filter on tags:"; + bar.appendChild(label); + for (const tag of groupTags) { + const chip = document.createElement("code"); + chip.className = "ewokssphinx-tag"; + chip.textContent = tag; + bar.appendChild(chip); + } + group.count = document.createElement("span"); + group.count.className = "ewokssphinx-tag-filter-count"; + bar.appendChild(group.count); + group.sections[0].parentNode.insertBefore(bar, group.sections[0]); } - const count = document.createElement("span"); - count.className = "ewokssphinx-tag-filter-count"; - bar.appendChild(count); - sections[0].parentNode.insertBefore(bar, sections[0]); function apply() { const include = new Set(); @@ -100,13 +118,11 @@ if (value === "include") include.add(tag); else if (value === "exclude") exclude.add(tag); } - let visible = 0; for (const [section, tags] of sectionTags) { const show = (!include.size || tags.some((t) => include.has(t))) && !tags.some((t) => exclude.has(t)); setVisible(section, show); - if (show) visible += 1; } for (const [model, tasks] of modelTasks) { setVisible(model, tasks.some((t) => t.style.display !== "none")); @@ -120,8 +136,11 @@ chip.classList.toggle("ewokssphinx-tag-include", value === "include"); chip.classList.toggle("ewokssphinx-tag-exclude", value === "exclude"); } - count.textContent = - include.size || exclude.size ? `${visible} of ${sections.length} tasks` : ""; + for (const group of taggedGroups) { + const visible = group.sections.filter((s) => s.style.display !== "none").length; + group.count.textContent = + include.size || exclude.size ? `${visible} of ${group.sections.length} tasks` : ""; + } } document.addEventListener("click", (event) => { From 1355c6e54288959a80bcd96d253c1658910c7f49 Mon Sep 17 00:00:00 2001 From: woutdenolf Date: Thu, 22 Oct 2026 09:59:36 +0200 Subject: [PATCH 3/3] CSS and JS moved to `static/`, registered with `add_static_dir` Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- src/ewokssphinx/__init__.py | 18 ++++++++++-------- src/ewokssphinx/{ => static}/ewokssphinx.css | 0 src/ewokssphinx/{ => static}/ewokssphinx.js | 0 4 files changed, 11 insertions(+), 9 deletions(-) rename src/ewokssphinx/{ => static}/ewokssphinx.css (100%) rename src/ewokssphinx/{ => static}/ewokssphinx.js (100%) diff --git a/pyproject.toml b/pyproject.toml index 36d57b5..0966841 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ package-dir = { "" = "src" } where = ["src"] [tool.setuptools.package-data] -ewokssphinx = ["*.css", "*.js"] +ewokssphinx = ["static/*"] [tool.coverage.run] omit = ['*/tests/*'] diff --git a/src/ewokssphinx/__init__.py b/src/ewokssphinx/__init__.py index a2bfcca..b40fa56 100644 --- a/src/ewokssphinx/__init__.py +++ b/src/ewokssphinx/__init__.py @@ -1,10 +1,13 @@ from pathlib import Path +import sphinx from sphinx.application import Sphinx -from sphinx.util.osutil import copyfile +from sphinx.config import Config from .ewoks_task_directive import EwoksTaskDirective +_STATIC_DIR = Path(__file__).parent / "static" + def setup(app: Sphinx): app.add_directive("ewokstasks", EwoksTaskDirective) @@ -27,9 +30,12 @@ def setup(app: Sphinx): description="Ignore Ewoks task discovery errors. ", ) + if sphinx.version_info >= (9, 1): + app.add_static_dir(_STATIC_DIR) + else: + app.connect("config-inited", _add_static_path) app.add_css_file("ewokssphinx.css") app.add_js_file("ewokssphinx.js") - app.connect("build-finished", _copy_static_files) return { "version": "1.0", @@ -38,9 +44,5 @@ def setup(app: Sphinx): } -def _copy_static_files(app, exception): - if app.builder.name != "html" or exception: - return - static_dir = Path(app.builder.outdir) / "_static" - for filename in ("ewokssphinx.css", "ewokssphinx.js"): - copyfile(Path(__file__).parent / filename, static_dir / filename) +def _add_static_path(app: Sphinx, config: Config) -> None: + config.html_static_path.append(str(_STATIC_DIR)) diff --git a/src/ewokssphinx/ewokssphinx.css b/src/ewokssphinx/static/ewokssphinx.css similarity index 100% rename from src/ewokssphinx/ewokssphinx.css rename to src/ewokssphinx/static/ewokssphinx.css diff --git a/src/ewokssphinx/ewokssphinx.js b/src/ewokssphinx/static/ewokssphinx.js similarity index 100% rename from src/ewokssphinx/ewokssphinx.js rename to src/ewokssphinx/static/ewokssphinx.js