diff --git a/.github/workflows/build-docs.yaml b/.github/workflows/build-docs.yaml new file mode 100644 index 000000000..659a8b04e --- /dev/null +++ b/.github/workflows/build-docs.yaml @@ -0,0 +1,46 @@ +name: Build API docs + +on: + workflow_dispatch: + push: + branches: ["main"] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11"] + fail-fast: false + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Upgrade pip + run: python -m pip install --upgrade pip + + - name: Install Quarto + uses: quarto-dev/quarto-actions/setup@v2 + with: + version: 1.3.340 + + - name: Install dependencies + run: | + cd docs + make deps + + - name: Run quartodoc + run: | + cd docs + make quartodoc + + - name: Build site + run: | + cd docs + make site diff --git a/.gitignore b/.gitignore index 286c29a3e..9ca4423b4 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,5 @@ docs/source/reference/ .DS_Store .Rproj.user + +/.luarc.json diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 000000000..95eb9c701 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,6 @@ +_site/ +api/ +_inv/ +_sidebar.yml +/.quarto/ +objects.json diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 000000000..d14e9b8b1 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,60 @@ +.PHONY: help Makefile +.DEFAULT_GOAL := help + +define BROWSER_PYSCRIPT +import os, webbrowser, sys + +from urllib.request import pathname2url + +webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) +endef +export BROWSER_PYSCRIPT + +define PRINT_HELP_PYSCRIPT +import re, sys + +for line in sys.stdin: + match = re.match(r'^([a-zA-Z1-9_-]+):.*?## (.*)$$', line) + if match: + target, help = match.groups() + print("%-20s %s" % (target, help)) +endef +export PRINT_HELP_PYSCRIPT + +BROWSER := python -c "$$BROWSER_PYSCRIPT" + +# Use venv from parent +VENV = ../venv +PYBIN = $(VENV)/bin + +# Any targets that depend on $(VENV) or $(PYBIN) will cause the venv to be +# created. To use the venv, python scripts should run with the prefix $(PYBIN), +# as in `$(PYBIN)/pip`. +$(VENV): + python3 -m venv $(VENV) + +$(PYBIN): $(VENV) + + +help: + @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) + +deps: $(PYBIN) ## Install build dependencies + $(PYBIN)/pip install pip --upgrade + $(PYBIN)/pip install -e ..[doc] + +quartodoc: ## Build qmd files for API docs + . $(PYBIN)/activate \ + && quartodoc interlinks \ + && quartodoc build --config _quartodoc.yml --verbose + +site: ## Build website + . $(PYBIN)/activate \ + && quarto render + +serve: ## Build website and serve + . $(PYBIN)/activate \ + && quarto preview --port 8080 + +clean: ## Clean build artifacts + rm -rf _inv api _site .quarto diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..b984e540e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,31 @@ +Shiny for Python API docs +========================= + +This directory contains files to generate Shiny for Python API documentation, using [Quartodoc](https://machow.github.io/quartodoc/get-started/overview.html) and [Quarto](https://quarto.org/). + +## Building the docs + +To build the docs, first install the Python dependencies and Quarto extensions: + +```bash +# Install build dependencies +make py-deps +``` + +After those dependencies are installed, build the .qmd files for Shiny, using quartodoc. This will go in the `api/` directory: + +```bash +make quartodoc +``` + +Then build the web site using Quarto: + +```bash +make site +``` + +Alternatively, running `make serve` will build the docs, and serve them locally, and watch for changes to the .qmd files: + +```bash +make serve +``` diff --git a/docs/_extensions/machow/interlinks/.gitignore b/docs/_extensions/machow/interlinks/.gitignore new file mode 100644 index 000000000..5a1bf0b4e --- /dev/null +++ b/docs/_extensions/machow/interlinks/.gitignore @@ -0,0 +1,3 @@ +*.html +*.pdf +*_files/ diff --git a/docs/_extensions/machow/interlinks/_extension.yml b/docs/_extensions/machow/interlinks/_extension.yml new file mode 100644 index 000000000..464b9f638 --- /dev/null +++ b/docs/_extensions/machow/interlinks/_extension.yml @@ -0,0 +1,7 @@ +title: Interlinks +author: Michael Chow +version: 1.0.0 +quarto-required: ">=1.2.0" +contributes: + filters: + - interlinks.lua diff --git a/docs/_extensions/machow/interlinks/interlinks.lua b/docs/_extensions/machow/interlinks/interlinks.lua new file mode 100644 index 000000000..95f139be2 --- /dev/null +++ b/docs/_extensions/machow/interlinks/interlinks.lua @@ -0,0 +1,183 @@ +local function read_json(filename) + local file = io.open(filename, "r") + if file == nil then + return nil + end + local str = file:read("a") + file:close() + return quarto.json.decode(str) +end + +local inventory = {} + +function lookup(search_object) + + local results = {} + for ii, inventory in ipairs(inventory) do + for jj, item in ipairs(inventory.items) do + -- e.g. :external+:::`` + if item.inv_name and item.inv_name ~= search_object.inv_name then + goto continue + end + + if item.name ~= search_object.name then + goto continue + end + + if search_object.role and item.role ~= search_object.role then + goto continue + end + + if search_object.domain and item.domain ~= search_object.domain then + goto continue + else + table.insert(results, item) + + goto continue + end + + ::continue:: + end + end + + if #results == 1 then + return results[1] + end + if #results > 1 then + print("Found multiple matches for " .. search_object.name) + quarto.utils.dump(results) + return nil + end + if #results == 0 then + print("Found no matches for object:") + quarto.utils.dump(search_object) + end + + return nil +end + +function mysplit (inputstr, sep) + if sep == nil then + sep = "%s" + end + local t={} + for str in string.gmatch(inputstr, "([^"..sep.."]+)") do + table.insert(t, str) + end + return t +end + +local function normalize_role(role) + if role == "func" then + return "function" + end + return role +end + +local function build_search_object(str) + local starts_with_colon = str:sub(1, 1) == ":" + local search = {} + if starts_with_colon then + local t = mysplit(str, ":") + if #t == 2 then + -- e.g. :py:func:`my_func` + search.role = normalize_role(t[1]) + search.name = t[2]:match("%%60(.*)%%60") + elseif #t == 3 then + -- e.g. :py:func:`my_func` + search.domain = t[1] + search.role = normalize_role(t[2]) + search.name = t[3]:match("%%60(.*)%%60") + elseif #t == 4 then + -- e.g. :ext+inv:py:func:`my_func` + search.external = true + + search.inv_name = t[1]:match("external%+(.*)") + search.domain = t[2] + search.role = normalize_role(t[3]) + search.name = t[4]:match("%%60(.*)%%60") + else + print("couldn't parse this link: " .. str) + return {} + end + else + search.name = str:match("%%60(.*)%%60") + end + + if search.name == nil then + print("couldn't parse this link: " .. str) + return {} + end + + if search.name:sub(1, 1) == "~" then + search.shortened = true + search.name = search.name:sub(2, -1) + end + return search +end + +function report_broken_link(link, search_object, replacement) + -- TODO: how to unescape html elements like [? + return pandoc.Code(pandoc.utils.stringify(link.content)) +end + +function Link(link) + -- do not process regular links ---- + if not link.target:match("%%60") then + return link + end + + -- lookup item ---- + local search = build_search_object(link.target) + local item = lookup(search) + + -- determine replacement, used if no link text specified ---- + local original_text = pandoc.utils.stringify(link.content) + local replacement = search.name + if search.shortened then + local t = mysplit(search.name, ".") + replacement = t[#t] + end + + -- set link text ---- + if original_text == "" and replacement ~= nil then + link.content = pandoc.Code(replacement) + end + + -- report broken links ---- + if item == nil then + return report_broken_link(link, search) + end + link.target = item.uri:gsub("%$$", search.name) + + + return link +end + +function fixup_json(json, prefix) + for _, item in ipairs(json.items) do + item.uri = prefix .. item.uri + end + table.insert(inventory, json) +end + +return { + { + Meta = function(meta) + local json + local prefix + for k, v in pairs(meta.interlinks.sources) do + json = read_json(quarto.project.offset .. "/_inv/" .. k .. "_objects.json") + prefix = pandoc.utils.stringify(v.url) + fixup_json(json, prefix) + end + json = read_json(quarto.project.offset .. "/objects.json") + if json ~= nil then + fixup_json(json, "/") + end + end + }, + { + Link = Link + } +} diff --git a/docs/_extensions/quarto-ext/shinylive/_extension.yml b/docs/_extensions/quarto-ext/shinylive/_extension.yml new file mode 100644 index 000000000..764d138cf --- /dev/null +++ b/docs/_extensions/quarto-ext/shinylive/_extension.yml @@ -0,0 +1,8 @@ +name: shinylive +title: Embedded Shinylive applications +author: Winston Chang +version: 0.0.3 +quarto-required: ">=1.2.198" +contributes: + filters: + - shinylive.lua diff --git a/docs/_extensions/quarto-ext/shinylive/resources/css/shinylive-quarto.css b/docs/_extensions/quarto-ext/shinylive/resources/css/shinylive-quarto.css new file mode 100644 index 000000000..3b7cc3aaa --- /dev/null +++ b/docs/_extensions/quarto-ext/shinylive/resources/css/shinylive-quarto.css @@ -0,0 +1,34 @@ +div.output-content, +div.shinylive-wrapper { + background-color: rgba(250, 250, 250, 0.65); + border: 1px solid rgba(233, 236, 239, 0.65); + border-radius: 0.5rem; + box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.04), 0px 3px 7px rgba(0, 0, 0, 0.04), + 0px 12px 30px rgba(0, 0, 0, 0.07); + margin-top: 32px; + margin-bottom: 32px; +} + +div.shinylive-wrapper { + margin: 1em 0; + border-radius: 8px; +} + +.shinylive-container { + background-color: #eeeff2; + min-height: auto; +} + +.shinylive-container > div { + box-shadow: none; +} + +.editor-container .cm-editor .cm-scroller { + font-size: 13px; + line-height: 1.5; +} + +iframe.app-frame { + /* Override the default margin from Bootstrap */ + margin-bottom: 0; +} diff --git a/docs/_extensions/quarto-ext/shinylive/shinylive.lua b/docs/_extensions/quarto-ext/shinylive/shinylive.lua new file mode 100644 index 000000000..f488d043a --- /dev/null +++ b/docs/_extensions/quarto-ext/shinylive/shinylive.lua @@ -0,0 +1,99 @@ +local hasDoneShinyliveSetup = false +local codeblockScript = nil + +-- Try calling `pandoc.pipe('shinylive', ...)` and if it fails, print a message +-- about installing shinylive package. +function callShinylive(args, input) + local res + local status, err = pcall( + function() + res = pandoc.pipe("shinylive", args, input) + end + ) + + if not status then + print(err) + error("Error running 'shinylive' command. Perhaps you need to install the 'shinylive' Python package?") + end + + return res +end + + +-- Do one-time setup when a Shinylive codeblock is encountered. +function ensureShinyliveSetup() + if hasDoneShinyliveSetup then + return + end + hasDoneShinyliveSetup = true + + -- Find the path to codeblock-to-json.ts and save it for later use. + codeblockScript = callShinylive({ "codeblock-to-json-path" }, "") + -- Remove trailing whitespace + codeblockScript = codeblockScript:gsub("%s+$", "") + + local baseDeps = getShinyliveBaseDeps() + for idx, dep in ipairs(baseDeps) do + quarto.doc.add_html_dependency(dep) + end + + quarto.doc.add_html_dependency( + { + name = "shinylive-quarto-css", + stylesheets = {"resources/css/shinylive-quarto.css"} + } + ) +end + + +function getShinyliveBaseDeps() + -- Relative path from the current page to the root of the site. This is needed + -- to find out where shinylive-sw.js is, relative to the current page. + if quarto.project.offset == nil then + error("The shinylive extension must be used in a Quarto project directory (with a _quarto.yml file).") + end + local depJson = callShinylive( + { "base-deps", "--sw-dir", quarto.project.offset }, + "" + ) + + local deps = quarto.json.decode(depJson) + return deps +end + + +return { + { + CodeBlock = function(el) + if el.attr and el.attr.classes:includes("{shinylive-python}") then + ensureShinyliveSetup() + + -- Convert code block to JSON string in the same format as app.json. + local parsedCodeblockJson = pandoc.pipe( + "quarto", + { "run", codeblockScript }, + el.text + ) + + -- This contains "files" and "quartoArgs" keys. + local parsedCodeblock = quarto.json.decode(parsedCodeblockJson) + + -- Find Python package dependencies for the current app. + local appDepsJson = callShinylive( + { "package-deps" }, + quarto.json.encode(parsedCodeblock["files"]) + ) + + local appDeps = quarto.json.decode(appDepsJson) + + for idx, dep in ipairs(appDeps) do + quarto.doc.attach_to_dependency("shinylive", dep) + end + + el.attr.classes = pandoc.List() + el.attr.classes:insert("shinylive-python") + return el + end + end + } +} diff --git a/docs/_quarto.yml b/docs/_quarto.yml new file mode 100644 index 000000000..bf6dedf20 --- /dev/null +++ b/docs/_quarto.yml @@ -0,0 +1,37 @@ +project: + type: website + output-dir: _site + +format: + html: + toc: true + +website: + title: "Shiny for Python (dev version)" + description: "A Python package for writing interactive web applications." + repo-url: https://github.com/rstudio/py-shiny + repo-actions: [issue] + page-navigation: true + navbar: + background: primary + pinned: true + search: true + left: + - text: "API" + file: api/index.qmd + right: + - icon: github + href: https://github.com/rstudio/py-shiny + aria-label: Shiny for Python on GitHub + +metadata-files: + - api/_sidebar.yml + +filters: + - shinylive + - interlinks + +interlinks: + sources: + python: + url: https://docs.python.org/3/ diff --git a/docs/_quartodoc.yml b/docs/_quartodoc.yml new file mode 100644 index 000000000..d1387b9db --- /dev/null +++ b/docs/_quartodoc.yml @@ -0,0 +1,320 @@ +quartodoc: + style: pkgdown + dir: api + out_index: index.qmd + package: shiny + rewrite_all_pages: false + sidebar: api/_sidebar.yml + renderer: + style: _renderer.py + show_signature_annotations: false + sections: + - title: Page containers + desc: Create a user interface page container. + contents: + - ui.page_navbar + - ui.page_fluid + - ui.page_fixed + - ui.page_bootstrap + - title: UI Layout + desc: Control the layout of multiple UI components. + contents: + - ui.layout_sidebar + - ui.panel_sidebar + - ui.panel_main + - ui.column + - ui.row + - title: UI Inputs + desc: Create UI that prompts the user for input values or interaction. + contents: + - ui.input_select + - ui.input_selectize + - ui.input_slider + - ui.input_date + - ui.input_date_range + - ui.input_checkbox + - ui.input_checkbox_group + - ui.input_switch + - ui.input_radio_buttons + - ui.input_numeric + - ui.input_text + - ui.input_text_area + - ui.input_password + - ui.input_action_button + - ui.input_action_link + - title: Update inputs + desc: Programmatically update input values. + contents: + - name: ui.update_select + dynamic: true + - name: ui.update_selectize + dynamic: true + - name: ui.update_slider + dynamic: true + - ui.update_date + - name: ui.update_date_range + dynamic: true + - name: ui.update_checkbox + dynamic: true + - name: ui.update_checkbox_group + dynamic: true + - name: ui.update_switch + dynamic: true + - name: ui.update_radio_buttons + dynamic: true + - name: ui.update_numeric + dynamic: true + - ui.update_text + - name: ui.update_text_area + dynamic: "shiny.ui.update_text" + - name: ui.update_navs + dynamic: true + - title: Navigation (tab) panels + desc: Create segments of UI content. + contents: + - ui.nav + - ui.nav_control + - ui.nav_spacer + - ui.nav_menu + - ui.navset_tab + - ui.navset_tab_card + - ui.navset_pill + - ui.navset_pill_card + - ui.navset_pill_list + - ui.navset_hidden + - title: UI panels + desc: Visually group together a section of UI components. + contents: + - ui.panel_absolute + - ui.panel_fixed + - ui.panel_conditional + - ui.panel_title + - ui.panel_well + - title: Uploads & downloads + desc: Allow users to upload and download files. + contents: + - ui.input_file + - ui.download_button + - title: Custom UI + desc: Lower-level UI functions for creating custom HTML/CSS/JS + contents: + - ui.HTML # uses justattributes.rst template + - ui.TagList # uses class.rst template + - name: ui.tags # uses tags.rst template + children: embedded + - ui.markdown + - ui.include_css + - ui.include_js + - ui.insert_ui + - ui.remove_ui + - title: Rendering outputs + desc: "UI (output_*()) and server (render)ing functions for generating content server-side." + contents: + - ui.output_plot + - ui.output_image + - ui.output_table + - ui.output_data_frame + - ui.output_text + - ui.output_text_verbatim + - ui.output_ui + - render.plot + - render.image + - render.table + - render.text + - render.ui + - render.data_frame + - render.DataGrid + - render.DataTable + - title: Reactive programming + desc: "" + contents: + - reactive.Calc + - reactive.Effect + - reactive.Value + - reactive.event + - reactive.isolate + - reactive.invalidate_later + - reactive.flush + - reactive.poll + - reactive.file_reader + - reactive.lock + - req + - title: Create and run applications + desc: "" + contents: + - run_app + # uses class.rst template + - App + - Inputs + - Outputs + - Session + - title: Display messages + desc: "" + contents: + - ui.help_text + - ui.notification_show + - ui.notification_remove + - ui.modal + - ui.modal_show + - ui.modal_remove + - ui.modal_button + - ui.Progress # uses class.rst + - title: Modules + desc: "" + contents: + # uses class.rst template + - module.ui + - module.server + - title: Developer facing tools + desc: "" + contents: + - session.get_current_session + - session.require_active_session + - session.session_context + - reactive.get_current_context + - name: input_handler.input_handlers + dynamic: true + - title: Types + desc: "" + contents: + - kind: page + path: MiscTypes.html + flatten: true + summary: + name: "Miscellaneous types" + desc: "" + contents: + - types.MISSING_TYPE + - types.MISSING + - types.FileInfo + - types.ImgData + - types.NavSetArg + - ui._input_slider.SliderValueArg + - ui._input_slider.SliderStepArg + - kind: page + path: TagTypes.html + summary: + name: "Tag types" + desc: "" + flatten: true + package: null + contents: + - htmltools.Tag + - htmltools.TagAttrs + - htmltools.TagAttrValue + - htmltools.TagChild + - htmltools.TagList + - kind: page + path: ExceptionTypes.html + summary: + name: "Exception types" + desc: "" + flatten: true + contents: + - types.SilentException + - types.SilentCancelOutputException + - types.SafeException + - title: Experimental + desc: "These methods are under consideration and are considered unstable. However, if there is a method you are excited about, please let us know!" + contents: + - kind: page + path: ExSidebar + summary: + name: "Sidebar" + desc: "Sidebar layouts allow users to easily access filters, settings, and other inputs alongside interactive features they control." + flatten: true + contents: + - experimental.ui.page_sidebar + - experimental.ui.sidebar + - experimental.ui.layout_sidebar + - experimental.ui.page_navbar + - experimental.ui.navset_bar + - experimental.ui.navset_tab_card + - experimental.ui.navset_pill_card + - experimental.ui.sidebar_toggle + - experimental.ui.panel_main + - experimental.ui.panel_sidebar + - experimental.ui.Sidebar + - experimental.ui.DeprecatedPanelMain + - experimental.ui.DeprecatedPanelSidebar + - kind: page + path: ExCard + summary: + name: "Card" + desc: "Cards are a common organizing unit for modern user interfaces (UI). At their core, they’re just rectangular containers with borders and padding. However, when utilized properly to group related information, they help users better digest, engage, and navigate through content." + flatten: true + contents: + - experimental.ui.card + - experimental.ui.card_header + - experimental.ui.card_title + - experimental.ui.card_body + - experimental.ui.card_image + - experimental.ui.card_footer + - experimental.ui.CardItem + - experimental.ui.ImgContainer + - experimental.ui.TagCallable + - experimental.ui.WrapperCallable + - kind: page + path: ExAccordionPanels + summary: + name: "Accordion panels" + desc: "Methods related to creating and updating vertically collapsing accordion panels." + flatten: true + contents: + - experimental.ui.accordion + - experimental.ui.accordion_panel + - experimental.ui.accordion_panel_set + - experimental.ui.accordion_panel_open + - experimental.ui.accordion_panel_close + - experimental.ui.accordion_panel_insert + - experimental.ui.accordion_panel_remove + - experimental.ui.update_accordion_panel + - experimental.ui.AccordionPanel + - kind: page + path: ExValueBoxes + summary: + name: "Value boxes" + desc: "Prominently display a value and label in a box that can be expanded to show more information." + flatten: true + contents: + - experimental.ui.value_box + - experimental.ui.showcase_left_center + - experimental.ui.showcase_top_right + - kind: page + path: ExFillingLayout + summary: + name: "Filling layouts" + desc: "Methods to create containers that are allowed to shrink or expand available space." + flatten: true + contents: + - experimental.ui.page_fillable + - experimental.ui.layout_column_wrap + - experimental.ui.as_fill_carrier + - experimental.ui.as_fillable_container + - experimental.ui.as_fill_item + - experimental.ui.remove_all_fill + - experimental.ui.is_fill_carrier + - experimental.ui.is_fillable_container + - experimental.ui.is_fill_item + - experimental.ui.FillingLayout + - experimental.ui.output_image + - experimental.ui.output_plot + - experimental.ui.output_ui + - kind: page + path: ExUiInputs + summary: + name: "UI Inputs" + desc: "Additional or upgraded UI inputs." + flatten: true + contents: + - experimental.ui.input_text_area + - kind: page + path: ExCss + summary: + name: "Css" + desc: "Helper methods related to CSS." + flatten: true + contents: + - experimental.ui.as_css_unit + - experimental.ui.as_css_padding + - experimental.ui.CssUnit diff --git a/docs/_renderer.py b/docs/_renderer.py new file mode 100644 index 000000000..f30e41854 --- /dev/null +++ b/docs/_renderer.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import base64 +import html +import re +from importlib.resources import files +from pathlib import Path +from typing import Literal, Optional, TypedDict, Union + +import quartodoc.ast as qast +from griffe import dataclasses as dc +from griffe import expressions as exp +from griffe.docstrings import dataclasses as ds +from plum import dispatch +from quartodoc import MdRenderer +from quartodoc.renderers.base import convert_rst_link_to_md, sanitize + +SHINY_PATH = Path(files("shiny").joinpath()) + +SHINYLIVE_CODE_TEMPLATE = """ +```{{shinylive-python}} +#| standalone: true +#| components: [editor, viewer] +#| layout: vertical +#| viewerHeight: 400{0} +``` +""" + +DOCSTRING_TEMPLATE = """\ +{rendered} + +{header} Examples + +{examples} +""" + + +# This is the same as the FileContentJson type in TypeScript. +class FileContentJson(TypedDict): + name: str + content: str + type: Literal["text", "binary"] + + +class Renderer(MdRenderer): + style = "shiny" + + @dispatch + def render(self, el: qast.DocstringSectionSeeAlso): + # The See Also section in the Shiny docs has bare function references, ones that + # lack a leading :func: and backticks. This function fixes them. In the future, + # we can fix the docstrings in Shiny, once we decide on a standard. Then we can + # remove this function. + return prefix_bare_functions_with_func(el.value) + + @dispatch + def render(self, el: Union[dc.Object, dc.Alias]): + # If `el` is a protocol class that only has a `__call__` method, + # then we want to display information about the method, not the class. + if len(el.members) == 1 and "__call__" in el.members.keys(): + return self.render(el.members["__call__"]) + + # Not a __call__ Alias, so render as normal. + rendered = super().render(el) + + converted = convert_rst_link_to_md(rendered) + + if isinstance(el, dc.Alias) and "experimental" in el.target_path: + p_example_dir = SHINY_PATH / "experimental" / "examples" / el.name + else: + p_example_dir = SHINY_PATH / "examples" / el.name + + if (p_example_dir / "app.py").exists(): + example = "" + + files = list(p_example_dir.glob("**/*")) + + # Sort, and then move app.py to first position. + files.sort() + app_py_idx = files.index(p_example_dir / "app.py") + files = [files[app_py_idx]] + files[:app_py_idx] + files[app_py_idx + 1 :] + + for f in files: + if f.is_dir(): + continue + file_info = read_file(f, p_example_dir) + if file_info["type"] == "text": + example += f"\n## file: {file_info['name']}\n{file_info['content']}" + else: + example += f"\n## file: {file_info['name']}\n## type: binary\n{file_info['content']}" + + example = SHINYLIVE_CODE_TEMPLATE.format(example) + + return DOCSTRING_TEMPLATE.format( + rendered=converted, + examples=example, + header="#" * (self.crnt_header_level + 1), + ) + + return converted + + @dispatch + def render(self, el: ds.DocstringSectionText): + # functions like shiny.ui.tags.b have html in their docstrings, so + # we escape them. Note that we are only escaping text sections, but + # since these cover the top text of the docstring, it should solve + # the immediate problem. + rendered = super().render(el) + return html_escape_except_backticks(rendered) + + @dispatch + def render_annotation(self, el: str): + return sanitize(el) + + # TODO-future; Can be removed once we use quartodoc 0.3.5 + # Related: https://github.com/machow/quartodoc/pull/205 + @dispatch + def render(self, el: ds.DocstringAttribute): + row = [ + sanitize(el.name), + self.render_annotation(el.annotation), + sanitize(el.description or "", allow_markdown=True), + ] + return row + + @dispatch + def render_annotation(self, el: None): + return "" + + @dispatch + def render_annotation(self, el: exp.Expression): + # an expression is essentially a list[exp.Name | str] + # e.g. Optional[TagList] + # -> [Name(source="Optional", ...), "[", Name(...), "]"] + + return "".join(map(self.render_annotation, el)) + + @dispatch + def render_annotation(self, el: exp.Name): + # e.g. Name(source="Optional", full="typing.Optional") + return f"[{el.source}](`{el.full}`)" + + @dispatch + def summarize(self, el: dc.Object | dc.Alias): + result = super().summarize(el) + return html.escape(result) + + # Consolidate the parameter type info into a single column + @dispatch + def render(self, el: ds.DocstringParameter): + param = f'{el.name}' + annotation = self.render_annotation(el.annotation) + if annotation: + param = f'{param}: {annotation}' + if el.default: + param = f'{param} = {el.default}' + + # Wrap everything in a code block to allow for links + param = "" + param + "" + + clean_desc = sanitize(el.description, allow_markdown=True) + return (param, clean_desc) + + @dispatch + def render(self, el: ds.DocstringSectionParameters): + rows = list(map(self.render, el.value)) + header = ["Parameter", "Description"] + + return self._render_table(rows, header) + + @dispatch + def signature(self, el: dc.Function, source: Optional[dc.Alias] = None): + if el.name == "__call__": + # Ex: experimental.ui._card.ImgContainer.__call__(self, *args: Tag) -> Tagifiable + sig = super().signature(el, source) + + # Remove leading function name (before `__call__`) and `self` parameter + # Ex: __call__(*args: Tag) -> Tagifiable + sig = re.sub(r"[^`\s]*__call__\(self, ", "__call__(", sig, count=1) + + return sig + + # Not a __call__ Function, so render as normal. + return super().signature(el, source) + + +def html_escape_except_backticks(s: str) -> str: + """ + HTML-escape a string, except for content inside of backticks. + + Examples + -------- + s = "This is a test string with `backticks unescaped`." + print(html_escape_except_backticks(s)) + #> This is a <b>test</b> string with `backticks unescaped`. + """ + # Split the string using backticks as delimiters + parts = re.split(r"(`[^`]*`)", s) + + # Iterate over the parts, escaping the non-backtick parts, and preserving backticks in the backtick parts + escaped_parts = [ + html.escape(part) if i % 2 == 0 else part for i, part in enumerate(parts) + ] + + # Join the escaped parts back together + escaped_string = "".join(escaped_parts) + return escaped_string + + +def prefix_bare_functions_with_func(s: str) -> str: + """ + The See Also section in the Shiny docs has bare function references, ones that lack + a leading :func: and backticks. This function fixes them. + + If there are bare function references, like "~shiny.ui.panel_sidebar", this will + prepend with :func: and wrap in backticks. + + For example, if the input is this: + "~shiny.ui.panel_sidebar :func:`~shiny.ui.panel_sidebar`" + This function will return: + ":func:`~shiny.ui.panel_sidebar` :func:`~shiny.ui.panel_sidebar`" + """ + + def replacement(match: re.Match[str]) -> str: + return f":func:`{match.group(0)}`" + + pattern = r"(? FileContentJson: + file = Path(file) + if root_dir is None: + root_dir = Path("/") + root_dir = Path(root_dir) + + type: Literal["text", "binary"] = "text" + + try: + with open(file, "r") as f: + file_content = f.read() + type = "text" + except UnicodeDecodeError: + # If text failed, try binary. + with open(file, "rb") as f: + file_content_bin = f.read() + file_content = base64.b64encode(file_content_bin).decode("utf-8") + type = "binary" + + return { + "name": str(file.relative_to(root_dir)), + "content": file_content, + "type": type, + } diff --git a/setup.cfg b/setup.cfg index 36eebaaf5..4ba561815 100644 --- a/setup.cfg +++ b/setup.cfg @@ -86,6 +86,14 @@ dev = pandas-stubs numpy shinyswatch>=0.2.4 +doc = + jupyter + jupyter_client < 8.0.0 + tabulate + shinylive==0.0.14 + pydantic==1.10 + quartodoc==0.4.1 + griffe @ git+https://github.com/machow/griffe.git@dev-shiny [options.packages.find] include = shiny, shiny.*