From 07fbb5e41a971abba145d8f3a9e6e9f52eea2999 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 17:02:16 +0000 Subject: [PATCH 1/2] Theme follows the user's choice; honest save reporting; a11y and doc fixes Audit A-17, A-18, A-19, A-25, A-29, A-30, A-35. New module plottle/utils/theming.py owns theme reading, writing and CSS generation, because none of the three theme defects could be fixed in place. A-17 -- Home.py injected a stylesheet with the NCCU dark palette baked in as literals while Settings let the user change the theme, and nothing connected them. On a light theme the hero subtitle was white-on-white and the scrollbar stayed black. Two of those literals also disagreed with each other -- the hero subtitle assumed a dark background, _ADV_HEADER used Streamlit's *light* grey -- so one was always low-contrast whatever theme was active, including the shipped dark default. app_css() now derives every colour from the live theme via st.get_option("theme.*"), which reflects the merged config actually in effect rather than just the file. A-18 -- the Settings page resolved config.toml relative to the package, returned silently when it did not exist, and printed "Theme saved" regardless. That silent path was the default for every pip-installed user, since the wheel does not ship .streamlit/. Two fixes: the target is now $CWD/.streamlit/config.toml, which is where Streamlit actually looks and works for both source checkouts and installs; and write_theme raises OSError so the caller reports the truth. Verified end to end through AppTest -- a writable directory gets a real file and a success message naming the path; a failing write gets an actionable error and no success message. A-19 -- the old writer re-serialised the whole file from parsed TOML, destroying every comment (including the NCCU palette documentation), silently dropping non-dict top-level values, and emitting Python repr for lists, which is invalid TOML. write_theme now rewrites only the [theme] key lines, preserves comments inside and outside the table, leaves other sections byte-identical, and writes atomically via tempfile + os.replace so an interrupted save cannot truncate the config. A-29 -- restored the sidebar collapse control, which was CSS-hidden and locked the sidebar open, costing plot canvas width on small screens and at high browser zoom. Added alt text to all four base64 tags; the two NCCU images are institutional branding, exactly the content that needs a text equivalent. A-35 -- dropped the Google Fonts . It failed on an air-gapped lab machine, disclosed a third-party request on every load, and Streamlit does not reliably preserve elements passed through st.markdown, so it may never have loaded at all. The local font stack was already the fallback. A-30 -- Data Tools (434 lines implementing the 12 documented DataFrame ops) had no sidebar link. With st.navigation(position="hidden") the sidebar is the only navigation, so the page was reachable only by typing its URL. A-25 -- "26 plot types" corrected to 27 in eight places. tests/test_docs_ consistency.py derives the expected count from PLOT_TYPES rather than hardcoding it, so adding a plot type and forgetting the docs fails, and updating both passes with no test change. It also caught my own wrong assumption that there were only three plot categories -- there is a fourth, Specialty, holding inset_plot. Note the Settings page imports theming.get_config_path aliased: unaliased it shadowed user_settings.get_config_path and silently repointed the "Config File" section from config.json to config.toml. 1006 passed, 21 skipped. ruff, ruff format and mypy clean. All three edited pages render with zero exceptions under streamlit.testing.v1.AppTest. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013YLgSnkyRUosy5sxWUb33P --- README.md | 6 +- docs/index.html | 8 +- plottle/Home.py | 69 ++------ plottle/pages/13_Help.py | 2 +- plottle/pages/14_Settings.py | 102 +++++------ plottle/utils/theming.py | 312 +++++++++++++++++++++++++++++++++ tests/test_docs_consistency.py | 102 +++++++++++ tests/test_theming.py | 277 +++++++++++++++++++++++++++++ 8 files changed, 755 insertions(+), 123 deletions(-) create mode 100644 plottle/utils/theming.py create mode 100644 tests/test_docs_consistency.py create mode 100644 tests/test_theming.py diff --git a/README.md b/README.md index c91699c..7078311 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Plottle provides a unified interface for scientific data work: - **Data I/O** — 18 file formats (CSV, Excel, TSV, JSON, Parquet, NumPy, Pickle, JCAMP-DX, HDF5, NetCDF, SPC, ASC, mzML/mzXML, and more) - **Mathematical analysis** — statistics, curve fitting, signal processing, peak analysis, hypothesis testing, optimization, linear algebra -- **Multi-library plotting** — 26 plot types across Matplotlib (static), Seaborn (statistical), and Plotly (interactive) +- **Multi-library plotting** — 27 plot types across Matplotlib (static), Seaborn (statistical), and Plotly (interactive) - **14-page Streamlit GUI** — exploratory data analysis without writing code - **CLI** — batch processing and scripted workflows - **Plugin system** — drop `plugin_*.py` into `plugins/` for custom plot types and tools @@ -76,7 +76,7 @@ without installing, use `python -m plottle.cli` instead. | --- | --- | | **Home** | Dashboard overview and help tabs | | **1 — Data Upload** | Upload files in 18 formats; preview shape, column types, and summary statistics; batch folder import | -| **2 — Quick Plot** | 26 plot types with live style controls, annotation panel, and Convert to Plotly toggle | +| **2 — Quick Plot** | 27 plot types with live style controls, annotation panel, and Convert to Plotly toggle | | **3 — Analysis Tools** | 8 tabs: Statistics, Distribution, Curve Fit, Optimization, Linear Algebra, Signal Processing, Peak Analysis, Statistical Tests | | **4 — Multi-Plot Dashboard** | Up to 4×4 grid layouts with axis sharing and combined PNG/PDF export | | **5 — Advanced Plotting** | Seaborn statistical plots and Plotly interactive charts with HTML export | @@ -140,7 +140,7 @@ save_figure(fig, 'spectrum.png', dpi=300) | --- | --- | | `plottle.io` | `load_data()` / `save_data()` — auto-detects format from extension | | `plottle.math` | 25 functions — statistics, curve fitting, hypothesis tests, optimization, linear algebra | -| `plottle.plotting` | 26 plot types; Matplotlib → `(fig, ax, info)`, Plotly → `(fig, info)` | +| `plottle.plotting` | 27 plot types; Matplotlib → `(fig, ax, info)`, Plotly → `(fig, info)` | | `plottle.signal` | 16 functions — smoothing, filtering, FFT, derivatives, baseline correction, interpolation | | `plottle.peaks` | 5 functions — find, integrate, FWHM, fit (Gaussian/Lorentzian/Voigt/pseudo-Voigt) | | `plottle.data_tools` | 12 non-destructive DataFrame operations | diff --git a/docs/index.html b/docs/index.html index 9030297..710269b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,7 +4,7 @@ Plottle — Scientific Data Visualization for Python - + @@ -402,7 +402,7 @@
Schultz Lab · NCCU - 26 plot types + 27 plot types

Scientific data
visualization,
simplified

@@ -791,7 +791,7 @@

Python API

plottle.plotting - 26 plot functions; Matplotlib returns (fig, ax, info), Plotly returns (fig, info) + 27 plot functions; Matplotlib returns (fig, ax, info), Plotly returns (fig, info) plottle.math @@ -862,7 +862,7 @@

14-page Streamlit GUI

2 Quick Plot - 26 plot types, live style controls, annotation panel, Plotly toggle + 27 plot types, live style controls, annotation panel, Plotly toggle 3 diff --git a/plottle/Home.py b/plottle/Home.py index 7cb8f5c..a754999 100644 --- a/plottle/Home.py +++ b/plottle/Home.py @@ -9,7 +9,7 @@ Home Data Upload Plot - Basic — Quick Plot (26 plot types, Matplotlib/Seaborn/Plotly) + Basic — Quick Plot (27 plot types, Matplotlib/Seaborn/Plotly) Multiplot — Multi-Plot Dashboard (grid layouts, axis sharing) Advanced ↳ Advanced Plotting — Seaborn statistical + Plotly interactive @@ -48,6 +48,7 @@ from plottle import __version__ # noqa: E402 from plottle.utils import initialize_session_state, get_session_summary # noqa: E402 +from plottle.utils.theming import app_css # noqa: E402 try: from PIL import Image as _PILImage @@ -73,43 +74,12 @@ }, ) -# ── App-wide font (Nunito — closest free alternative to Avenir) ─────────────── -st.markdown( - """ - - - """, - unsafe_allow_html=True, -) +# ── App-wide styling, derived from the active theme ─────────────────────────── +# Colours come from plottle.utils.theming.app_css() rather than being hardcoded, so +# the stylesheet follows whatever the user selects in Settings. Previously the NCCU +# dark palette was baked in as literals, which left the hero subtitle +# white-on-white on a light theme (A-17). +st.markdown(app_css(), unsafe_allow_html=True) initialize_session_state() @@ -128,7 +98,7 @@ def _home_page() -> None: if _LOGO_PNG.exists(): _b64 = base64.b64encode(_LOGO_PNG.read_bytes()).decode() st.markdown( - f'Plottle logo', unsafe_allow_html=True, ) @@ -138,7 +108,7 @@ def _home_page() -> None: unsafe_allow_html=True, ) st.markdown( - "

" + "

" "Scientific data visualization and analysis " f" ·  v{__version__}" "

", @@ -166,7 +136,7 @@ def _home_page() -> None: st.markdown(""" 1. **Upload Data** — Go to *Data Upload* to load a CSV, Excel, NumPy, or other file. Or load a built-in example dataset. - 2. **Plot** — *Plot → Basic* lets you choose from 26 plot types and configure them + 2. **Plot** — *Plot → Basic* lets you choose from 27 plot types and configure them interactively. 3. **Analyse** — *Analyze → Single* has curve fitting, statistics, signal processing, peak analysis, and more. @@ -295,7 +265,6 @@ def _home_page() -> None: url_path="settings", ) -# Data Tools — included in routing for direct URL access; not shown in main nav datatools_pg = st.Page( str(_PAGES_DIR / "9_Data_Tools.py"), title="Data Tools", @@ -330,22 +299,13 @@ def _home_page() -> None: # ── Custom sidebar navigation ───────────────────────────────────────────────── -_ADV_HEADER = ( - "

Advanced

" -) +_ADV_HEADER = "" with st.sidebar: if _LOGO_PNG.exists(): _sb_logo_b64 = base64.b64encode(_LOGO_PNG.read_bytes()).decode() st.markdown( - f'Plottle logo', unsafe_allow_html=True, ) @@ -363,6 +323,7 @@ def _home_page() -> None: with st.expander("**Analyze**", expanded=pg in _ANALYZE_PAGES): st.page_link(single_pg, label="Single") st.page_link(batch_pg, label="Batch") + st.page_link(datatools_pg, label="Data Tools") st.divider() st.page_link(export_pg, label="Export") @@ -376,6 +337,7 @@ def _home_page() -> None: _horiz_b64 = base64.b64encode(_NCCU_HORIZ.read_bytes()).decode() st.markdown( f'North Carolina Central University', unsafe_allow_html=True, ) @@ -383,6 +345,7 @@ def _home_page() -> None: _wings_b64 = base64.b64encode(_NCCU_WINGS.read_bytes()).decode() st.markdown( f'NCCU Eagle Wings emblem', unsafe_allow_html=True, ) diff --git a/plottle/pages/13_Help.py b/plottle/pages/13_Help.py index 4711ada..9e78f59 100644 --- a/plottle/pages/13_Help.py +++ b/plottle/pages/13_Help.py @@ -22,7 +22,7 @@ ## Workflow 1. **Upload Data** — Go to *Data Upload* and upload a file, or load a built-in example dataset. - 2. **Quick Plot** — Choose from 26 plot types, configure parameters interactively + 2. **Quick Plot** — Choose from 27 plot types, configure parameters interactively in the sidebar. 3. **Analysis Tools** — Statistical tests, curve fitting, signal processing, peak analysis, and more. diff --git a/plottle/pages/14_Settings.py b/plottle/pages/14_Settings.py index 4c91db6..437a1ca 100644 --- a/plottle/pages/14_Settings.py +++ b/plottle/pages/14_Settings.py @@ -15,14 +15,6 @@ from pathlib import Path import sys -# `tomllib` entered the stdlib in Python 3.11, but Plottle supports 3.9+. -# Without this fallback the whole page raises ModuleNotFoundError on 3.9/3.10 -# while every other page works — see G-013 / audit A-04. -try: - import tomllib -except ModuleNotFoundError: # Python 3.9 / 3.10 - import tomli as tomllib - import streamlit as st sys.path.insert(0, str(Path(__file__).parent.parent.parent)) @@ -38,6 +30,16 @@ delete_preset, ) from plottle.utils.plot_config import COLOR_PALETTE_NAMES, _FONT_OPTIONS +from plottle.utils.theming import ( + STREAMLIT_DEFAULTS, + read_theme, + write_theme, +) + +# Aliased: this page shows two different config files, and an unaliased import +# would shadow user_settings.get_config_path() (config.json) with the theming +# one (config.toml), silently repointing the "Config File" section below. +from plottle.utils.theming import get_config_path as get_theme_config_path initialize_session_state() @@ -52,61 +54,17 @@ # Section 0 — Theme # ══════════════════════════════════════════════════════════════════════════════ -_STREAMLIT_CONFIG = Path(__file__).parent.parent.parent / ".streamlit" / "config.toml" - -_THEME_DEFAULTS = { - "base": "light", - "primaryColor": "#1f77b4", - "backgroundColor": "#ffffff", - "secondaryBackgroundColor": "#f0f2f6", - "textColor": "#262730", -} - - -def _read_theme() -> dict: - """Read the [theme] section from config.toml.""" - if not _STREAMLIT_CONFIG.exists(): - return dict(_THEME_DEFAULTS) - try: - with open(_STREAMLIT_CONFIG, "rb") as fh: - data = tomllib.load(fh) - t = data.get("theme", {}) - return {**_THEME_DEFAULTS, **t} - except Exception: - return dict(_THEME_DEFAULTS) - - -def _write_theme(theme: dict) -> None: - """Write the [theme] section to config.toml, preserving other sections.""" - if not _STREAMLIT_CONFIG.exists(): - return - try: - with open(_STREAMLIT_CONFIG, "rb") as fh: - data = tomllib.load(fh) - except Exception: - data = {} - data["theme"] = theme - lines = ["# Plottle — Streamlit configuration\n"] - for section, values in data.items(): - lines.append(f"\n[{section}]\n") - if isinstance(values, dict): - for k, v in values.items(): - if isinstance(v, str): - lines.append(f'{k} = "{v}"\n') - elif isinstance(v, bool): - lines.append(f"{k} = {'true' if v else 'false'}\n") - else: - lines.append(f"{k} = {v}\n") - _STREAMLIT_CONFIG.write_text("".join(lines), encoding="utf-8") - - st.markdown("## Theme") st.markdown( "Choose a base theme and optionally customize colours. " "Click **Save theme** then **restart the app** for changes to take effect." ) +st.caption( + f"Theme is saved to `{get_theme_config_path()}` — the location Streamlit reads when " + "launched from this directory." +) -_cur = _read_theme() +_cur = read_theme() with st.form("theme_form"): _tc1, _tc2 = st.columns(2) @@ -141,21 +99,41 @@ def _write_theme(theme: dict) -> None: _theme_submitted = st.form_submit_button("Save theme", type="primary") + +def _save_theme(theme: dict, success_message: str) -> None: + """Write a theme and report what actually happened. + + The previous implementation returned silently when config.toml was missing -- + the default for every pip-installed user -- while still printing a success + message. See audit A-18. + """ + try: + written = write_theme(theme) + except OSError as exc: + st.error( + f"Could not write the theme: {exc}\n\n" + f"Plottle tried to write `{get_theme_config_path()}`. If that directory is " + "read-only, launch Plottle from a directory you can write to, or create " + "`.streamlit/config.toml` there yourself." + ) + return + st.success(f"{success_message} Saved to `{written}` — restart the app to apply.") + + if _theme_submitted: - _write_theme( + _save_theme( { "base": _base, "primaryColor": _primary, "backgroundColor": _bg, "secondaryBackgroundColor": _sbg, "textColor": _text, - } + }, + "Theme saved.", ) - st.success("Theme saved to .streamlit/config.toml. Restart the app to apply.") if st.button("Reset theme to Streamlit default"): - _write_theme({"base": "light"}) - st.success("Theme reset. Restart the app to apply.") + _save_theme(dict(STREAMLIT_DEFAULTS), "Theme reset to Streamlit defaults.") st.markdown("---") diff --git a/plottle/utils/theming.py b/plottle/utils/theming.py new file mode 100644 index 0000000..92e0459 --- /dev/null +++ b/plottle/utils/theming.py @@ -0,0 +1,312 @@ +"""Theme reading, writing, and CSS generation for the Plottle GUI. + +Why this module exists +---------------------- +Three related defects motivated it (audit A-17, A-18, A-19): + +- **A-17** — ``Home.py`` injected a stylesheet with the NCCU dark palette baked in + as literals, while the Settings page let the user change the theme. Nothing + connected the two, so on a light theme the hero subtitle was white-on-white and + the scrollbar stayed black. Worse, two of those literals disagreed with each + other, so one was always low-contrast *whatever* theme was active. + :func:`app_css` now derives every colour from the live theme. + +- **A-18** — the Settings page wrote to ``.streamlit/config.toml`` resolved relative + to the package, returned silently when the file did not exist, and reported + "Theme saved" regardless. That silent path was the default for every + pip-installed user, since the wheel does not ship ``.streamlit/``. + :func:`write_theme` targets the location Streamlit actually reads, creates it + when missing, and raises on failure so the caller can tell the truth. + +- **A-19** — the old writer re-serialised the whole file from parsed TOML, which + destroyed every comment, dropped non-dict top-level values, and emitted Python + ``repr`` for lists (invalid TOML). :func:`write_theme` rewrites only the + ``[theme]`` keys and leaves the rest of the file byte-identical, writing + atomically so an interrupted save cannot truncate the config. + +Config file location +-------------------- +Streamlit reads ``$CWD/.streamlit/config.toml`` and ``~/.streamlit/config.toml``. +It does *not* look inside the installed package, so the project-level path +relative to the working directory is the only target that works both from a source +checkout and from a ``pip install``. +""" + +from __future__ import annotations + +import os +import re +import tempfile +from pathlib import Path +from typing import Dict, List, Optional + +# tomllib is stdlib from Python 3.11; Plottle supports 3.9+ (G-013). +try: + import tomllib +except ModuleNotFoundError: # Python 3.9 / 3.10 + import tomli as tomllib # type: ignore[no-redef] + +__all__ = [ + "THEME_KEYS", + "STREAMLIT_DEFAULTS", + "get_config_path", + "read_theme", + "write_theme", + "active_theme", + "app_css", +] + +THEME_KEYS: List[str] = [ + "base", + "primaryColor", + "backgroundColor", + "secondaryBackgroundColor", + "textColor", +] + +#: Streamlit's own light-theme defaults, used when nothing else is available. +STREAMLIT_DEFAULTS: Dict[str, str] = { + "base": "light", + "primaryColor": "#1f77b4", + "backgroundColor": "#ffffff", + "secondaryBackgroundColor": "#f0f2f6", + "textColor": "#262730", +} + +_KEY_LINE = re.compile(r"^\s*[A-Za-z_][A-Za-z0-9_]*\s*=") +_SECTION_LINE = re.compile(r"^\s*\[") + + +def get_config_path() -> Path: + """Return the ``config.toml`` path Streamlit will actually read. + + Returns + ------- + Path + ``/.streamlit/config.toml``. Resolved against the working directory + rather than the package, because Streamlit does not read config from + inside an installed package -- see the module docstring. + """ + return Path.cwd() / ".streamlit" / "config.toml" + + +def read_theme() -> Dict[str, str]: + """Read the ``[theme]`` table, falling back to the live theme then defaults. + + Returns + ------- + dict + All five :data:`THEME_KEYS`, always populated. + """ + theme = dict(STREAMLIT_DEFAULTS) + theme.update(active_theme()) + + path = get_config_path() + if path.is_file(): + try: + with open(path, "rb") as fh: + stored = tomllib.load(fh).get("theme", {}) + theme.update({k: str(v) for k, v in stored.items() if k in THEME_KEYS}) + except (OSError, tomllib.TOMLDecodeError): + pass + return theme + + +def active_theme() -> Dict[str, str]: + """Return the theme Streamlit is currently running with. + + Reads ``st.get_option("theme.*")``, which reflects the merged config actually + in effect -- so injected CSS matches what the user sees even when the config + file is absent, unreadable, or overridden by a CLI flag or environment + variable. + + Returns + ------- + dict + Subset of :data:`THEME_KEYS` that Streamlit reports a value for. Empty if + Streamlit is unavailable (so this module stays importable in tests). + """ + try: + import streamlit as st + except ImportError: # pragma: no cover - streamlit is a hard dependency + return {} + + resolved: Dict[str, str] = {} + for key in THEME_KEYS: + try: + value = st.get_option(f"theme.{key}") + except Exception: + continue + if value: + resolved[key] = str(value) + return resolved + + +def _render_theme_body(theme: Dict[str, str]) -> List[str]: + """Render the ``[theme]`` key lines, in :data:`THEME_KEYS` order.""" + return [f'{key} = "{theme[key]}"\n' for key in THEME_KEYS if theme.get(key)] + + +def write_theme(theme: Dict[str, str]) -> Path: + """Rewrite only the ``[theme]`` keys in ``config.toml``. + + Everything outside the ``[theme]`` table -- other sections, and every comment + including the file header -- is preserved byte-for-byte. Comments *inside* the + table are kept too; only ``key = value`` lines are replaced. The write is + atomic, so an interrupted save cannot leave a truncated config. + + Parameters + ---------- + theme : dict + Values for :data:`THEME_KEYS`. Keys outside that set are ignored; keys + with a falsy value are omitted from the output. + + Returns + ------- + Path + The file that was written, for display to the user. + + Raises + ------ + OSError + If the file or its parent directory cannot be created or written. Callers + must surface this rather than reporting success -- see A-18. + """ + path = get_config_path() + existing: List[str] = [] + if path.is_file(): + existing = path.read_text(encoding="utf-8").splitlines(keepends=True) + + out: List[str] = [] + index = 0 + replaced = False + + while index < len(existing): + line = existing[index] + if line.strip() != "[theme]": + out.append(line) + index += 1 + continue + + # Found the table: emit the header, then the new keys, then walk the old + # body dropping only `key = value` lines so comments and blanks survive. + out.append(line) + out.extend(_render_theme_body(theme)) + replaced = True + index += 1 + while index < len(existing) and not _SECTION_LINE.match(existing[index]): + if not _KEY_LINE.match(existing[index]): + out.append(existing[index]) + index += 1 + + if not replaced: + if out and not out[-1].endswith("\n"): + out.append("\n") + out.append("\n[theme]\n") + out.extend(_render_theme_body(theme)) + + path.parent.mkdir(parents=True, exist_ok=True) + + # Atomic replace: write a sibling temp file, flush, then rename over the target. + fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=".config-", suffix=".toml") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write("".join(out)) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp_name, path) + except BaseException: + Path(tmp_name).unlink(missing_ok=True) + raise + return path + + +def _hex_to_rgba(colour: str, alpha: float) -> str: + """Convert ``#rrggbb`` (or ``#rgb``) to a CSS ``rgba()`` string. + + Falls back to a mid grey for anything unparseable, so a malformed colour in + config.toml degrades to readable rather than breaking the stylesheet. + """ + text = colour.strip().lstrip("#") + if len(text) == 3: + text = "".join(ch * 2 for ch in text) + try: + red, green, blue = (int(text[i : i + 2], 16) for i in (0, 2, 4)) + except (ValueError, IndexError): + red, green, blue = (128, 128, 128) + return f"rgba({red}, {green}, {blue}, {alpha})" + + +def app_css(theme: Optional[Dict[str, str]] = None) -> str: + """Build the app-wide stylesheet from the active theme. + + Every colour is derived from *theme*, so the injected CSS follows whatever the + user selected in Settings instead of assuming the NCCU dark palette (A-17). + + Parameters + ---------- + theme : dict, optional + Theme mapping; defaults to :func:`read_theme`. + + Returns + ------- + str + A `` + """ diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py new file mode 100644 index 0000000..57039f5 --- /dev/null +++ b/tests/test_docs_consistency.py @@ -0,0 +1,102 @@ +"""Guards against documented counts drifting away from the code. + +Audit finding A-25: "26 plot types" was stated in eight places across +``README.md``, ``docs/index.html``, ``Home.py`` and the Help page while +``PLOT_TYPES`` actually held 27 entries. ``inset_plot`` had been added without +anyone updating the prose, and nothing could catch it. + +Rather than assert a hardcoded 27 -- which would just move the drift one level +out -- these tests derive the number from ``PLOT_TYPES`` and check that the +documentation agrees with it. Adding a plot type and forgetting the docs fails +here; adding one and updating the docs passes with no test change. +""" + +import re +from pathlib import Path + +import pytest + +from plottle.utils.plot_config import PLOT_TYPES + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_PACKAGE_ROOT = _REPO_ROOT / "plottle" + +# Files that state the plot-type count in prose, and must therefore stay in step +# with PLOT_TYPES. +_FILES_STATING_THE_COUNT = [ + _REPO_ROOT / "README.md", + _REPO_ROOT / "docs" / "index.html", + _PACKAGE_ROOT / "Home.py", + _PACKAGE_ROOT / "pages" / "13_Help.py", +] + + +class TestPlotTypeCatalogue: + def test_every_plot_type_resolves_to_a_callable(self): + """A PLOT_TYPES key with no matching function is a dead GUI menu entry.""" + import plottle.plotting as plotting + + missing = sorted(k for k in PLOT_TYPES if not callable(getattr(plotting, k, None))) + assert not missing, ( + f"PLOT_TYPES lists entries with no callable in plottle.plotting: {missing}" + ) + + def test_every_entry_has_a_label_and_category(self): + malformed = sorted( + key + for key, meta in PLOT_TYPES.items() + if not meta.get("label") or not meta.get("category") + ) + assert not malformed, f"PLOT_TYPES entries missing label/category: {malformed}" + + def test_categories_are_known(self): + # Matplotlib 15, Seaborn 4, Plotly 7, Specialty 1 as of 2.0.1. `Specialty` + # holds inset_plot, which composes two axes rather than wrapping a single + # library call, so it does not belong under a library heading. + allowed = {"Matplotlib", "Seaborn", "Plotly", "Specialty"} + unexpected = { + meta["category"] for meta in PLOT_TYPES.values() if meta["category"] not in allowed + } + assert not unexpected, ( + f"unexpected plot categories: {sorted(unexpected)}. The Quick Plot picker " + "groups by category, so a new one needs a deliberate home in the UI." + ) + + +class TestDocumentedPlotCountMatchesCode: + """Regression guard for A-25.""" + + @pytest.mark.parametrize( + "path", _FILES_STATING_THE_COUNT, ids=lambda p: str(p.relative_to(_REPO_ROOT)) + ) + def test_no_stale_plot_count(self, path: Path): + expected = len(PLOT_TYPES) + text = path.read_text(encoding="utf-8") + + # Any " plot types" / " plot functions" claim must use the real number. + stated = { + int(n) for n in re.findall(r"(\d+)\s+plot\s+(?:types|functions)\b", text) + } + wrong = sorted(n for n in stated if n != expected) + assert not wrong, ( + f"{path.relative_to(_REPO_ROOT)} claims {wrong} plot types but PLOT_TYPES " + f"has {expected}. Update the prose, or the catalogue." + ) + + def test_at_least_one_file_actually_states_the_count(self): + """Keeps the parametrized test honest if the wording ever changes. + + Without this, rewording every mention to something the regex misses would + leave the guard above vacuously green. + """ + expected = len(PLOT_TYPES) + found = [ + path.relative_to(_REPO_ROOT) + for path in _FILES_STATING_THE_COUNT + if re.search(rf"{expected}\s+plot\s+(?:types|functions)\b", path.read_text("utf-8")) + ] + assert found, ( + f"no documentation file states '{expected} plot types' -- either the " + "wording changed (update _FILES_STATING_THE_COUNT and the regex) or the " + "count is stale everywhere." + ) diff --git a/tests/test_theming.py b/tests/test_theming.py new file mode 100644 index 0000000..8a1247d --- /dev/null +++ b/tests/test_theming.py @@ -0,0 +1,277 @@ +"""Tests for plottle.utils.theming — audit A-17, A-18, A-19.""" + +from pathlib import Path + +import pytest + +from plottle.utils import theming +from plottle.utils.theming import ( + STREAMLIT_DEFAULTS, + THEME_KEYS, + _hex_to_rgba, + app_css, + get_config_path, + read_theme, + write_theme, +) + + +@pytest.fixture +def in_tmp_cwd(tmp_path, monkeypatch): + """Run with cwd pointed at a temp dir, so config writes are isolated.""" + monkeypatch.chdir(tmp_path) + return tmp_path + + +_SHIPPED_CONFIG = """\ +# Plottle — Streamlit configuration + +# ── NCCU theme ──────────────────────────────────────────────────────────────── +# primaryColor: soft rose — accent, tab underlines, active states +[theme] +base = "dark" +primaryColor = "#e0a3a3" +backgroundColor = "#1b1b1b" +secondaryBackgroundColor = "#240000" +textColor = "#ffffff" + +[server] +maxUploadSize = 200 +enableCORS = true +enableXsrfProtection = true + +[runner] +fastReruns = true +""" + + +class TestConfigPath: + def test_path_is_relative_to_cwd_not_the_package(self, in_tmp_cwd): + """Streamlit reads $CWD/.streamlit/config.toml, never the installed package. + + Resolving this against the package was why a pip-installed user's theme + save silently did nothing (A-18). + """ + assert get_config_path() == in_tmp_cwd / ".streamlit" / "config.toml" + assert "site-packages" not in str(get_config_path()) + + +class TestWriteTheme: + def test_creates_file_and_directory_when_absent(self, in_tmp_cwd): + written = write_theme({"base": "light", "primaryColor": "#123456"}) + assert written.is_file() + text = written.read_text(encoding="utf-8") + assert "[theme]" in text + assert 'base = "light"' in text + assert 'primaryColor = "#123456"' in text + + def test_roundtrips_through_read_theme(self, in_tmp_cwd): + theme = { + "base": "dark", + "primaryColor": "#aabbcc", + "backgroundColor": "#111111", + "secondaryBackgroundColor": "#222222", + "textColor": "#eeeeee", + } + write_theme(theme) + restored = read_theme() + for key, value in theme.items(): + assert restored[key] == value + + def test_preserves_comments_and_other_sections(self, in_tmp_cwd): + """A-19: the old writer re-serialised the file and destroyed every comment.""" + cfg = in_tmp_cwd / ".streamlit" / "config.toml" + cfg.parent.mkdir(parents=True) + cfg.write_text(_SHIPPED_CONFIG, encoding="utf-8") + + write_theme({**STREAMLIT_DEFAULTS, "primaryColor": "#ff0000"}) + text = cfg.read_text(encoding="utf-8") + + # Header and section comments survive. + assert "# Plottle — Streamlit configuration" in text + assert "# ── NCCU theme" in text + assert "soft rose" in text + # Other sections survive verbatim, values included. + assert "[server]" in text + assert "maxUploadSize = 200" in text + assert "enableXsrfProtection = true" in text + assert "[runner]" in text + assert "fastReruns = true" in text + # And the theme actually changed. + assert 'primaryColor = "#ff0000"' in text + assert "#e0a3a3" not in text + + def test_does_not_duplicate_the_theme_table(self, in_tmp_cwd): + cfg = in_tmp_cwd / ".streamlit" / "config.toml" + cfg.parent.mkdir(parents=True) + cfg.write_text(_SHIPPED_CONFIG, encoding="utf-8") + write_theme(dict(STREAMLIT_DEFAULTS)) + write_theme(dict(STREAMLIT_DEFAULTS)) + text = cfg.read_text(encoding="utf-8") + assert text.count("[theme]") == 1 + for key in THEME_KEYS: + assert text.count(f"{key} = ") == 1, f"{key} written more than once" + + def test_appends_theme_table_when_config_has_none(self, in_tmp_cwd): + cfg = in_tmp_cwd / ".streamlit" / "config.toml" + cfg.parent.mkdir(parents=True) + cfg.write_text("[server]\nmaxUploadSize = 50\n", encoding="utf-8") + write_theme({"base": "dark"}) + text = cfg.read_text(encoding="utf-8") + assert "maxUploadSize = 50" in text + assert "[theme]" in text + assert 'base = "dark"' in text + + def test_output_is_valid_toml(self, in_tmp_cwd): + cfg = in_tmp_cwd / ".streamlit" / "config.toml" + cfg.parent.mkdir(parents=True) + cfg.write_text(_SHIPPED_CONFIG, encoding="utf-8") + write_theme({**STREAMLIT_DEFAULTS, "base": "dark"}) + with open(cfg, "rb") as fh: + parsed = theming.tomllib.load(fh) + assert parsed["theme"]["base"] == "dark" + assert parsed["server"]["maxUploadSize"] == 200 + + def test_list_valued_option_elsewhere_survives(self, in_tmp_cwd): + """The old hand-rolled serialiser emitted Python repr for lists, which is + invalid TOML. Only the [theme] table is touched now, so this holds.""" + cfg = in_tmp_cwd / ".streamlit" / "config.toml" + cfg.parent.mkdir(parents=True) + cfg.write_text( + '[server]\nfolderWatchBlacklist = ["a", "b"]\n\n[theme]\nbase = "dark"\n', + encoding="utf-8", + ) + write_theme({"base": "light"}) + with open(cfg, "rb") as fh: + parsed = theming.tomllib.load(fh) + assert parsed["server"]["folderWatchBlacklist"] == ["a", "b"] + assert parsed["theme"]["base"] == "light" + + def test_raises_oserror_when_target_is_unwritable(self, in_tmp_cwd, monkeypatch): + """A-18: failures must raise so the caller does not report success.""" + + def _boom(*args, **kwargs): + raise OSError("read-only file system") + + monkeypatch.setattr(theming.os, "replace", _boom) + with pytest.raises(OSError): + write_theme(dict(STREAMLIT_DEFAULTS)) + + def test_no_temp_files_left_behind_on_failure(self, in_tmp_cwd, monkeypatch): + def _boom(*args, **kwargs): + raise OSError("nope") + + monkeypatch.setattr(theming.os, "replace", _boom) + with pytest.raises(OSError): + write_theme(dict(STREAMLIT_DEFAULTS)) + leftovers = list((in_tmp_cwd / ".streamlit").glob(".config-*")) + assert not leftovers, f"temp files left behind: {leftovers}" + + +class TestReadTheme: + def test_returns_all_keys_when_no_config_exists(self, in_tmp_cwd): + theme = read_theme() + assert set(THEME_KEYS) <= set(theme) + assert all(theme[k] for k in THEME_KEYS) + + def test_corrupt_config_falls_back_instead_of_raising(self, in_tmp_cwd): + cfg = in_tmp_cwd / ".streamlit" / "config.toml" + cfg.parent.mkdir(parents=True) + cfg.write_text("this is not valid toml }{", encoding="utf-8") + theme = read_theme() + assert all(theme[k] for k in THEME_KEYS) + + +class TestHexToRgba: + @pytest.mark.parametrize( + "colour,expected", + [ + ("#ffffff", "rgba(255, 255, 255, 0.5)"), + ("ffffff", "rgba(255, 255, 255, 0.5)"), + ("#000000", "rgba(0, 0, 0, 0.5)"), + ("#fff", "rgba(255, 255, 255, 0.5)"), + ("#1b1b1b", "rgba(27, 27, 27, 0.5)"), + ], + ) + def test_parses_hex_forms(self, colour, expected): + assert _hex_to_rgba(colour, 0.5) == expected + + @pytest.mark.parametrize("colour", ["", "not-a-colour", "#12", "#gggggg"]) + def test_degrades_to_grey_rather_than_raising(self, colour): + # A malformed colour in config.toml should not break the whole stylesheet. + assert _hex_to_rgba(colour, 0.4) == "rgba(128, 128, 128, 0.4)" + + +class TestAppCss: + def test_uses_the_supplied_theme_colours(self): + css = app_css( + { + "base": "light", + "primaryColor": "#ff0000", + "backgroundColor": "#00ff00", + "secondaryBackgroundColor": "#0000ff", + "textColor": "#123456", + } + ) + assert "#ff0000" in css + assert "#00ff00" in css + assert "#0000ff" in css + assert "rgba(18, 52, 86" in css # textColor, as muted rgba + + def test_no_hardcoded_dark_palette_literals(self): + """A-17: the old CSS baked in the NCCU dark palette regardless of theme.""" + css = app_css(dict(STREAMLIT_DEFAULTS)) + for literal in ("#e0a3a3", "#1b1b1b", "#5a0010", "#240000"): + assert literal not in css, f"{literal} is hardcoded in app_css output" + assert "rgba(255,255,255,0.55)" not in css + assert "rgba(49,51,63,0.45)" not in css + + def test_does_not_hide_the_sidebar_collapse_control(self): + """A-29: hiding it locked the sidebar open, costing canvas width.""" + css = app_css(dict(STREAMLIT_DEFAULTS)) + assert "stSidebarCollapseButton" not in css + assert "collapsedControl" not in css + + def test_fetches_no_external_resources(self): + """A-35: the Google Fonts broke offline and disclosed a request.""" + css = app_css(dict(STREAMLIT_DEFAULTS)) + for token in ("http://", "https://", "@import", "fonts.googleapis"): + assert token not in css, f"app_css reaches out to {token}" + + def test_missing_keys_fall_back_to_defaults(self): + css = app_css({"base": "light"}) + assert STREAMLIT_DEFAULTS["primaryColor"] in css + + +class TestHomePageUsesThemedCss: + """A-17 end-to-end: Home.py must not reintroduce hardcoded colours.""" + + @pytest.fixture(scope="class") + def home_source(self) -> str: + import plottle + + return (Path(plottle.__file__).resolve().parent / "Home.py").read_text("utf-8") + + def test_home_delegates_styling_to_app_css(self, home_source: str): + assert "app_css()" in home_source + + def test_home_has_no_hardcoded_theme_colours(self, home_source: str): + for literal in ("#e0a3a3", "#1b1b1b", "#5a0010", "rgba(255,255,255,0.55)"): + assert literal not in home_source, f"{literal} is back in Home.py" + + def test_home_does_not_load_external_fonts(self, home_source: str): + assert "fonts.googleapis.com" not in home_source + + def test_every_image_has_alt_text(self, home_source: str): + """A-29: four base64 tags carried no alt attribute.""" + img_count = home_source.count(")
+        assert img_count > 0
+        assert alt_count >= img_count, (
+            f tags but only {alt_count} alt attributes" + ) + + def test_data_tools_is_reachable_from_the_nav(self, home_source: str): + """A-30: the page was routable by URL but had no sidebar link, and + st.navigation(position="hidden") makes the sidebar the only navigation.""" + assert "st.page_link(datatools_pg" in home_source From d7a1a680913a04c75a374eab4492d3883d4b6550 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 17:16:54 +0000 Subject: [PATCH 2/2] Replace the unbuilt Sphinx setup with a working docs site (A-13) Per the user's decision: drop Sphinx, wire up index.html. docs/ held two disconnected systems. A full Sphinx setup -- conf.py, index.rst, api/*.rst, Makefile, furo, a `docs` extra -- that no workflow ever built, and a hand-written index.html. docs.yml uploaded ./docs/ raw, so the published site was one marketing page: the .rst sources shipped as dead files, the API reference was never generated at all, and the seven markdown guides shipped as raw .md that browsers download rather than render. index.html linked to none of them -- five in-page anchors and four GitHub links, nothing into docs/. Sphinx removed (conf.py, index.rst, api/, Makefile, _static/, _templates/), and the docs extra now pins `markdown` instead of sphinx+furo. New build_docs.py renders each guide into a page styled to match index.html, with a shared nav, breadcrumb, and footer. It rewrites cross-references from `tutorials/cli_guide.md` to the built .html, and wraps every table in an overflow-x container so a wide reference table cannot make the page scroll sideways. docs.yml installs markdown, runs the build, and uploads docs/_site/. .nojekyll moved into docs/ where it is actually part of the artifact. index.html gains a Documentation section with a card per guide plus a nav link, so every page is reachable from the landing page. Rendering the site in Chromium caught what grep had not: the guides still taught the old namespace. getting_started.md and cheatsheet.md were full of `from modules.io import ...`, which has not existed since the rename -- the A-24 problem, still live in the guides after the README was fixed. Also stale and now corrected in getting_started.md: - install step said `pip install -r requirements.txt`, which no longer installs the package or the `plottle` command - "GUI Pages at a Glance" listed six pages ending at "6 - Settings", left over from the pre-M21 structure that TDEC-001 flagged for the README in March - Next Steps pointed at notebooks/ (excluded per TDEC-007, does not exist), DEPLOYMENT.md (excluded per G-007, does not exist), and "the course discussion board", which does not belong in a public repo Navigation verified by clicking through in Chromium: nav links, logo, and all six landing-page cards resolve, no page overflows horizontally at 1280px or 390px, and no JS errors. Also in this commit: - A-25 finished. My earlier sed was case-sensitive and missed "26 Plot Types" in an index.html card heading and a Help page header. The guard test was case-sensitive too, so it had been passing vacuously over both -- now IGNORECASE, and it fails on either. - A-27: removed the dead [tool.pytest.ini_options] block. pytest.ini takes precedence, so it was inert, and it declared a `visualization` marker that pytest.ini does not -- under --strict-markers anyone following it would error. - tests/test_docs_site.py: 17 tests. The most useful asserts that every markdown guide under docs/ is either in build_docs.PAGES or under docs/archive/, so a guide added later cannot ship unreachable the way these seven did. - ruff --fix removed genuinely unused imports from four test files. Not done here: the remaining 30 ruff findings in tests/ and examples/ (A-28). That wants to be its own mechanical commit alongside widening the CI lint scope. 1023 passed, 21 skipped. ruff, ruff format and mypy clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013YLgSnkyRUosy5sxWUb33P --- .github/workflows/docs.yml | 20 +- .gitignore | 3 + build_docs.py | 334 +++++++++++++++++++++++++++++++++ .nojekyll => docs/.nojekyll | 0 docs/Makefile | 18 -- docs/_static/.gitkeep | 1 - docs/_templates/.gitkeep | 1 - docs/api/io.rst | 13 -- docs/api/math.rst | 10 - docs/api/plotting.rst | 15 -- docs/cheatsheet.md | 24 +-- docs/conf.py | 89 --------- docs/getting_started.md | 58 +++--- docs/index.html | 78 +++++++- docs/index.rst | 23 --- docs/tutorials/gui_guide.md | 2 +- plottle/pages/13_Help.py | 2 +- pyproject.toml | 13 +- tests/test_batch.py | 1 - tests/test_docs_consistency.py | 8 +- tests/test_docs_site.py | 181 ++++++++++++++++++ tests/test_integration.py | 2 - tests/test_plugin_loader.py | 1 - tests/test_theming.py | 4 +- tests/test_utils.py | 3 - 25 files changed, 673 insertions(+), 231 deletions(-) create mode 100644 build_docs.py rename .nojekyll => docs/.nojekyll (100%) delete mode 100644 docs/Makefile delete mode 100644 docs/_static/.gitkeep delete mode 100644 docs/_templates/.gitkeep delete mode 100644 docs/api/io.rst delete mode 100644 docs/api/math.rst delete mode 100644 docs/api/plotting.rst delete mode 100644 docs/conf.py delete mode 100644 docs/index.rst create mode 100644 tests/test_docs_site.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 116407a..7a19269 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - "docs/**" + - "build_docs.py" - ".github/workflows/docs.yml" workflow_dispatch: @@ -25,13 +26,30 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Set up Python 3.11 + uses: actions/setup-python@v6 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install docs build dependencies + run: pip install markdown + + # Previously this workflow uploaded ./docs/ raw, which published the markdown + # sources as downloads and the Sphinx sources as dead files, while the API + # reference was never built at all. build_docs.py renders each guide into a + # styled page and wires them together. See audit A-13. + - name: Build documentation site + run: python build_docs.py + - name: Configure Pages uses: actions/configure-pages@v6 - name: Upload Pages artifact uses: actions/upload-pages-artifact@v5 with: - path: ./docs/ + path: ./docs/_site/ deploy: name: Deploy to GitHub Pages diff --git a/.gitignore b/.gitignore index 38cdd99..787a9e9 100644 --- a/.gitignore +++ b/.gitignore @@ -190,3 +190,6 @@ CLAUDE.md # Plottle — generated test artifacts (should never be committed) tests/*.png tests/*.json + +# Generated documentation site (build_docs.py) +docs/_site/ diff --git a/build_docs.py b/build_docs.py new file mode 100644 index 0000000..95d7968 --- /dev/null +++ b/build_docs.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Build the static documentation site into ``docs/_site/``. + +Plottle's docs used to contain two disconnected systems: a full Sphinx setup that +CI never built, and a hand-written ``docs/index.html`` that linked to none of the +markdown guides. The published site was therefore a single marketing page, with +seven markdown files and an entire API reference deployed but unreachable except +by guessing URLs — and ``.md`` served raw downloads rather than renders +(audit A-13). + +Sphinx was dropped in favour of this script, which renders each markdown guide +into a page styled to match ``index.html`` and wires them together with a shared +nav. Run it locally to preview, or let ``.github/workflows/docs.yml`` run it. + +Usage +----- + pip install markdown + python build_docs.py + # then open docs/_site/index.html + +Output +------ +``docs/_site/`` — ``index.html`` (copied verbatim), one HTML page per guide, and +``docs.css``. The directory is gitignored and is what the Pages workflow uploads. +""" + +from __future__ import annotations + +import re +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import List + +_ROOT = Path(__file__).resolve().parent +_DOCS = _ROOT / "docs" +_SITE = _DOCS / "_site" + +REPO_URL = "https://github.com/The-Schultz-Lab/plottle" + + +@dataclass(frozen=True) +class Page: + """One markdown guide and where it lands in the built site.""" + + source: Path + slug: str + title: str + blurb: str + + @property + def output_name(self) -> str: + return f"{self.slug}.html" + + +# Ordered — this is also the nav order and the order of the cards on the landing +# page. `docs/archive/` is deliberately excluded: it is superseded internal notes, +# not user documentation. +PAGES: List[Page] = [ + Page( + _DOCS / "getting_started.md", + "getting-started", + "Getting Started", + "Install Plottle, load your first dataset, and make your first plot.", + ), + Page( + _DOCS / "tutorials" / "gui_guide.md", + "gui-guide", + "GUI Guide", + "A tour of all 14 pages of the Streamlit interface.", + ), + Page( + _DOCS / "tutorials" / "cli_guide.md", + "cli-guide", + "CLI Guide", + "The five `plottle` subcommands, with worked examples.", + ), + Page( + _DOCS / "cheatsheet.md", + "cheatsheet", + "Cheatsheet", + "Quick reference for the Python API — every module, at a glance.", + ), + Page( + _DOCS / "bug-reports.md", + "bug-reports", + "Reporting Bugs", + "What to include so a problem can be reproduced and fixed.", + ), + Page( + _DOCS / "feature_requests.md", + "feature-requests", + "Feature Requests", + "How to propose new functionality.", + ), +] + +_CSS = """\ +/* Documentation pages — palette matched to index.html. */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { font-size: 16px; scroll-behavior: smooth; } +body { + font-family: Inter, 'Segoe UI', -apple-system, BlinkMacSystemFont, + 'Helvetica Neue', Arial, sans-serif; + background: #ffffff; color: #334155; line-height: 1.7; + -webkit-font-smoothing: antialiased; +} +img, svg { display: block; max-width: 100%; } + +.nav { + position: sticky; top: 0; z-index: 100; + background: rgba(15, 23, 42, 0.92); + backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); + border-bottom: 1px solid rgba(255,255,255,0.06); +} +.nav__inner { + max-width: 1160px; margin: 0 auto; padding: 0.85rem 1.5rem; + display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap; +} +.nav__logo { + font-weight: 700; font-size: 1.0625rem; color: #f8fafc; + text-decoration: none; letter-spacing: -0.01em; +} +.nav__link { + color: #94a3b8; text-decoration: none; font-size: 0.9375rem; + transition: color 0.15s ease; +} +.nav__link:hover, .nav__link[aria-current="page"] { color: #f8fafc; } +.nav__link[aria-current="page"] { font-weight: 600; } +.nav__spacer { margin-left: auto; } +.btn--ghost { + color: #e2e8f0; text-decoration: none; font-size: 0.875rem; + padding: 0.4rem 0.85rem; border: 1px solid rgba(203,213,225,0.22); + border-radius: 7px; transition: background 0.15s ease; +} +.btn--ghost:hover { background: rgba(255,255,255,0.07); color: #f8fafc; } + +.wrap { max-width: 820px; margin: 0 auto; padding: 3rem 1.5rem 5rem; } +.crumb { font-size: 0.875rem; color: #64748b; margin-bottom: 1.5rem; } +.crumb a { color: #8B1A2B; text-decoration: none; } +.crumb a:hover { text-decoration: underline; } + +.wrap h1 { + font-size: clamp(1.9rem, 4vw, 2.4rem); font-weight: 700; color: #0f172a; + letter-spacing: -0.02em; line-height: 1.2; margin-bottom: 1.25rem; +} +.wrap h2 { + font-size: 1.5rem; font-weight: 700; color: #0f172a; + margin: 2.75rem 0 0.85rem; padding-bottom: 0.4rem; + border-bottom: 1px solid #e2e8f0; +} +.wrap h3 { font-size: 1.15rem; font-weight: 650; color: #0f172a; margin: 2rem 0 0.6rem; } +.wrap h4 { font-size: 1rem; font-weight: 650; color: #475569; margin: 1.5rem 0 0.5rem; } +.wrap p, .wrap li { font-size: 1rem; } +.wrap p { margin: 0.85rem 0; } +.wrap ul, .wrap ol { margin: 0.85rem 0 0.85rem 1.5rem; } +.wrap li { margin: 0.35rem 0; } +.wrap a { color: #8B1A2B; text-decoration: none; } +.wrap a:hover { text-decoration: underline; } +.wrap strong { color: #0f172a; font-weight: 650; } +.wrap hr { border: 0; border-top: 1px solid #e2e8f0; margin: 2.5rem 0; } +.wrap blockquote { + border-left: 3px solid #D94054; padding: 0.15rem 0 0.15rem 1.1rem; + margin: 1.25rem 0; color: #475569; +} + +.wrap code { + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; + font-size: 0.875em; background: #f1f5f9; color: #8B1A2B; + padding: 0.15em 0.4em; border-radius: 4px; +} +.wrap pre { + background: #0f172a; border-radius: 10px; padding: 1.15rem 1.35rem; + overflow-x: auto; margin: 1.25rem 0; +} +.wrap pre code { + background: none; color: #e2e8f0; padding: 0; font-size: 0.875rem; line-height: 1.65; +} + +/* Wide tables scroll inside their own container so the page never does. */ +.table-scroll { overflow-x: auto; margin: 1.25rem 0; } +.wrap table { border-collapse: collapse; width: 100%; font-size: 0.9375rem; } +.wrap th, .wrap td { + text-align: left; padding: 0.6rem 0.85rem; border-bottom: 1px solid #e2e8f0; + vertical-align: top; +} +.wrap th { color: #0f172a; font-weight: 650; background: #f8fafc; white-space: nowrap; } + +.footer { border-top: 1px solid #e2e8f0; background: #f8fafc; } +.footer__inner { + max-width: 1160px; margin: 0 auto; padding: 2rem 1.5rem; + display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; + font-size: 0.875rem; color: #64748b; +} +.footer a { color: #475569; text-decoration: none; } +.footer a:hover { color: #0f172a; } + +@media (max-width: 640px) { + .wrap { padding: 2rem 1.15rem 3.5rem; } + .nav__inner { gap: 0.85rem; } +} +""" + + +def _nav_html(current_slug: str = "") -> str: + # Built without backslashes inside f-string expressions: that is a syntax error + # before Python 3.12, and this script must run on the same floor as the package. + links = [] + for page in PAGES[:4]: + current = ' aria-current="page"' if page.slug == current_slug else "" + links.append( + '{page.title}' + ) + return f"""""" + + +def _footer_html() -> str: + return f"""
+ +
""" + + +def _wrap_tables(html: str) -> str: + """Put each table in a horizontally scrollable container. + + Wide reference tables would otherwise force the whole page to scroll + sideways on a narrow screen. + """ + return re.sub( + r"(.*?
)", + r'
\1
', + html, + flags=re.DOTALL, + ) + + +def _rewrite_internal_links(html: str) -> str: + """Point cross-references at built pages instead of source markdown. + + The guides link to each other with paths like ``tutorials/cli_guide.md``, + which would 404 in the built site. + """ + by_source_name = {p.source.name: p.output_name for p in PAGES} + for source_name, output_name in by_source_name.items(): + html = re.sub( + rf'href="(?:\.\./)*(?:tutorials/)?{re.escape(source_name)}"', + f'href="{output_name}"', + html, + ) + return html + + +def _render(page: Page, body_html: str) -> str: + return f""" + + + + +{page.title} — Plottle + + + + + +{_nav_html(page.slug)} +
+

Plottle › {page.title}

+{body_html} +
+{_footer_html()} + + +""" + + +def main() -> int: + try: + import markdown + except ImportError: + print( + "error: the `markdown` package is required.\n" + ' pip install markdown (or: pip install -e ".[docs]")', + file=sys.stderr, + ) + return 1 + + missing = [p.source for p in PAGES if not p.source.is_file()] + if missing: + for path in missing: + print(f"error: missing source file {path}", file=sys.stderr) + return 1 + + if _SITE.exists(): + shutil.rmtree(_SITE) + _SITE.mkdir(parents=True) + + (_SITE / "docs.css").write_text(_CSS, encoding="utf-8") + + # The landing page is hand-written and copied verbatim. + shutil.copy2(_DOCS / "index.html", _SITE / "index.html") + print(" index.html <- docs/index.html") + + converter = markdown.Markdown( + extensions=["extra", "sane_lists", "toc"], + output_format="html5", + ) + for page in PAGES: + converter.reset() + body = converter.convert(page.source.read_text(encoding="utf-8")) + body = _wrap_tables(_rewrite_internal_links(body)) + (_SITE / page.output_name).write_text(_render(page, body), encoding="utf-8") + print(f" {page.output_name:<20}<- {page.source.relative_to(_ROOT)}") + + print(f"\nBuilt {len(PAGES) + 1} pages into {_SITE.relative_to(_ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.nojekyll b/docs/.nojekyll similarity index 100% rename from .nojekyll rename to docs/.nojekyll diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 995edf1..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,18 +0,0 @@ -# Minimal Sphinx Makefile -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = _build - -.PHONY: help html clean - -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -html: - @$(SPHINXBUILD) -b html "$(SOURCEDIR)" "$(BUILDDIR)/html" $(SPHINXOPTS) $(O) - @echo "" - @echo "Build finished. Open _build/html/index.html" - -clean: - rm -rf $(BUILDDIR) diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/docs/_static/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/_templates/.gitkeep b/docs/_templates/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/docs/_templates/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/api/io.rst b/docs/api/io.rst deleted file mode 100644 index 87143a6..0000000 --- a/docs/api/io.rst +++ /dev/null @@ -1,13 +0,0 @@ -I/O Module (``modules.io``) -============================ - -Auto-detect format from extension and load or save any supported data type. - -Supported extensions: ``.pkl``, ``.npy``, ``.npz``, ``.csv``, ``.xlsx``, ``.tsv``, -``.json``, ``.parquet`` - -.. automodule:: modules.io - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/math.rst b/docs/api/math.rst deleted file mode 100644 index b880388..0000000 --- a/docs/api/math.rst +++ /dev/null @@ -1,10 +0,0 @@ -Math Module (``modules.math``) -================================ - -Statistical analysis, curve fitting, optimization, and linear algebra. - -.. automodule:: modules.math - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/plotting.rst b/docs/api/plotting.rst deleted file mode 100644 index b87b2b8..0000000 --- a/docs/api/plotting.rst +++ /dev/null @@ -1,15 +0,0 @@ -Plotting Module (``modules.plotting``) -======================================= - -All 13 plot types across Matplotlib, Seaborn, and Plotly. - -Return conventions (DEC-003): - -* **Matplotlib / Seaborn** functions return ``(fig, ax, info)`` -* **Plotly** functions return ``(fig, info)`` - -.. automodule:: modules.plotting - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 6960af5..82aa267 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -10,9 +10,9 @@ NCCU Department of Chemistry and Biochemistry import sys sys.path.insert(0, 'path/to/plottle') -from modules.io import load_data, save_data -from modules.math import calculate_statistics, fit_linear, fit_polynomial -from modules.plotting import line_plot, scatter_plot, histogram, save_figure +from plottle.io import load_data, save_data +from plottle.math import calculate_statistics, fit_linear, fit_polynomial +from plottle.plotting import line_plot, scatter_plot, histogram, save_figure ``` --- @@ -20,7 +20,7 @@ from modules.plotting import line_plot, scatter_plot, histogram, save_figure ## I/O — Loading Data ```python -from modules.io import load_data, load_dataframe, load_numpy, load_pickle +from plottle.io import load_data, load_dataframe, load_numpy, load_pickle df = load_data('data.csv') # auto-detect by extension df = load_dataframe('data.csv') # → DataFrame (.csv .xlsx .tsv .json .parquet) @@ -31,7 +31,7 @@ obj = load_pickle('data.pkl') # → any object (.pkl) ## I/O — Saving Data ```python -from modules.io import save_data, save_dataframe, save_numpy, save_pickle +from plottle.io import save_data, save_dataframe, save_numpy, save_pickle save_data(df, 'output.csv') save_dataframe(df, 'output.xlsx') @@ -44,7 +44,7 @@ save_pickle(obj, 'session.pkl') ## Statistics ```python -from modules.math import ( +from plottle.math import ( calculate_mean, calculate_median, calculate_std, calculate_statistics, check_normality, ) @@ -65,7 +65,7 @@ norm = check_normality(arr) ## Curve Fitting ```python -from modules.math import fit_linear, fit_polynomial, fit_exponential, fit_custom +from plottle.math import fit_linear, fit_polynomial, fit_exponential, fit_custom # Linear y = m·x + b r = fit_linear(x, y) @@ -92,7 +92,7 @@ r = fit_custom(x, y, func=my_func, p0=[1.0, 0.1, 0.0]) ## Matplotlib Plots ```python -from modules.plotting import histogram, line_plot, scatter_plot, heatmap, contour_plot +from plottle.plotting import histogram, line_plot, scatter_plot, heatmap, contour_plot # Histogram fig, ax, info = histogram(data, bins=20, xlabel='Value', ylabel='Count', title='Distribution') @@ -116,7 +116,7 @@ fig, ax = contour_plot(X, Y, Z, title='Potential Energy Surface') ## Seaborn Plots ```python -from modules.plotting import distribution_plot, box_plot, regression_plot +from plottle.plotting import distribution_plot, box_plot, regression_plot fig, ax = distribution_plot(data, kind='kde') # kind: hist | kde | ecdf fig, ax = box_plot(df) # or box_plot(df, kind='violin') @@ -126,7 +126,7 @@ fig, ax = regression_plot(x, y) # scatter + regression line + C ## Interactive (Plotly) Plots ```python -from modules.plotting import ( +from plottle.plotting import ( interactive_histogram, interactive_scatter, interactive_line, interactive_heatmap, interactive_3d_surface, ) @@ -146,7 +146,7 @@ fig.write_html('plot.html') # save as self-contained HTML ## Saving Figures ```python -from modules.plotting import save_figure +from plottle.plotting import save_figure save_figure(fig, 'plot.png', dpi=150) # screen quality save_figure(fig, 'plot.png', dpi=300) # print / publication quality @@ -249,7 +249,7 @@ fig, ax = heatmap(corr, title='Correlation Matrix') ```python # Available in the GUI Settings page and plot_config module -from modules.utils.plot_config import COLOR_PALETTES +from plottle.utils.plot_config import COLOR_PALETTES palettes = list(COLOR_PALETTES.keys()) # 'Default', 'Color-Blind Safe (Wong)', 'Color-Blind Safe (Okabe-Ito)', diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 4ca9a77..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Sphinx configuration for Plottle API documentation.""" - -import os -import sys - -# Point Sphinx to the repo root so autodoc can import plottle.* -sys.path.insert(0, os.path.abspath("..")) - -# -- Project information ------------------------------------------------------- - -project = "Plottle" -copyright = "2026, Jonathan D. Schultz, PhD — NCCU Department of Chemistry and Biochemistry" -author = "Jonathan D. Schultz, PhD" -# Read the version from the package so it cannot drift from pyproject.toml. -_version_ns: dict = {} -for _line in open(os.path.join("..", "plottle", "__init__.py"), encoding="utf-8"): - if _line.startswith("__version__"): - exec(_line, _version_ns) # noqa: S102 -- a single literal assignment - break -release = _version_ns["__version__"] -version = ".".join(release.split(".")[:2]) - -# -- General configuration ----------------------------------------------------- - -extensions = [ - "sphinx.ext.autodoc", # auto-generate docs from docstrings - "sphinx.ext.napoleon", # Google- and NumPy-style docstring support - "sphinx.ext.viewcode", # add [source] links next to each function - "sphinx.ext.intersphinx", # cross-reference NumPy / SciPy docs -] - -intersphinx_mapping = { - "python": ("https://docs.python.org/3", None), - "numpy": ("https://numpy.org/doc/stable", None), - "scipy": ("https://docs.scipy.org/doc/scipy", None), - "pandas": ("https://pandas.pydata.org/docs", None), - "matplotlib": ("https://matplotlib.org/stable", None), -} - -# Napoleon settings — match the Google-style docstrings used in this project -napoleon_google_docstring = True -napoleon_numpy_docstring = True -napoleon_include_init_with_doc = False -napoleon_include_private_with_doc = False - -# autodoc settings -autodoc_member_order = "bysource" -autodoc_typehints = "description" - -# Mock imports that are not available in the Sphinx build environment or that -# should not be executed during doc generation (GUI-only dependencies). -autodoc_mock_imports = ["streamlit"] - -# Exclude the Streamlit GUI entry point and page modules from autodoc. -# These files import streamlit at module level and are not part of the public API. -exclude_patterns = [ - "_build", - "**/_build/*", - "Thumbs.db", - ".DS_Store", -] - -templates_path = ["_templates"] - -# -- HTML output --------------------------------------------------------------- - -html_theme = "furo" - -html_theme_options = { - "light_css_variables": { - "color-brand-primary": "#5a0010", - "color-brand-content": "#5a0010", - }, - "dark_css_variables": { - "color-brand-primary": "#e0a3a3", - "color-brand-content": "#e0a3a3", - }, -} - -html_static_path = ["_static"] - -html_title = "Plottle Documentation" -html_short_title = "Plottle" - -# -- Source files -------------------------------------------------------------- - -source_suffix = ".rst" -master_doc = "index" -language = "en" diff --git a/docs/getting_started.md b/docs/getting_started.md index 3b69b63..933d5bc 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -46,12 +46,15 @@ python -m venv .venv source .venv/bin/activate ``` -### Step 4 — Install dependencies +### Step 4 — Install Plottle ```bash -pip install -r requirements.txt +pip install -e ".[formats,nist]" ``` +The `formats` extra adds HDF5 and NetCDF support; `nist` adds the NIST WebBook +lookup. The editable install is also what puts the `plottle` command on your PATH. + ### Step 5 — Verify ```bash @@ -71,7 +74,7 @@ launch.bat ### Any platform ```bash -streamlit run modules/Home.py +streamlit run plottle/Home.py ``` Open `http://localhost:8501` in your browser. Use the sidebar to navigate between pages. @@ -94,7 +97,7 @@ import numpy as np import sys sys.path.insert(0, 'path/to/plottle') -from modules.plotting import line_plot, save_figure +from plottle.plotting import line_plot, save_figure # Simulate absorbance vs. wavelength wavelength = np.linspace(400, 800, 200) @@ -116,10 +119,10 @@ save_figure(fig, 'spectrum.png', dpi=300) ## Loading Data -The `modules.io` module supports eight common formats. +The `plottle.io` module supports eight common formats. ```python -from modules.io import load_data, save_data +from plottle.io import load_data, save_data # Auto-detect format from extension df = load_data('experiment.csv') # → pandas DataFrame @@ -145,7 +148,7 @@ Supported extensions: ## Computing Statistics ```python -from modules.math import calculate_statistics +from plottle.math import calculate_statistics stats = calculate_statistics(arr) print(f"Mean: {stats['mean']:.4f}") @@ -160,7 +163,7 @@ Returns: `mean`, `median`, `std`, `var`, `min`, `max`, `q1`, `q3`, `iqr`, `range ## Curve Fitting ```python -from modules.math import fit_linear, fit_polynomial +from plottle.math import fit_linear, fit_polynomial # Beer-Lambert: A = ε·c·l → linear fit result = fit_linear(concentration, absorbance) @@ -197,7 +200,7 @@ y_fit = poly['predict'](x) # call the returned predict function ## Saving Figures ```python -from modules.plotting import save_figure +from plottle.plotting import save_figure save_figure(fig, 'plot.png', dpi=300) # high-res raster save_figure(fig, 'plot.svg') # vector (no dpi needed) @@ -210,12 +213,20 @@ save_figure(fig, 'plot.pdf') # vector, publication-ready | Page | Purpose | | --- | --- | -| **1 — Data Upload** | Load files; preview shape, types, and statistics | -| **2 — Quick Plot** | Generate any plot type with live configuration controls | -| **3 — Analysis Tools** | Statistics, curve fitting, smoothing, peak fitting | -| **4 — Multi-Plot Dashboard** | Side-by-side grid of up to 6 independent plots | -| **5 — Advanced Plotting** | Correlation heatmaps, overlaid distributions, 3D scatter | -| **6 — Settings** | Persistent defaults and named style presets | +| **Home** | Dashboard overview and help tabs | +| **Data Upload** | Load files in 18 formats; preview shape, types, and statistics; batch folder import | +| **Plot → Basic** | 27 plot types with live style controls and an annotation panel | +| **Plot → Multiplot** | Up to 4×4 grid layouts with axis sharing and combined export | +| **Plot → Advanced Plotting** | Seaborn statistical plots and Plotly interactive charts | +| **Plot → Spectroscopy** | IR/Raman, NMR, UV-Vis, Mass Spec; NIST WebBook lookup by CAS | +| **Plot → Molecular Viz** | Gaussian/ORCA/Molden output; 3D structure and vibrational modes | +| **Analyze → Single** | Statistics, distributions, curve fitting, signal processing, peaks | +| **Analyze → Batch** | Batch statistics, curve fitting, and peak analysis with presets | +| **Analyze → Data Tools** | 12 non-destructive DataFrame operations | +| **Export** | Export plots, data, and analyses; save/load sessions; PDF reports | +| **Gallery** | Pre-rendered examples with "Use this config" buttons | +| **Help** | Getting started, plot types, analysis tools, formats, tips | +| **Settings** | Theme, plot defaults, and named preset management | --- @@ -224,9 +235,9 @@ save_figure(fig, 'plot.pdf') # vector, publication-ready ### Beer-Lambert calibration curve ```python -from modules.io import load_dataframe -from modules.math import fit_linear -from modules.plotting import scatter_plot, save_figure +from plottle.io import load_dataframe +from plottle.math import fit_linear +from plottle.plotting import scatter_plot, save_figure import numpy as np df = load_dataframe('calibration.csv') # columns: concentration, absorbance @@ -250,7 +261,7 @@ save_figure(fig, 'calibration.png', dpi=300) ### Comparing multiple spectra ```python -from modules.plotting import line_plot +from plottle.plotting import line_plot fig, ax = line_plot( wavelength, @@ -266,7 +277,8 @@ fig, ax = line_plot( ## Next Steps -- Work through the **Jupyter notebooks** in `notebooks/` for guided tutorials. -- See `docs/cheatsheet.md` for a quick-reference card of all functions. -- See `DEPLOYMENT.md` for how to run the GUI locally or on Streamlit Cloud. -- Report issues or suggestions via the course discussion board. +- Read the [GUI Guide](tutorials/gui_guide.md) for a tour of every page. +- Read the [CLI Guide](tutorials/cli_guide.md) for scripted and batch workflows. +- Keep the [Cheatsheet](cheatsheet.md) handy as a quick reference to the Python API. +- Found a problem? See [Reporting Bugs](bug-reports.md). +- Want something added? See [Feature Requests](feature_requests.md). diff --git a/docs/index.html b/docs/index.html index 710269b..1418bc8 100644 --- a/docs/index.html +++ b/docs/index.html @@ -222,6 +222,8 @@ background: linear-gradient(135deg, rgba(139,26,43,0.10), rgba(197,160,40,0.08)); border: 1px solid rgba(139,26,43,0.12); } + .feature-card__title a { color: inherit; text-decoration: none; } + .feature-card__title a:hover { text-decoration: underline; } .feature-card__title { font-size: 1.0625rem; font-weight: 700; color: #0f172a; margin-bottom: 0.5rem; @@ -390,6 +392,7 @@ Install API GUI + Docs GitHub →
@@ -543,7 +546,7 @@

Everything in one toolkit

📊
-
26 Plot Types
+
27 Plot Types

Scatter, line, bar, histogram, box, violin, heatmap, contour, 3D surface, regression, and more — in Matplotlib, Seaborn, or interactive Plotly. @@ -957,6 +960,79 @@

Command-line interface

+ +
+
+

Documentation

+

+ Guides for the GUI, the CLI, and the Python API. +

+
+ +
+
🚀
+ +

+ Install Plottle, load your first dataset, and make your first plot. +

+
+ +
+
🖱️
+
+ GUI Guide +
+

+ A tour of all 14 pages of the Streamlit interface. +

+
+ +
+
⌨️
+
+ CLI Guide +
+

+ The five plottle subcommands, with worked examples. +

+
+ +
+
📖
+ +

+ Quick reference for the Python API — every module, at a glance. +

+
+ +
+
🐛
+ +

+ What to include so a problem can be reproduced and fixed. +

+
+ +
+
💡
+ +

+ How to propose new functionality. +

+
+ +
+
+
+