diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..3886744
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,11 @@
+Closes #
+
+- [ ] The pull request is for `main`.
+
+### Main changes
+
+-
+
+### Pages that changed
+
+-
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..00c2dfb
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,10 @@
+version: 2
+updates:
+ - package-ecosystem: pip
+ directory: /
+ schedule:
+ interval: weekly
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml
new file mode 100644
index 0000000..859c584
--- /dev/null
+++ b/.github/workflows/docs-build.yml
@@ -0,0 +1,52 @@
+name: Docs build
+
+# Builds the site with warnings as errors and checks it against the docs-site
+# standard. The weekly run finds a change in a submodule, a dependency or an
+# external page that nobody pushed.
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ schedule:
+ - cron: "0 6 * * 1"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ submodules: recursive
+
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+
+ - name: Install
+ run: python -m pip install -r requirements.txt
+
+ - name: Check the site against the standard
+ run: python scripts/check_site.py .
+
+ # Not yet blocking: 121 distinct names on the guide pages resolve to neither
+ # toolbox, and scripts/known_names.txt is empty. Remove continue-on-error
+ # after the names are checked and the real ones allow-listed.
+ - name: Check names on the guide pages
+ continue-on-error: true
+ run: python scripts/check_names.py
+
+ - name: Build with warnings as errors
+ run: sphinx-build -W --keep-going -b html docs docs/_build/html
+
+ # Not yet blocking: the state of the external links of this site was not
+ # known when the check was added. Remove continue-on-error after a run
+ # that is clean, or list the links that reject crawlers in linkcheck_ignore.
+ - name: Check external links
+ continue-on-error: true
+ run: sphinx-build -b linkcheck docs docs/_build/linkcheck
diff --git a/.github/workflows/update-submodules.yml b/.github/workflows/update-submodules.yml
index e7424f5..b25937a 100644
--- a/.github/workflows/update-submodules.yml
+++ b/.github/workflows/update-submodules.yml
@@ -1,14 +1,19 @@
name: Update submodules
+# Moves each submodule to the tip of the branch set in .gitmodules and opens a
+# pull request. The pull request build checks that the new source still builds
+# and that the documented examples still hold; nothing is pushed to main.
+
on:
schedule:
- - cron: '0 4 * * *' # daily at 04:00 UTC
- workflow_dispatch: {} # adds a "Run workflow" button for manual runs
+ - cron: "0 4 * * *"
+ workflow_dispatch: {}
permissions:
- contents: write # needed to push the pointer commit
+ contents: write
+ pull-requests: write
-concurrency: # avoid overlapping runs racing on a commit
+concurrency:
group: update-submodules
cancel-in-progress: false
@@ -16,19 +21,16 @@ jobs:
update:
runs-on: ubuntu-latest
steps:
- - name: Checkout gecko-docs
- uses: actions/checkout@v5
+ - uses: actions/checkout@v5
with:
submodules: true
fetch-depth: 1
- - name: Bump submodules to their tracked branches
+ - name: Move submodules to their tracked branches
run: |
- # Fetch each submodule's tracked branch (from .gitmodules) and check
- # it out at its tip. Done explicitly because the submodules are
- # `shallow = true`, so only the pinned commit is present and
- # `git submodule update --remote` can't resolve the remote branch ref.
- # (GECKO -> develop4, geckopy -> develop)
+ # The submodules are `shallow = true`: only the pinned commit is
+ # present, so `git submodule update --remote` cannot resolve the
+ # remote branch. Each branch tip is fetched explicitly.
git submodule sync --recursive
git submodule foreach '
branch=$(git config -f "$toplevel/.gitmodules" "submodule.$name.branch" || echo main)
@@ -37,14 +39,22 @@ jobs:
git checkout -B "$branch" FETCH_HEAD
'
- - name: Commit & push if anything changed
+ - name: Open or update the pull request
+ env:
+ GH_TOKEN: ${{ github.token }}
run: |
- git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git diff --quiet; then
echo "No submodule updates."
- else
- git add -A
- git commit -m "chore: update submodules to latest tracked branches"
- git push
+ exit 0
+ fi
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git switch -C chore/update-submodules
+ git add -A
+ git commit -m "chore: update submodules to latest tracked branches"
+ git push --force-with-lease origin chore/update-submodules
+ if [ -z "$(gh pr list --head chore/update-submodules --state open --json number --jq '.[].number')" ]; then
+ gh pr create --base main --head chore/update-submodules \
+ --title "chore: update submodules to latest tracked branches" \
+ --body "Moves each submodule to the tip of the branch set in .gitmodules."
fi
diff --git a/.gitignore b/.gitignore
index 7649c74..8f2b4c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -167,7 +167,7 @@ venv.bak/
# mkdocs documentation
/site
-# Sphinx build: generated at build time by scripts/gen_api_pages_sphinx.py,
+# Sphinx build: generated at build time by scripts/gen_api_pages.py,
# never checked in.
docs/api/matlab/*.md
docs/api/python/*.md
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 4f88d33..52bff90 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -16,7 +16,8 @@ build:
sphinx:
configuration: docs/conf.py
+ fail_on_warning: true
python:
install:
- - requirements: requirements-sphinx.txt
+ - requirements: requirements.txt
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 949a04e..f6b3638 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,39 +1,25 @@
# Contributing to gecko-docs
-## Documentation prose style
+The standard for this site is the `docs-site` plugin of [CompMetEng/skills](https://github.com/CompMetEng/skills), and the intent of this site is in [DESIGN.md](DESIGN.md). Prose follows the `writing` skill of the `craft` plugin. The rules most often broken:
-Many readers are not native English speakers. Prose must be direct and
-literal, not just factually correct.
+1. No em dashes anywhere: not in prose, headings, table cells or generated strings. Use the punctuation the sentence calls for: a comma where the clause continues, a semicolon where it is independent, a colon where what follows defines or lists what precedes it, parentheses around an aside.
+2. Check every sentence while writing it:
+ - Can the subject perform the verb? A file, function or table can return, raise, contain or default to. It cannot want, know or prefer, and it does nothing quietly or conveniently.
+ - Does the sentence state a behaviour and its condition, or does it tell the reader what is worth doing? State the fact and leave the judgement to the reader.
+ - Is the sentence about the software or about the document? Remove "this page is about" and "as mentioned above".
+ - Does a word minimise something? Remove "just", "simply", "merely", "only", "of course" when the claim does not change.
+ - Would a non-native English reader parse it on the first read? No idioms, figures of speech or headlines that drop the subject or verb.
+3. Established technical shorthand (a domain term, a standard abbreviation) stays.
+4. No mention of AI tools or AI authorship in the repository: not in commits, pull requests, code comments or pages. Branch names do not contain a tool name.
-1. No em dashes, anywhere: not in prose, headings, table cells, or generated
- strings. Use the punctuation the sentence actually calls for: a comma
- where the clause continues, a semicolon where it is independent, a colon
- where what follows defines or enumerates what precedes it, parentheses
- for a matched pair around an aside.
-2. Apply these five checks to every sentence while writing it, not as a
- later sweep:
- - Can the subject perform the verb? A file, function, or table can
- return, raise, contain, default to. It cannot want, know, prefer, or
- do anything "quietly," "conveniently," or "simply." A manner adverb on
- an inanimate subject is the most common violation.
- - Does the sentence state a behavior and its condition, or does it tell
- the reader what's "worth" doing? No "worth using," "the usual way,"
- "best practice." State the fact; leave the judgment to the reader.
- - Is the sentence about the software, or about the document? Cut "this
- page is about," "as mentioned above," "the point of this example."
- - Does a word minimize something? "just," "simply," "merely," "only,"
- "of course." If removing it doesn't change the claim, remove it.
- - Would a non-native English reader parse this correctly on first read?
- No idioms, figures of speech, or headline-style fragments that drop
- the subject or verb for effect (example of what to avoid: "same
- verdicts, very different cost" as a heading). Rewrite as a complete,
- literal sentence. This is not the same as dumbing content down: keep
- the technical precision, drop only the cleverness.
-3. No AI/Claude/Anthropic attribution anywhere in the repository: not in
- commit messages, PR text, code comments, or the docs themselves.
+## Building and checking
-One exception worth stating explicitly: established, consistently-used
-technical shorthand (a real domain term, a standard abbreviation) is not
-the same as a one-off clever phrase, and doesn't need to be purged just
-because it isn't perfectly literal. Reserve the rewrite effort for genuine
-idiom and headline compression, especially on pages newcomers read first.
+```bash
+git submodule update --init --recursive
+python -m venv .venv && source .venv/bin/activate
+pip install -r requirements.txt
+python scripts/check_site.py .
+sphinx-build -W -b html docs docs/_build/html
+```
+
+Pages are written as described in the `docs-page` skill. A guide example is run before it is committed, and the output under it is the output of that run.
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..2bfe0c2
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,68 @@
+# GECKO documentation: design and content guide
+
+This document is the source of intent. It describes how the site should be designed and what it should contain, not the current state of `main`. When the site and this document disagree, the site is brought in line, or the decision is recorded here first. New decisions go in the decisions log; resolved items leave "Open questions".
+
+The site follows the `docs-site` standard of [CompMetEng/skills](https://github.com/CompMetEng/skills). Differences from the standard are listed in section 6.
+
+Before any work in this repository, fetch all remotes of the repository and of every submodule. The submodules are updated often and automatically, so a local checkout becomes stale quickly:
+
+```bash
+git fetch --all --prune
+git checkout main && git pull --ff-only
+git submodule sync --recursive
+git submodule update --init --recursive
+```
+
+## 1. Purpose
+
+One documentation site for **GECKO**, the MATLAB toolbox, and **geckopy**, its Python port, for reconstructing, simulating and analyzing enzyme-constrained genome-scale metabolic models (ecModels). A user learns a task once and applies it in either language. Readers are researchers who build ecModels from a genome-scale model, and many of them are not native English speakers.
+
+## 2. Principles
+
+- **Both languages together.** MATLAB and Python are presented side by side: paired API entries and linked code tabs.
+- **Single source of truth.** The API reference is generated from the function help in the toolbox sources, which are git submodules tracking the branches in `.gitmodules`.
+- **Low maintenance.** What can be generated is generated at build time. Prose pages are short and have one job.
+- **Curated content.** A small set of correct pages is preferred to a large set of stale ones.
+- **Literal prose.** The writing rules of `CONTRIBUTING.md` apply to every page, because many readers are not native English speakers.
+
+## 3. Technical foundation
+
+- Sphinx, MyST Markdown, PyData theme, `sphinx-design`, `sphinx-copybutton`. Read the Docs builds with warnings as errors.
+- Submodules: `GECKO` (branch `develop`) and `geckopy` (branch `develop`).
+- The API reference is generated at build time by `scripts/gen_api_pages.py`, called from `docs/conf.py`. The scripts in `scripts/` are copies of `docs-site/scripts` in CompMetEng/skills; what is specific to this site is in `scripts/project.yml`, `scripts/curated_pairs.yml` and `scripts/known_names.txt`. A change to a script is made in the plugin first.
+- `.github/workflows/docs-build.yml` runs `scripts/check_site.py`, the build with `-W`, the name check and the link check.
+
+## 4. Site structure
+
+- **Home** (`docs/index.md`): one sentence on GECKO and geckopy, the install tabs for both languages, key features, quick start, links to the papers.
+- **Installation** (`docs/installation/`).
+- **Guide** (`docs/guide/`): *Start* (Introduction, Installation), *Guide* (ten task pages from getting started to enzyme usage and bottlenecks), *Background* (GECKO light, the YAML format, the files of the data folder, the model adapter parameters). The Introduction and the Background pages explain a concept or a format and are marked ``, so they need no function table.
+- **Migration**: `migrate.md`, `gecko3-to-gecko4.md` and `gecko-to-geckopy.md`.
+- **API reference** (`docs/api/`): a hand-written index, then generated MATLAB pages by folder and Python pages by package, and `docs/matlab-vs-python.md`.
+- **References** (`docs/references.md`): the papers to cite.
+
+## 5. Conventions
+
+- Code tabs are labelled MATLAB and Python, linked, with MATLAB first.
+- MATLAB `camelCase` and Python `snake_case` are paired by normalised name; pairs that differ are in `scripts/curated_pairs.yml`.
+- Functions that resolve to cobrapy are marked with the cobrapy badge.
+- Prose follows `CONTRIBUTING.md`. No mention of AI tools.
+
+## 6. Differences from the standard
+
+| Difference | Reason |
+| --- | --- |
+| `sphinx.ext.mathjax` in `extensions` | the Introduction contains equations |
+
+## 7. Open questions
+
+- **Unresolved names.** `scripts/check_names.py` reports 121 distinct names on the guide pages that are neither a top-level function or class of the toolboxes nor in `scripts/known_names.txt`. Most are keyword arguments, struct fields, YAML keys and variables of the examples; four (`cap_usage`, `from_folder`, `shadow_price`, `upper_bound`) are methods or properties. Someone who knows the pages has to sort them, and allow-list the ones that are not functions, before the check can block CI.
+- **Link check.** `docs-build.yml` does not fail on the link check yet; the state of the external links was not known when the check was added.
+- **Guide page sections.** Most guide pages have no "What can go wrong" section and no function table; `scripts/check_site.py` reports this as a warning.
+
+## 8. Decisions log
+
+- 2026-10-24: the site follows the `docs-site` standard. `requirements-sphinx.txt` is `requirements.txt` and `gen_api_pages_sphinx.py` is `gen_api_pages.py`. The brand colours are `#43684B` for tabs and borders and `#2D6E3B` for links, with `#5F936A` and `#87d398` in dark mode, set as `--pst-color-primary`, `--pst-color-link` and `--site-accent`. Logos and the favicon are in `docs/_static` only, with a white logo for dark mode. The API pages use the numpydoc rendering and show signatures.
+- 2026-10-24: Read the Docs and CI build with `-W`. The submodule update opens a pull request instead of pushing to `main`.
+- 2026-10-24: `docs/_static/gecko-logo-white.png`, the logo for dark mode, was made from `gecko-logo.png` by making every pixel white and keeping the alpha channel, the way `gecko-icon-white.png` was made. The maintainer accepted it. The unused `gecko-icon-color.png` and `gecko-icon-white.png` of the former `docs/assets` folder were removed and are in the history.
+- 2026-10-24: the language of the code tabs (MATLAB or Python) is remembered in `localStorage` by `docs/_static/language-sync.js`, so it applies to every tab on a page, to the next pages, to new browser tabs and to later visits, and install tabs follow it. sphinx-design alone keeps the choice in `sessionStorage` for one browser tab.
diff --git a/README.md b/README.md
index 6e8aab7..268fa5c 100644
--- a/README.md
+++ b/README.md
@@ -1,40 +1,36 @@
# GECKO Documentation
-Documentation for GECKO, a MATLAB toolbox, and geckopy, its Python port, for
-reconstructing, simulating and analyzing enzyme-constrained genome-scale
-metabolic models (ecModels). Built with [Sphinx](https://www.sphinx-doc.org/),
-[MyST](https://myst-parser.readthedocs.io/) and
-[pydata-sphinx-theme](https://pydata-sphinx-theme.readthedocs.io/), matching
-the [raven-docs](https://github.com/edkerk/raven-docs) setup for RAVEN and
-raven-toolbox.
-
-Alongside the Guide, the site includes an auto-generated **API reference**
-that documents both implementations side by side, statically extracted at
-build time from each toolbox's source (pulled in as git submodules). See
-[API reference (MATLAB + Python)](#api-reference-matlab--python) below.
+Documentation for GECKO, a MATLAB toolbox, and geckopy, its Python port, for reconstructing, simulating and analyzing enzyme-constrained genome-scale metabolic models (ecModels). Built with [Sphinx](https://www.sphinx-doc.org/), [MyST](https://myst-parser.readthedocs.io/) and [pydata-sphinx-theme](https://pydata-sphinx-theme.readthedocs.io/), following the `docs-site` standard of [CompMetEng/skills](https://github.com/CompMetEng/skills), which [raven-docs](https://github.com/CompMetEng/raven-docs) follows for RAVEN and raven-toolbox. The intent of the site is in [DESIGN.md](DESIGN.md).
+
+Alongside the Guide, the site includes an auto-generated **API reference** that documents both implementations side by side, statically extracted at build time from each toolbox's source (pulled in as git submodules). See [API reference (MATLAB + Python)](#api-reference-matlab--python) below.
## Repository layout
```
gecko-docs/
-├── .readthedocs.yaml # Read the Docs build configuration (incl. submodules)
+├── .readthedocs.yaml # Read the Docs build configuration (incl. submodules)
├── .gitmodules # GECKO + geckopy submodule definitions
-├── requirements-sphinx.txt # Python build dependencies
+├── .github/ # workflows (build, submodule updates), Dependabot, pull request template
+├── requirements.txt # Python build dependencies
+├── DESIGN.md # what the site should be, and the decisions log
+├── CONTRIBUTING.md # writing rules and build commands
├── README.md # This file
├── GECKO/ # submodule: CompMetEng/GECKO (MATLAB source)
-├── geckopy/ # submodule: CompMetEng/geckopy (Python source)
+├── geckopy/ # submodule: CompMetEng/geckopy (Python source)
├── scripts/
-│ ├── api_index.py # shared MATLAB/Python source-collection helpers
-│ ├── gen_api_pages_sphinx.py # generates docs/api/ and docs/matlab-vs-python.md
-│ └── curated_pairs.yml # hand-curated name pairs the generator can't match automatically
+│ ├── project.yml # names, paths and categories of this site, read by the scripts
+│ ├── curated_pairs.yml # hand-curated name pairs the generator can't match automatically
+│ ├── known_names.txt # names the name checker accepts that are not functions
+│ └── *.py # copies of docs-site/scripts: API generator, linker, name and site checks
└── docs/
- ├── conf.py # Sphinx configuration
- ├── index.md # Landing page
+ ├── conf.py # Sphinx configuration
+ ├── index.md # Landing page
├── migrate.md, gecko3-to-gecko4.md, gecko-to-geckopy.md
- ├── references.md # Citations
+ ├── references.md # Citations
├── installation/index.md
- ├── guide/ # Introduction, Getting started, and the task pages
- └── api/ # auto-generated bilingual API reference (not checked in)
+ ├── guide/ # Introduction, Getting started, and the task pages
+ ├── _static/ # stylesheet, logos, favicon
+ └── api/ # index.md is hand-written; matlab/ and python/ are generated (not checked in)
```
## Building locally
@@ -44,47 +40,32 @@ gecko-docs/
git submodule update --init --recursive # fetch the GECKO + geckopy sources
python -m venv .venv
source .venv/bin/activate # on Windows: .venv\Scripts\activate
-pip install -r requirements-sphinx.txt
-sphinx-build -b html docs docs/_build/html
+pip install -r requirements.txt
+sphinx-build -W -b html docs docs/_build/html
```
-The `git submodule update --init` step is required: without it the `GECKO/`
-and `geckopy/` source trees are empty and the API reference builds with no
-functions in it.
+The `git submodule update --init` step is required: without it the `GECKO/` and `geckopy/` source trees are empty and the API reference builds with no functions in it.
-Open `docs/_build/html/index.html` in a browser. There is no live-reload dev
-server configured; re-run `sphinx-build` after editing.
+Open `docs/_build/html/index.html` in a browser. There is no live-reload dev server configured; re-run `sphinx-build` after editing. The build uses `-W`, so a warning fails it, as on Read the Docs. Before a pull request, run `python scripts/check_site.py .` as well.
## Hosting on Read the Docs
1. Push this folder to a GitHub, GitLab or Bitbucket repository.
2. Sign in at https://readthedocs.org/ and import the repository.
-3. Read the Docs detects `.readthedocs.yaml` and builds the site
- automatically on every push.
+3. Read the Docs detects `.readthedocs.yaml` and builds the site automatically on every push. In the project settings, enable "Build pull requests for this project" to get a preview of each pull request.
-Read the Docs initializes the git submodules automatically because
-`.readthedocs.yaml` sets `submodules.include: all`, so the API reference is
-built on every push without any extra configuration.
+Read the Docs initializes the git submodules automatically because `.readthedocs.yaml` sets `submodules.include: all`, so the API reference is built on every push without any extra configuration. The build fails on warnings (`fail_on_warning: true`).
## API reference (MATLAB + Python)
-`docs/api/` documents both toolboxes side by side, plus
-`docs/matlab-vs-python.md`, a generated table pairing every function that
-exists in both. All of it is written by `scripts/gen_api_pages_sphinx.py`
-at build time (run from `docs/conf.py`'s `setup()` hook), which extracts
-MATLAB help blocks and Python docstrings directly from source: no MATLAB
-runtime, no installed geckopy package, and no live-rendering plugin
-involved. Nothing under `docs/api/` or `docs/matlab-vs-python.md` is
-checked into the repository or should be hand-edited; regenerate by
-rebuilding. Function pairs that don't match by name alone (geckopy renamed
-part of the API during the port) are recorded in `scripts/curated_pairs.yml`,
-which is validated against the live source at build time.
+`docs/api/` documents both toolboxes side by side, plus `docs/matlab-vs-python.md`, a generated table pairing every function that exists in both. All of it, except `docs/api/index.md`, is written by `scripts/gen_api_pages.py` at build time (run from `docs/conf.py`'s `setup()` hook), which extracts MATLAB help blocks and Python docstrings directly from source: no MATLAB runtime, no installed geckopy package, and no live-rendering plugin involved. Nothing under `docs/api/matlab/`, `docs/api/python/` or `docs/matlab-vs-python.md` is checked into the repository or should be hand-edited; regenerate by rebuilding. Function pairs that don't match by name alone (geckopy renamed part of the API during the port) are recorded in `scripts/curated_pairs.yml`, which is validated against the live source at build time.
+
+The scripts in `scripts/` are copies of `docs-site/scripts` in [CompMetEng/skills](https://github.com/CompMetEng/skills). What is specific to this site is in `scripts/project.yml`. A change to a script is made there first.
-Sources are git submodules pinned to each project's tracked development
-branch (see `.gitmodules`):
+Sources are git submodules pinned to each project's tracked development branch (see `.gitmodules`):
-- `GECKO/` — [CompMetEng/GECKO](https://github.com/CompMetEng/GECKO) (MATLAB)
-- `geckopy/` — [CompMetEng/geckopy](https://github.com/CompMetEng/geckopy) (Python)
+- `GECKO/`: [CompMetEng/GECKO](https://github.com/CompMetEng/GECKO) (MATLAB)
+- `geckopy/`: [CompMetEng/geckopy](https://github.com/CompMetEng/geckopy) (Python)
To refresh the reference against the latest upstream code:
@@ -94,12 +75,8 @@ git add GECKO geckopy
git commit -m "chore: update submodules to latest tracked branches"
```
-(`.github/workflows/update-submodules.yml` does this automatically on a
-daily schedule.)
+`.github/workflows/update-submodules.yml` does this daily and opens a pull request, whose build checks that the new sources still build.
## License and attribution
-The GECKO source code is released under the MIT license at
-https://github.com/CompMetEng/GECKO. The scientific content summarized
-in these pages is the work of the original authors; see
-[Citations](docs/references.md) for the papers to cite.
+The GECKO source code is released under the MIT license at https://github.com/CompMetEng/GECKO. The scientific content summarized in these pages is the work of the original authors; see [Citations](docs/references.md) for the papers to cite.
diff --git a/docs/_static/custom.css b/docs/_static/custom.css
index 69847cd..bed30da 100644
--- a/docs/_static/custom.css
+++ b/docs/_static/custom.css
@@ -1,42 +1,38 @@
-/* ===== GECKO brand palette (green) =====================================
- Sourced from GECKO.png -- a flat single-tone gecko icon + wordmark,
- RGB(67,104,75). Shades below are HSL adjustments of that exact color
- (same hue), not eyeballed. Carried over from the MkDocs build's
- docs/stylesheets/extra.css. */
+/* ===== PROJECT COLOURS: the only block that differs between sites ======
+ Take the brand colour from the logo. The primary colour is the brand colour
+ itself (tab and border colour); the accent is used for links and the language
+ badge, and is the same as the primary colour when the brand colour is dark
+ enough for text. The dark value keeps a contrast ratio
+ of at least 4.5:1 on the dark page background. The theme reads the
+ --pst-color-* variables as plain colours (for example
+ `border: .125rem solid var(--pst-color-primary)`), so a list of numbers
+ makes every rule that uses them invalid. */
html[data-theme="light"] {
- --gecko-green: #43684B;
- --gecko-green-dark: #273D2C;
- --gecko-green-light: #5F936A;
- --gecko-accent: #2D6E3B;
- /* pydata-sphinx-theme reads these as plain CSS colors (e.g. `border: .125rem
- solid var(--pst-color-primary)`), not as an rgb()-triplet to compose --
- a bare "67, 104, 75" here makes every rule that uses it invalid, which is
- what silently dropped the tab-content border (see below) in the first
- place. Real color values only. */
+ --site-accent: #2D6E3B;
+ --site-accent-bg: rgba(67,104,75, .12);
--pst-color-primary: #43684B;
--pst-color-link: #2D6E3B;
}
html[data-theme="dark"] {
- --gecko-green: #5F936A;
- --gecko-accent: #87d398;
+ --site-accent: #87d398;
+ --site-accent-bg: rgba(67,104,75, .12);
--pst-color-primary: #5F936A;
--pst-color-link: #87d398;
}
/* ===== Inline links: visible as links in body text ===================== */
-/* pydata-sphinx-theme's default gives body links a colour change but no
- underline, so they read as normal prose unless a reader is specifically
- scanning for them. External-reference icons already carry their own cue,
- so they are excluded. */
+/* The theme gives body links a colour change but no underline. External
+ references carry their own icon, so they are excluded. */
.bd-article a:not(.reference.external) {
text-decoration: underline;
text-underline-offset: 2px;
}
-/* ===== Home page: a real "# GECKO" H1 keeps the document structure valid
- (and helps a screen reader / search engine), but the logo image beneath it
- already carries the visible title -- "gecko" is the id Sphinx slugifies
- that exact heading text to, and is unique site-wide. ==================== */
+/* ===== Home page title ================================================
+ The page keeps a real H1 for the document structure, search engines and
+ screen readers. The logo beneath it carries the visible title. Sphinx
+ slugifies the heading "# GECKO" to the section id "gecko", which is
+ unique in the site. */
#gecko > h1 {
position: absolute;
width: 1px; height: 1px;
@@ -47,10 +43,9 @@ html[data-theme="dark"] {
border: 0;
}
-/* ===== Tables: fit the article column instead of forcing a horizontal
- scrollbar. table-layout:auto sizes a table from its content's preferred
- width with no upper bound; capping it at the container's width makes long
- cells wrap instead of pushing the table wider than the page. ============ */
+/* ===== Tables: fit the article column ================================= */
+/* table-layout:auto has no upper bound on the width; a fixed layout capped at
+ the container makes long cells wrap. */
.bd-article table {
table-layout: fixed;
width: 100%;
@@ -60,54 +55,49 @@ html[data-theme="dark"] {
.bd-article table th {
overflow-wrap: break-word;
}
-/* Sphinx wraps inline code in a single white-space:nowrap span, which is
- right in prose but lets one long identifier list force a table cell wide.
- Table cells are the one place breaking a code token mid-word is better
- than the cell (and the whole table) overflowing. */
+/* Sphinx wraps inline code in a nowrap span. In table cells a long identifier
+ list would widen the table, so there a code token may break. */
.bd-article table code .pre {
white-space: normal;
overflow-wrap: break-word;
}
/* ===== Home page hero ================================================= */
-.gd-hero { text-align: center; padding: 1.5rem 0 1rem; }
-/* Logo and badge share a row, bottom edges level; wraps to stacked/centered
- on narrow viewports instead of squeezing side by side. */
-.gd-hero-top {
+.site-hero { text-align: center; padding: 1.5rem 0 1rem; }
+/* align-items:flex-end puts the badge's bottom edge on the logo's bottom
+ edge, whatever the rendered height of the logo is. */
+.site-logo-row {
display: flex;
align-items: flex-end;
justify-content: center;
flex-wrap: wrap;
gap: .6rem;
- margin-bottom: .9rem;
+ margin-bottom: .8rem;
}
-.gd-hero img.gd-hero-logo {
- max-width: 380px; width: 100%; height: auto;
- /* pydata-sphinx-theme gives content images a white backing (right for a
- transparent screenshot on a dark background, wrong for this logo,
- which is already transparent and reads fine on either theme). */
- background-color: transparent !important;
-}
-.gd-tag {
- /* margin: 0, not just an absent margin-bottom: the theme's own paragraph
- spacing (`.bd-article p`) outranks a bare `.gd-tag` margin-bottom that
- isn't set at all, and any bottom margin here would shift the badge up
- off the logo's bottom edge in the flex row below (.gd-hero-top,
- align-items: flex-end). */
+.site-tag {
display: inline-block; font-size: .7rem; font-weight: 700;
letter-spacing: .07em; text-transform: uppercase;
- color: var(--gecko-green);
- background: rgba(67,104,75,.12);
+ color: var(--site-accent);
+ background: var(--site-accent-bg);
border-radius: 20px; padding: .2rem .75rem;
+ /* margin 0: the theme's paragraph spacing would lift the badge off the
+ bottom edge of the logo. */
margin: 0;
}
-.gd-tagline {
+.site-tagline {
font-size: .95rem; color: var(--pst-color-text-muted);
- max-width: 560px; margin: 0 auto .9rem; line-height: 1.65;
+ max-width: 560px; margin: 0 auto; line-height: 1.65;
}
-/* ===== MATLAB / Python tab-set ========================================
- No custom rule for .sd-tab-content: pydata-sphinx-theme's own stylesheet
- already borders it in --pst-color-primary (the invalid-format bug above
- was what made that look like it needed restoring). Same tab-set styling
- as raven-docs, which doesn't touch this either. */
+/* ===== Home page logo: one image per colour scheme ==================== */
+.site-logo-light, .site-logo-dark {
+ max-width: 340px;
+ width: 100%;
+ height: auto;
+ /* The theme gives content images a white backing. The logos are
+ transparent and read on either background. */
+ background-color: transparent !important;
+}
+.site-logo-dark { display: none; }
+html[data-theme="dark"] .site-logo-light { display: none; }
+html[data-theme="dark"] .site-logo-dark { display: inline; }
diff --git a/docs/assets/gecko-icon-green.png b/docs/_static/gecko-icon.png
similarity index 100%
rename from docs/assets/gecko-icon-green.png
rename to docs/_static/gecko-icon.png
diff --git a/docs/_static/gecko-logo-white.png b/docs/_static/gecko-logo-white.png
new file mode 100644
index 0000000..b865338
Binary files /dev/null and b/docs/_static/gecko-logo-white.png differ
diff --git a/docs/_static/language-sync.js b/docs/_static/language-sync.js
new file mode 100644
index 0000000..5926b0c
--- /dev/null
+++ b/docs/_static/language-sync.js
@@ -0,0 +1,85 @@
+/* Language of the code tabs: MATLAB or Python.
+ *
+ * sphinx-design links the tabs that have the same sync key on one page and
+ * remembers the choice for the next page, but in sessionStorage, which a new
+ * browser tab or a later visit does not see. This script stores the choice in
+ * localStorage and applies it to every tab set of the page when the page loads
+ * and when the reader changes the language. Tab sets that have no sync key, such
+ * as the install tabs ("Python (pip)", "MATLAB (Add-Ons)"), follow the language
+ * too: the first tab whose label names the language is selected. A tab set
+ * that already shows a tab of the language is left as it is, and a tab set that
+ * has no tab of the language is not touched.
+ */
+(function () {
+ "use strict";
+
+ var KEY = "docs-language";
+
+ function languageOf(label) {
+ var text = label.getAttribute("data-sync-id") || label.textContent;
+ if (/python/i.test(text)) return "python";
+ if (/matlab/i.test(text)) return "matlab";
+ return null;
+ }
+
+ function read() {
+ try {
+ return window.localStorage.getItem(KEY);
+ } catch (error) {
+ return null;
+ }
+ }
+
+ function write(language) {
+ try {
+ window.localStorage.setItem(KEY, language);
+ } catch (error) {
+ /* storage is blocked: the language still applies to the open page */
+ }
+ }
+
+ function labelsOf(tabSet) {
+ return Array.prototype.filter.call(tabSet.children, function (element) {
+ return element.classList.contains("sd-tab-label");
+ });
+ }
+
+ function apply(language, keep) {
+ document.querySelectorAll(".sd-tab-set").forEach(function (tabSet) {
+ if (tabSet === keep) return;
+ var labels = labelsOf(tabSet);
+ var matches = labels.filter(function (label) {
+ return languageOf(label) === language;
+ });
+ if (!matches.length) return;
+ var shown = labels.filter(function (label) {
+ return label.previousElementSibling && label.previousElementSibling.checked;
+ })[0];
+ if (shown && languageOf(shown) === language) return;
+ matches[0].previousElementSibling.checked = true;
+ });
+ }
+
+ document.addEventListener("click", function (event) {
+ var label = event.target.closest && event.target.closest(".sd-tab-label");
+ if (!label) return;
+ var language = languageOf(label);
+ if (!language) return;
+ write(language);
+ apply(language, label.closest(".sd-tab-set"));
+ });
+
+ /* The language was changed in another tab of the site. */
+ window.addEventListener("storage", function (event) {
+ if (event.key === KEY && event.newValue) apply(event.newValue);
+ });
+
+ /* After the listeners of sphinx-design, which restores its own choice from
+ * sessionStorage, so that the stored language wins. */
+ document.addEventListener("DOMContentLoaded", function () {
+ window.setTimeout(function () {
+ var language = read();
+ if (language) apply(language);
+ }, 0);
+ });
+})();
diff --git a/docs/assets/gecko-icon-color.png b/docs/assets/gecko-icon-color.png
deleted file mode 100644
index a572c50..0000000
Binary files a/docs/assets/gecko-icon-color.png and /dev/null differ
diff --git a/docs/assets/gecko-icon-white.png b/docs/assets/gecko-icon-white.png
deleted file mode 100644
index 3278b98..0000000
Binary files a/docs/assets/gecko-icon-white.png and /dev/null differ
diff --git a/docs/assets/gecko-logo.png b/docs/assets/gecko-logo.png
deleted file mode 100644
index 267e9cc..0000000
Binary files a/docs/assets/gecko-logo.png and /dev/null differ
diff --git a/docs/conf.py b/docs/conf.py
index 6e9a547..c2e156e 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,9 +1,8 @@
-"""Sphinx configuration for the gecko-docs documentation.
+"""Sphinx configuration for the GECKO documentation.
-`.readthedocs.yaml` builds from this file. Configuration mirrors
-raven-docs' `docs/conf.py` (same extensions, same theme, same generator
-pattern), since gecko-docs tracks the same house style and the two sites
-should behave the same way for a reader moving between them.
+`.readthedocs.yaml` builds from this file; requirements.txt lists the Python
+dependencies this build needs. The standard is the `docs-site` plugin of
+CompMetEng/skills; a difference from the template is listed in DESIGN.md.
"""
from __future__ import annotations
@@ -16,53 +15,59 @@
"myst_parser",
"sphinx_design",
"sphinx_copybutton",
- # GECKO's introduction covers the kcat/MW stoichiometry math with LaTeX
- # ($$ ... $$); dollarmath (below) only parses that into math nodes, this
- # extension is what actually renders them via MathJax. RAVEN's docs have
- # no equations, so raven-docs' conf.py doesn't need it.
+ # For a Python side that builds on cobrapy: scripts/autolink.py links
+ # cobrapy names through this inventory.
+ "sphinx.ext.intersphinx",
+ # The GECKO introduction has equations ($$ ... $$): dollarmath below parses
+ # them into math nodes and this extension renders them with MathJax.
"sphinx.ext.mathjax",
]
+intersphinx_mapping = {"cobra": ("https://cobrapy.readthedocs.io/en/latest/", None)}
+
source_suffix = {".md": "markdown"}
root_doc = "index"
myst_enable_extensions = [
- "colon_fence", # the ::: directive spelling used throughout this site
+ "colon_fence", # the ::: directive spelling used throughout the site
"deflist",
"attrs_inline",
"dollarmath",
]
myst_heading_anchors = 4
+# Straight quotes stay straight: the API pages quote MATLAB and Python
+# literals such as 'rxns', which must paste back into code unchanged.
+smartquotes = False
exclude_patterns = [
"_build",
"conf.py",
- # Generated by scripts/gen_api_pages_sphinx.py at build time, present only
- # after that script has run once.
]
+# External links that reject crawlers go here, each with the reason.
+linkcheck_ignore: list[str] = []
+linkcheck_timeout = 20
+linkcheck_retries = 2
+
html_theme = "pydata_sphinx_theme"
# pydata-sphinx-theme's own template appends "-- {{ project }}" to whatever
-# html_title is, so "GECKO" here would double up with project = "GECKO"
-# above into a duplicated page title.
+# html_title is, so the project name must not be repeated here.
html_title = "GECKO documentation"
-html_favicon = "assets/gecko-icon-green.png"
+html_favicon = "_static/gecko-icon.png"
html_static_path = ["_static"]
html_css_files = ["custom.css"]
-# The "view source" link points at this generated MyST file, not anything a
-# reader can act on the way an edit-on-GitHub link would.
+# Remembers the language of the code tabs across pages, browser tabs and visits.
+html_js_files = ["language-sync.js"]
+# The source pages are generated MyST files, not something a reader can edit.
html_show_sourcelink = False
html_theme_options = {
"show_nav_level": 1,
"navigation_depth": 4,
- # Only the active path expands; everything else stays collapsed until
- # clicked, rather than the whole tree opening at once.
+ # Only the active path expands.
"collapse_navigation": True,
"show_toc_level": 2,
"use_edit_page_button": False,
- # Two separate GitHub links, one per implementation, rather than the
- # single "github_url" shortcut (which only points at one repository).
"icon_links": [
{
"name": "GECKO (MATLAB) on GitHub",
@@ -78,11 +83,10 @@
},
],
"navbar_end": ["theme-switcher", "navbar-icon-links"],
- # The full wordmark, not just the icon, in the navbar; there is no
- # separate dark-mode wordmark asset, so both modes use the same file.
+ # The wordmark carries the project name, so there is no navbar logo text.
"logo": {
- "image_light": "assets/gecko-logo.png",
- "image_dark": "assets/gecko-logo.png",
+ "image_light": "_static/gecko-logo.png",
+ "image_dark": "_static/gecko-logo-white.png",
},
"announcement": (
"This site documents GECKO 4.0.0b1 "
@@ -97,18 +101,18 @@
def setup(app):
- """Run the API-reference generator before Sphinx reads the source tree.
+ """Generate the API reference and link the guide pages to it.
- Statically extracts full docstrings for both GECKO (MATLAB, via direct
- help-block parsing) and geckopy (Python, via griffe used standalone) and
- writes plain MyST pages -- there is no maintained Sphinx equivalent of
- mkdocstrings / mkdocstrings-matlab (both MkDocs-only live-rendering
- plugins), the same conclusion raven-docs reached for RAVEN / raven-toolbox.
+ The generator collects from the tracked submodules and writes static pages
+ before Sphinx's own file discovery runs; scripts/autolink.py then links
+ function names on the guide pages.
"""
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
- import gen_api_pages_sphinx
+ import autolink
+ import gen_api_pages
- gen_api_pages_sphinx.generate(Path(__file__).resolve().parent)
+ gen_api_pages.generate(Path(__file__).resolve().parent)
+ return autolink.setup(app)
diff --git a/docs/guide/data-folder-files.md b/docs/guide/data-folder-files.md
index abe5221..f2a6103 100644
--- a/docs/guide/data-folder-files.md
+++ b/docs/guide/data-folder-files.md
@@ -1,5 +1,7 @@
# Files in the data folder
+
+
Every file a project's `data` folder can hold, and where each one gets used
or produced. File formats (TSV, JSON) are the same in both languages, so a
`data` folder built with one toolbox can be reused with the other; see
diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md
index 437f3cd..415c141 100644
--- a/docs/guide/introduction.md
+++ b/docs/guide/introduction.md
@@ -1,5 +1,7 @@
# Introduction to ecModels
+
+
Genome-scale metabolic models (GEMs) let you explore metabolism
mathematically, and have accelerated work in biotechnology, biomedicine and
fundamental biology. A conventional GEM reconstructs all known metabolic
diff --git a/docs/guide/model-adapter-parameters.md b/docs/guide/model-adapter-parameters.md
index e223d2f..941b8b3 100644
--- a/docs/guide/model-adapter-parameters.md
+++ b/docs/guide/model-adapter-parameters.md
@@ -1,5 +1,7 @@
# Model adapter parameters
+
+
The core `obj.params` entries in the MATLAB model adapter, and the
corresponding TOML key in the Python `model_adapter.toml` (dotted keys are
`[section]` tables). Example values come from the `full_ecModel` tutorial;
diff --git a/docs/guide/yaml-format.md b/docs/guide/yaml-format.md
index 54160a2..c64f220 100644
--- a/docs/guide/yaml-format.md
+++ b/docs/guide/yaml-format.md
@@ -1,5 +1,7 @@
# ecModel YAML format
+
+
This page defines the YAML format GECKO and geckopy use to save and load
ecModels. It is reference material, not a task: both toolboxes read and
write the same file, so there is no MATLAB-vs-Python distinction to draw
diff --git a/docs/index.md b/docs/index.md
index 7c35cfc..2e6d141 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,11 +1,12 @@
# GECKO
-
-
-

-
MATLAB & Python
+
+
+

+

+
MATLAB & Python
-
Reconstruction, simulation and analysis of enzyme-constrained metabolic models (ecModels): a MATLAB toolbox built on RAVEN, and geckopy, a Python port built on cobrapy.
+
Reconstruction, simulation and analysis of enzyme-constrained metabolic models (ecModels): a MATLAB toolbox built on RAVEN, and geckopy, a Python port built on cobrapy.
## Install
diff --git a/requirements-sphinx.txt b/requirements.txt
similarity index 100%
rename from requirements-sphinx.txt
rename to requirements.txt
diff --git a/scripts/api_index.py b/scripts/api_index.py
index 0fde5fd..193799b 100644
--- a/scripts/api_index.py
+++ b/scripts/api_index.py
@@ -1,69 +1,41 @@
"""Shared index of the two toolboxes' public APIs, collected from submodule source.
-Both the API-reference generator (``gen_api_pages_sphinx.py``) and any future
-name checker need to know which functions actually exist in GECKO (MATLAB)
-and geckopy (Python). Collecting that in one place means the prose can be
-validated against exactly the same index the reference pages are built from,
-so a function name that no longer resolves cannot slip through in one while
-the other is regenerated.
-
-Collection is static in both languages: ``.m`` files are read as text and
-Python modules are parsed with ``ast``. Neither MATLAB nor an installed
-geckopy is required.
+The API-reference generator (``gen_api_pages.py``), the linker
+(``autolink.py``) and the name checker (``check_names.py``) need to know which
+functions exist in the MATLAB toolbox and in the Python package. Collecting that
+in one place means the prose is validated against exactly the index the
+reference pages are built from, so a function name that no longer resolves
+cannot slip through in one while the other is regenerated.
+
+Collection is static in both languages: ``.m`` files are read as text and Python
+modules are parsed with ``ast``. Neither MATLAB nor the installed Python package
+is required. The names, paths and categories come from ``project.yml``.
"""
from __future__ import annotations
import ast
-import os
import re
+import sys
from pathlib import Path
-ROOT = Path(__file__).resolve().parent.parent
-
-# The submodules are the source of truth for a real build. For local work they
-# can be pointed elsewhere -- e.g. at a working checkout of either toolbox --
-# which is useful when a submodule cannot be moved to the tracked commit.
-GECKO = Path(os.environ.get("GECKO_DOCS_GECKO_SRC", ROOT / "GECKO" / "src"))
-PYPKG = Path(
- os.environ.get(
- "GECKO_DOCS_PYTHON_SRC", ROOT / "geckopy" / "src" / "geckopy"
- )
-)
-
-# GECKO src categories to document, in nav order: (folder, page title). GECKO
-# dropped the src/geckomat nesting level and split what used to be a single
-# "utilities" catch-all into several categories. Pruned to existing folders
-# at build time; "deprecated" (backward-compat aliases, e.g. selectKcatValue
-# for assignKcatValues) and "dlkcat-gecko" (the Python/Docker DLKcat runner,
-# not MATLAB) are intentionally excluded.
-MATLAB_CATEGORIES = [
- ("change_model", "Build & edit ecModel"),
- ("download_databases", "Download databases"),
- ("enzyme_usage", "Enzyme usage"),
- ("flux_analysis", "Flux analysis"),
- ("gather_kcats", "Gather kcats"),
- ("get_enzyme_data", "Enzyme & EC data"),
- ("io", "Input / output"),
- ("kcat_tuning", "kcat sensitivity tuning"),
- ("limit_proteins", "Limit proteins"),
- ("model_adapter", "Model adapter"),
- ("utilities", "Utilities"),
-]
-
-# Friendly titles for geckopy packages. ec_model is geckopy's counterpart to
-# change_model; adapter to model_adapter.
-PY_PACKAGE_TITLES = {
- "ec_model": "Build & edit ecModel",
- "gather_kcats": "Gather kcats",
- "get_enzyme_data": "Enzyme & EC data",
- "kcat_tuning": "kcat sensitivity tuning",
- "limit_proteins": "Limit proteins",
- "adapter": "Model adapter",
- "utilities": "Utilities",
- "databases": "Databases",
- "_toplevel": "Top-level",
-}
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+import project as _project # noqa: E402
+
+PROJECT = _project.load()
+ROOT = _project.ROOT
+
+# The submodules are the source of truth for a real build. For local work the
+# environment variables DOCS_MATLAB_SRC and DOCS_PYTHON_SRC point at a working
+# checkout instead, which is useful when a submodule cannot be moved to the
+# tracked commit.
+MATLAB = PROJECT.matlab_src
+PYPKG = PROJECT.python_src
+# Top-level categories to document, in navigation order: (folder, page title).
+MATLAB_CATEGORIES = PROJECT.categories
+# Titles for the Python sub-packages.
+PY_PACKAGE_TITLES = PROJECT.package_titles
def norm(name: str) -> str:
@@ -88,10 +60,11 @@ def cell(text: str) -> str:
def is_matlab_function(path: Path) -> bool:
"""True if the .m file declares a function (i.e. not a plain script).
- Read as utf-8-sig: some of GECKO's .m files carry a UTF-8 byte-order mark,
+ Read as utf-8-sig: some .m files carry a UTF-8 byte-order mark,
and a leading U+FEFF is not whitespace to ``str.strip()``, so the opening
``function`` line would not be recognised and the file would be indexed as
- a script -- dropping it from the API reference without any error.
+ a script -- dropping it from the API reference and from the name checker's
+ index without any error.
"""
try:
with path.open(encoding="utf-8-sig", errors="ignore") as fh:
@@ -108,10 +81,8 @@ def is_matlab_function(path: Path) -> bool:
def is_matlab_classdef(path: Path) -> bool:
"""True if the .m file declares a class rather than a function or script.
- GECKO's classdef files (``model_adapter/``) put their help comment
- *before* the ``classdef`` line, the reverse of a function's help block,
- which is why classdef needs its own detector and its own summary
- extractor below rather than reusing :func:`is_matlab_function`.
+ A classdef file puts its help comment *before* the ``classdef`` line, the
+ reverse of a function, whose help follows the ``function`` line.
"""
try:
with path.open(encoding="utf-8-sig", errors="ignore") as fh:
@@ -125,90 +96,116 @@ def is_matlab_classdef(path: Path) -> bool:
return False
-def matlab_classdef_summary(path: Path, fname: str) -> str:
- """First descriptive line of a classdef's leading MATLAB help block."""
+def _strip_comment(line: str) -> str:
+ text = line.strip()[1:]
+ return text[1:] if text.startswith(" ") else text
+
+
+def matlab_classdef_help_block(path: Path) -> list[str]:
+ """Help lines of a classdef file: the comment block before ``classdef``."""
try:
with path.open(encoding="utf-8-sig", errors="ignore") as fh:
- lines = fh.readlines()
+ lines = fh.read().splitlines()
except OSError:
- return ""
-
+ return []
+ i = 0
+ while i < len(lines) and not lines[i].strip():
+ i += 1
help_lines: list[str] = []
- for line in lines:
- stripped = line.strip()
- if stripped.startswith("%"):
- help_lines.append(stripped.lstrip("%").strip())
- elif stripped == "" and not help_lines:
- continue
- else:
- break
+ while i < len(lines) and lines[i].strip().startswith("%"):
+ help_lines.append(_strip_comment(lines[i]))
+ i += 1
+ while help_lines and not help_lines[-1].strip():
+ help_lines.pop()
+ return help_lines
- cleaned = [h for h in help_lines if h]
- if not cleaned:
- return ""
- first = cleaned[0]
- if first.lower().startswith(fname.lower()):
- rest = first[len(fname):].strip(" -:\t")
- if rest:
- return rest
- if len(cleaned) > 1:
- return cleaned[1]
- return ""
- return first
+def is_documented_matlab_object(path: Path) -> bool:
+ """A function, or a class that has a help block (an undocumented class is skipped)."""
+ if is_matlab_function(path):
+ return True
+ return is_matlab_classdef(path) and any(line.strip() for line in matlab_classdef_help_block(path))
-def matlab_summary(path: Path, fname: str) -> str:
- """First descriptive line of a function's MATLAB help block."""
+
+def matlab_help_block(path: Path) -> tuple[str, list[str]]:
+ """(signature, help lines) of a MATLAB function file.
+
+ The signature is the ``function`` line, with any ``...`` continuation
+ lines joined onto it. The help lines are the comment block that follows,
+ with the ``%`` and the one space after it removed, so the author's
+ indentation and blank lines are kept. A classdef file has no signature, and
+ its help block comes before the ``classdef`` line.
+ """
+ if is_matlab_classdef(path):
+ return "", matlab_classdef_help_block(path)
try:
with path.open(encoding="utf-8-sig", errors="ignore") as fh:
- lines = fh.readlines()
+ lines = fh.read().splitlines()
except OSError:
- return ""
-
+ return "", []
+
+ i = 0
+ while i < len(lines) and not lines[i].strip().startswith("function"):
+ i += 1
+ if i == len(lines):
+ return "", []
+ signature = [lines[i].strip()]
+ while signature[-1].endswith("...") and i + 1 < len(lines):
+ i += 1
+ signature.append(lines[i].rstrip())
+ i += 1
+
+ while i < len(lines) and not lines[i].strip():
+ i += 1
help_lines: list[str] = []
- started = False
- for line in lines:
- stripped = line.strip()
- if not started:
- if stripped.startswith("function"):
- started = True
- continue
- if stripped.startswith("%"):
- help_lines.append(stripped.lstrip("%").strip())
- elif stripped == "" and not help_lines:
- continue
- else:
- break
-
- cleaned = [h for h in help_lines if h]
- if not cleaned:
- return ""
- # The first help line is often just the function name; strip it.
- first = cleaned[0]
+ while i < len(lines) and lines[i].strip().startswith("%"):
+ help_lines.append(_strip_comment(lines[i]))
+ i += 1
+ while help_lines and not help_lines[-1].strip():
+ help_lines.pop()
+ return "\n".join(signature), help_lines
+
+
+def matlab_summary_and_body(path: Path, fname: str) -> tuple[str, list[str]]:
+ """(summary, remaining help lines) of a MATLAB function.
+
+ The first help line is often just the function name, or the name followed
+ by the summary; the name is stripped, and a line holding nothing else
+ gives way to the next one.
+ """
+ _signature, help_lines = matlab_help_block(path)
+ nonblank = [n for n, line in enumerate(help_lines) if line.strip()]
+ if not nonblank:
+ return "", []
+ first_idx = nonblank[0]
+ first = help_lines[first_idx].strip()
if first.lower().startswith(fname.lower()):
rest = first[len(fname):].strip(" -:\t")
if rest:
- return rest
- if len(cleaned) > 1:
- return cleaned[1]
- return ""
- return first
+ return rest, help_lines[first_idx + 1:]
+ if len(nonblank) > 1:
+ return help_lines[nonblank[1]].strip(), help_lines[nonblank[1] + 1:]
+ return "", []
+ return first, help_lines[first_idx + 1:]
+
+
+def matlab_summary(path: Path, fname: str) -> str:
+ """First descriptive line of a function's MATLAB help block."""
+ return matlab_summary_and_body(path, fname)[0]
def collect_matlab() -> dict[str, list[dict]]:
- """category -> sorted list of {name, summary} for documented functions
- and classes."""
+ """category -> sorted list of {name, summary} for documented functions."""
cats: dict[str, list[dict]] = {}
for folder, _title in MATLAB_CATEGORIES:
funcs: dict[str, str] = {}
- base = GECKO / folder
+ base = MATLAB / folder
for m in base.rglob("*.m"):
if m.stem == "Contents":
continue
- if is_matlab_function(m):
- funcs.setdefault(m.stem, matlab_summary(m, m.stem))
- elif is_matlab_classdef(m):
- funcs.setdefault(m.stem, matlab_classdef_summary(m, m.stem))
+ if not is_documented_matlab_object(m):
+ continue
+ funcs.setdefault(m.stem, matlab_summary(m, m.stem))
cats[folder] = [
{"name": n, "summary": funcs[n]}
for n in sorted(funcs, key=str.lower)
@@ -217,20 +214,18 @@ def collect_matlab() -> dict[str, list[dict]]:
def collect_matlab_all() -> dict[str, str]:
- """Every GECKO function or class name -> the category folder it lives in.
+ """Every function or class name of the MATLAB toolbox -> its category folder.
- Unlike :func:`collect_matlab` this also walks the folders that are not
- part of the documented category list, so a name checker recognises a
- function that exists in the toolbox but has no reference page.
+ Unlike :func:`collect_matlab` this also walks the folders that are not part
+ of the documented category list, so the name checker recognises a function
+ that exists in the toolbox but has no reference page.
"""
found: dict[str, str] = {}
- for m in GECKO.rglob("*.m"):
- rel = m.relative_to(GECKO)
- if rel.parts[0] in {"dlkcat-gecko"}:
- continue
- if m.stem == "Contents":
+ for m in MATLAB.rglob("*.m"):
+ rel = m.relative_to(MATLAB)
+ if rel.parts[0] in PROJECT.excluded_folders:
continue
- if not (is_matlab_function(m) or is_matlab_classdef(m)):
+ if m.stem == "Contents" or not (is_matlab_function(m) or is_matlab_classdef(m)):
continue
found.setdefault(m.stem, rel.parts[0])
return found
@@ -240,7 +235,7 @@ def collect_matlab_all() -> dict[str, str]:
# Collect Python functions and classes #
# --------------------------------------------------------------------------- #
def module_dotted(path: Path) -> str:
- """geckopy/src/geckopy/ec_model/build.py -> geckopy.ec_model.build"""
+ """
/src//io/excel.py -> .io.excel"""
rel = path.relative_to(PYPKG.parent) # relative to src/
parts = list(rel.with_suffix("").parts)
if parts[-1] == "__init__":
@@ -248,6 +243,15 @@ def module_dotted(path: Path) -> str:
return ".".join(parts)
+def is_deprecated_alias(summary: str) -> bool:
+ """True for a backward-compatibility alias, whose docstring starts "Deprecated".
+
+ The alias sits next to its replacement. It is left out of the reference, which
+ also keeps name matching from pairing a MATLAB function with the shim.
+ """
+ return summary.startswith("Deprecated")
+
+
def py_summary(node: ast.AST) -> str:
"""First non-empty line of a node's docstring."""
doc = ast.get_docstring(node) or ""
@@ -257,8 +261,13 @@ def py_summary(node: ast.AST) -> str:
return ""
-def collect_python() -> list[dict]:
- """List of {name, ident, package, summary} for public top-level objects."""
+def collect_python(include_deprecated: bool = False) -> list[dict]:
+ """List of {name, ident, package, summary} for public top-level objects.
+
+ Deprecated aliases are left out unless ``include_deprecated`` is set. The
+ reference pages leave them out; the name checker keeps them, because a page
+ may name an alias on purpose.
+ """
objects: list[dict] = []
for py in PYPKG.rglob("*.py"):
try:
@@ -273,18 +282,7 @@ def collect_python() -> list[dict]:
if node.name.startswith("_"):
continue
summary = py_summary(node)
- # geckopy keeps a backward-compat alias for the original
- # MATLAB name alongside its canonical replacement in the same
- # module (e.g. get_ec_from_database next to
- # fill_eccodes_from_database), each marked with a docstring
- # starting "Deprecated alias for ...". MATLAB's own
- # backward-compat aliases live in a separate excluded
- # deprecated/ folder; Python's have no such physical
- # separation, so they are excluded here by convention
- # instead. Skipping them also keeps norm()-based matching
- # from pairing a MATLAB name with the deprecated shim that
- # happens to share its transliterated spelling.
- if summary.startswith("Deprecated"):
+ if is_deprecated_alias(summary) and not include_deprecated:
continue
ident = f"{dotted}.{node.name}" if dotted else node.name
objects.append(
diff --git a/scripts/autolink.py b/scripts/autolink.py
new file mode 100644
index 0000000..4f5a4f7
--- /dev/null
+++ b/scripts/autolink.py
@@ -0,0 +1,178 @@
+"""Link function names on the guide pages to their API reference.
+
+A Sphinx extension. On ``source-read`` it rewrites the inline code spans of
+``guide/*`` pages, outside fenced code, headings and existing links:
+
+* a MATLAB function becomes a link to its entry in the generated MATLAB API
+ pages (``api/matlab/.md``);
+* a Python function or class becomes a link to its entry in the Python
+ API pages (``api/python/.md``);
+* a cobrapy object becomes a link to its entry in cobrapy's documentation,
+ looked up in cobrapy's intersphinx inventory. Single names such as ``pfba``
+ or ``Model`` are only looked up in a table row that carries the cobrapy
+ badge; elsewhere a cobrapy name has to be dotted (``Model.optimize``) or
+ snake_case (``read_sbml_model``), so ordinary words are never linked.
+
+Every span in a table row is linked, so the "Functions on this page" table
+links each entry; in prose only the first mention of a name per section is.
+The cobrapy badge in a table row points at the first cobrapy object linked in
+that row instead of cobrapy's index page.
+
+The page sources on disk are not changed, so ``scripts/run_examples.py``, which
+reads them directly, sees the same code blocks.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from api_index import PROJECT, collect_matlab, collect_python, slug # noqa: E402
+from project import COBRAPY_URL # noqa: E402
+from sphinx.util import logging # noqa: E402
+
+COBRA_INDEX = COBRAPY_URL
+
+_FENCE = re.compile(r"^\s*(```|~~~)")
+_HEADING = re.compile(r"^#{1,6}\s")
+# An inline code span that is not the text of a markdown link ([`x`](...)) and
+# not the content of a role ({bdg-link-secondary}`...`).
+_SPAN = re.compile(r"(?`")
+_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$")
+_SNAKE = re.compile(r"^[a-z][a-z0-9]+(?:_[a-z0-9]+)+$")
+_COBRA_TYPES = ("py:function", "py:method", "py:class", "py:attribute", "py:property")
+# Python names that mean something else on the guide pages: `main` is a
+# command-line entry point in the package and a branch name in the prose.
+_NOT_LINKED = {"main"}
+
+
+class Linker:
+ def __init__(self, cobra_inventory: dict | None) -> None:
+ self.matlab = {f["name"]: folder for folder, funcs in collect_matlab().items() for f in funcs}
+ packages: dict[str, set[str]] = {}
+ for obj in collect_python():
+ packages.setdefault(obj["name"], set()).add(obj["package"])
+ # A name defined in two packages has no single target: leave it unlinked.
+ self.python = {name: next(iter(pk)) for name, pk in packages.items() if len(pk) == 1}
+ self.cobra = self._cobra_index(cobra_inventory or {})
+ self.counts = {"matlab": 0, "python": 0, "cobrapy": 0, "badges": 0}
+
+ @staticmethod
+ def _cobra_index(inventory: dict) -> dict[str, str]:
+ """Every dotted suffix of a cobrapy object's full name -> its URL.
+
+ ``cobra.core.model.Model.optimize`` is reachable as ``optimize``,
+ ``Model.optimize``, ``model.Model.optimize`` and so on. Where two
+ objects share a suffix, the one with the shorter full name wins.
+ """
+ best: dict[str, tuple[int, str, str]] = {}
+ for role in _COBRA_TYPES:
+ for full, entry in inventory.get(role, {}).items():
+ url = getattr(entry, "uri", None) or entry[2]
+ parts = full.split(".")
+ for i in range(len(parts)):
+ key = ".".join(parts[i:])
+ rank = (len(parts), full, url)
+ if key not in best or rank < best[key]:
+ best[key] = rank
+ return {key: url for key, (_n, _f, url) in best.items()}
+
+ def target(self, token: str, in_badged_row: bool) -> tuple[str, str] | None:
+ """(kind, href) for a code span, or None to leave it as it is."""
+ if token in _NOT_LINKED:
+ return None
+ if token in self.matlab:
+ return "matlab", f"../api/matlab/{self.matlab[token]}.md#{slug(token)}"
+ if token in self.python:
+ return "python", f"../api/python/{self.python[token]}.md#{slug(token)}"
+ if not _IDENT.match(token):
+ return None
+ bare = token.removeprefix("cobra.")
+ if "." in bare or _SNAKE.match(bare) or in_badged_row:
+ url = self.cobra.get(bare) or self.cobra.get(token)
+ if url:
+ return "cobrapy", url
+ return None
+
+ def link_page(self, text: str) -> str:
+ out: list[str] = []
+ in_fence = False
+ seen: set[str] = set()
+ for line in text.split("\n"):
+ if _FENCE.match(line):
+ in_fence = not in_fence
+ out.append(line)
+ continue
+ if in_fence:
+ out.append(line)
+ continue
+ if _HEADING.match(line):
+ seen = set()
+ out.append(line)
+ continue
+ out.append(self._link_line(line, seen))
+ return "\n".join(out)
+
+ def _link_line(self, line: str, seen: set[str]) -> str:
+ if "`" not in line:
+ return line
+ in_table = line.lstrip().startswith("|")
+ badged = in_table and bool(_BADGE.search(line))
+ first_cobra: list[str] = []
+
+ def replace(m: re.Match) -> str:
+ raw = m.group(1)
+ token = raw.strip()
+ token = token[:-2] if token.endswith("()") else token
+ found = self.target(token, badged)
+ if found is None:
+ return m.group(0)
+ kind, href = found
+ if not in_table:
+ if token in seen:
+ return m.group(0)
+ seen.add(token)
+ if kind == "cobrapy" and not first_cobra:
+ first_cobra.append(href)
+ self.counts[kind] += 1
+ return f"[`{raw}`]({href})"
+
+ line = _SPAN.sub(replace, line)
+ if badged and first_cobra:
+ line = _BADGE.sub(
+ lambda _m: f"{{bdg-link-secondary}}`cobrapy <{first_cobra[0]}>`", line, count=1
+ )
+ self.counts["badges"] += 1
+ return line
+
+
+def _cobra_inventory(app) -> dict | None:
+ named = getattr(app.env, "intersphinx_named_inventory", None) or {}
+ return named.get("cobra")
+
+
+def setup(app):
+ state: dict[str, Linker] = {}
+
+ def on_source_read(app, docname, source):
+ if not docname.startswith("guide/"):
+ return
+ if "linker" not in state:
+ state["linker"] = Linker(_cobra_inventory(app))
+ source[0] = state["linker"].link_page(source[0])
+
+ def on_build_finished(app, exception):
+ if "linker" in state:
+ c = state["linker"].counts
+ logging.getLogger(__name__).info(
+ "autolink: %d %s, %d %s and %d cobrapy links; %d cobrapy badges",
+ c["matlab"], PROJECT.matlab_name, c["python"], PROJECT.python_name, c["cobrapy"], c["badges"],
+ )
+
+ app.connect("source-read", on_source_read)
+ app.connect("build-finished", on_build_finished)
+ return {"parallel_read_safe": True, "parallel_write_safe": True}
diff --git a/scripts/check_names.py b/scripts/check_names.py
new file mode 100644
index 0000000..ad60939
--- /dev/null
+++ b/scripts/check_names.py
@@ -0,0 +1,193 @@
+"""Report prose that names a function that does not exist.
+
+The hand-written pages describe two moving APIs, and names drift in by analogy:
+`camelCase` rewritten as `snake_case` on the assumption that the Python function
+must be called that. This script resolves every function-shaped identifier in
+the hand-written pages against the same index the API reference is generated
+from (:mod:`api_index`) and lists anything that does not resolve. Generated
+pages are skipped: they come from the index by construction.
+
+Two kinds of identifier are checked:
+
+* **inline code spans** -- ``camelCase`` tokens must be MATLAB functions,
+ ``snake_case`` tokens must be Python functions;
+* **calls inside ```python blocks** -- every called name must be a
+ Python function, a known cobrapy name, or allow-listed.
+
+Anything that is legitimately not a function -- model struct fields
+(``metDeltaG``), cobrapy names (``read_sbml_model``), reaction ids, file names --
+belongs in ``known_names.txt`` next to this file, one per line. Keep that list
+short: every entry is a name nothing can verify for you.
+
+Run it with:
+
+ python scripts/check_names.py
+"""
+
+from __future__ import annotations
+
+import ast
+import re
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from api_index import PROJECT, collect_matlab_all, collect_python # noqa: E402
+
+HERE = Path(__file__).resolve().parent
+ALLOWLIST_FILE = HERE / "known_names.txt"
+
+# Pages generated at build time from the API index; validating them against the
+# same index would be circular.
+GENERATED_PREFIXES = ("api/",)
+GENERATED_FILES = {"matlab-vs-python.md"}
+
+_INLINE_CODE = re.compile(r"`([^`\n]+)`")
+_FENCE = re.compile(r"^([ \t]*)```", re.M)
+_PY_BLOCK = re.compile(r"```python\n(.*?)^[ \t]*```", re.S | re.M)
+
+# camelCase: starts lower, contains an uppercase run, no separators.
+_CAMEL = re.compile(r"^[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*$")
+# snake_case: at least two alphanumeric characters before the first underscore,
+# so reaction ids such as r_4041 and s_0584 are not mistaken for functions.
+_SNAKE = re.compile(r"^[a-z][a-z0-9]+(?:_[a-z0-9]+)+$")
+
+_BUILTINS = {
+ "print", "len", "open", "sorted", "list", "dict", "set", "str", "int",
+ "float", "range", "enumerate", "zip", "sum", "min", "max", "abs", "round",
+}
+
+
+def load_allowlist() -> set[str]:
+ """Names that are legitimately not functions of either toolbox."""
+ if not ALLOWLIST_FILE.is_file():
+ return set()
+ entries = set()
+ for line in ALLOWLIST_FILE.read_text(encoding="utf-8").splitlines():
+ line = line.split("#", 1)[0].strip()
+ if line:
+ entries.add(line)
+ return entries
+
+
+class Index:
+ """The names that exist, collected once per build."""
+
+ def __init__(self) -> None:
+ self.matlab = set(collect_matlab_all())
+ self.python = {obj["name"] for obj in collect_python(include_deprecated=True)}
+ self.allowed = load_allowlist()
+
+ def is_generated(self, src_path: str) -> bool:
+ rel = src_path.replace("\\", "/")
+ return rel in GENERATED_FILES or rel.startswith(GENERATED_PREFIXES)
+
+ def problems(self, markdown: str, src_path: str) -> list[str]:
+ if self.is_generated(src_path):
+ return []
+ found: list[str] = []
+ found += self._check_inline(markdown)
+ found += self._check_python_blocks(markdown)
+ return found
+
+ # -- inline code spans -------------------------------------------------- #
+ def _check_inline(self, markdown: str) -> list[str]:
+ problems = []
+ for token in _INLINE_CODE.findall(_strip_fences(markdown)):
+ token = token.strip()
+ if token.endswith("()"):
+ token = token[:-2]
+ if not token or token in self.allowed:
+ continue
+ if _CAMEL.match(token):
+ if token not in self.matlab:
+ problems.append(
+ f"`{token}` looks like a {PROJECT.matlab_name} function but no such .m "
+ f"file exists on the tracked branch"
+ )
+ elif _SNAKE.match(token):
+ if token not in self.python:
+ problems.append(
+ f"`{token}` looks like a {PROJECT.python_name} function but is "
+ f"not defined in the package"
+ )
+ return problems
+
+ # -- python code blocks ------------------------------------------------- #
+ def _check_python_blocks(self, markdown: str) -> list[str]:
+ problems = []
+ for block in _PY_BLOCK.findall(markdown):
+ code = _dedent_block(block)
+ try:
+ tree = ast.parse(code)
+ except SyntaxError:
+ continue # illustrative fragment, not runnable code
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ func = node.func
+ # Only bare calls are checked. A method call belongs to whatever
+ # object is on the left (a cobra.Model, a DataFrame, a Path), not
+ # to the package namespace, so there is nothing here that
+ # could verify it -- and allow-listing every .items(), .replace()
+ # and .rglob() the examples use would dilute the check that does
+ # work. The failure this exists to catch, an invented function
+ # imported and called (``from .io import
+ # import_model``), is a bare call and is still caught.
+ name = func.id if isinstance(func, ast.Name) else None
+ if name is None or name in _BUILTINS or name in self.allowed:
+ continue
+ if name not in self.python:
+ problems.append(
+ f"`{name}(...)` is called in a Python example but is "
+ f"neither a {PROJECT.python_name} function nor an allow-listed name"
+ )
+ return problems
+
+
+def _strip_fences(markdown: str) -> str:
+ """Remove fenced code blocks so only prose code spans are inspected."""
+ out, inside = [], False
+ for line in markdown.splitlines():
+ if _FENCE.match(line):
+ inside = not inside
+ continue
+ if not inside:
+ out.append(line)
+ return "\n".join(out)
+
+
+def _dedent_block(block: str) -> str:
+ """Undo the indentation a fenced block carries inside a content tab."""
+ lines = block.splitlines()
+ bodies = [ln for ln in lines if ln.strip()]
+ if not bodies:
+ return block
+ pad = min(len(ln) - len(ln.lstrip()) for ln in bodies)
+ return "\n".join(ln[pad:] if len(ln) >= pad else ln for ln in lines)
+
+
+# --------------------------------------------------------------------------- #
+# Standalone #
+# --------------------------------------------------------------------------- #
+def main() -> int:
+ index = Index()
+ docs = HERE.parent / "docs"
+ total = 0
+ for md in sorted(docs.rglob("*.md")):
+ rel = md.relative_to(docs).as_posix()
+ problems = index.problems(md.read_text(encoding="utf-8"), rel)
+ for problem in problems:
+ print(f"{rel}: {problem}")
+ total += len(problems)
+ print(
+ f"\n{total} problem(s); index holds {len(index.matlab)} {PROJECT.matlab_name} and "
+ f"{len(index.python)} {PROJECT.python_name} names, plus {len(index.allowed)} "
+ f"allow-listed."
+ )
+ return 1 if total else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/check_site.py b/scripts/check_site.py
new file mode 100644
index 0000000..91853f9
--- /dev/null
+++ b/scripts/check_site.py
@@ -0,0 +1,399 @@
+#!/usr/bin/env python3
+"""Check a documentation site against the docs-site standard.
+
+Usage: check_site.py [SITE_DIR] [--against PLUGIN_SCRIPTS_DIR]
+
+With --against, the scripts in SITE_DIR/scripts that also exist in the plugin are
+compared with the plugin copies, and a difference is an error.
+
+Prints one finding per line as ``CODE severity path[:line] message`` and exits
+with 1 when there is an error. Warnings do not change the exit code.
+
+Codes: L layout, N name, C configuration, D design file, S style, P page.
+"""
+
+from __future__ import annotations
+
+import configparser
+import re
+import subprocess
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+REQUIRED_FILES = [
+ ".gitmodules",
+ ".readthedocs.yaml",
+ "CONTRIBUTING.md",
+ "DESIGN.md",
+ "README.md",
+ "requirements.txt",
+ "docs/conf.py",
+ "docs/index.md",
+ "docs/_static/custom.css",
+ "docs/_static/language-sync.js",
+ "scripts/gen_api_pages.py",
+ "scripts/project.yml",
+]
+
+# Scripts that every site copies unchanged from the plugin.
+SHARED_SCRIPTS = [
+ "project.py", "api_index.py", "gen_api_pages.py", "autolink.py",
+ "check_names.py", "run_examples.py", "check_site.py",
+]
+
+WRONG_NAMES = {
+ "requirements-sphinx.txt": "requirements.txt",
+ "scripts/gen_api_pages_sphinx.py": "scripts/gen_api_pages.py",
+}
+
+GENERATED = ["docs/api/matlab", "docs/api/python", "docs/matlab-vs-python.md"]
+
+DESIGN_HEADINGS = ["Purpose", "Decisions log"]
+
+TEXT_SUFFIXES = {".md", ".py", ".yml", ".yaml", ".css", ".cff", ".txt", ".toml"}
+SKIP_DIRS = {".git", ".venv", "_build", "__pycache__", "node_modules"}
+
+EM_DASH = chr(0x2014)
+AI_WORDS = re.compile(r"\b(claude|anthropic|chatgpt|copilot|co-authored-by)\b", re.I)
+AI_BRANCH = re.compile(r"\b(claude|anthropic|codex|copilot|chatgpt)/", re.I)
+# Files that state the rules or implement the check name what they forbid.
+RULE_FILES = {"scripts/check_site.py", "CONTRIBUTING.md"}
+
+GUIDE_EXPECTED = ["Functions on this page", "What can go wrong", "See also"]
+# A page that explains a concept instead of a task opts out of the anatomy.
+CONCEPT_MARKER = ""
+
+
+@dataclass
+class Finding:
+ code: str
+ severity: str
+ path: str
+ message: str
+ line: int | None = None
+
+ def __str__(self) -> str:
+ where = f"{self.path}:{self.line}" if self.line else self.path
+ return f"{self.code} {self.severity} {where} {self.message}"
+
+
+def tracked_files(site: Path) -> list[Path]:
+ """Files of the site, excluding submodules and build output."""
+ try:
+ out = subprocess.run(
+ ["git", "-C", str(site), "ls-files", "-z"],
+ check=True, capture_output=True, text=True,
+ ).stdout
+ files = [site / p for p in out.split("\0") if p]
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ files = [p for p in site.rglob("*") if p.is_file()]
+ submodules = set(submodule_paths(site))
+ keep = []
+ for p in files:
+ rel = p.relative_to(site)
+ if any(part in SKIP_DIRS for part in rel.parts):
+ continue
+ if rel.parts and rel.parts[0] in submodules:
+ continue
+ if p.is_file():
+ keep.append(p)
+ return keep
+
+
+def submodule_paths(site: Path) -> list[str]:
+ cfg = read_gitmodules(site)
+ return [cfg[s].get("path", "") for s in cfg.sections() if cfg.has_option(s, "path")]
+
+
+def read_gitmodules(site: Path) -> configparser.RawConfigParser:
+ cfg = configparser.RawConfigParser()
+ path = site / ".gitmodules"
+ if path.is_file():
+ cfg.read(path, encoding="utf-8")
+ return cfg
+
+
+def check_layout(site: Path) -> list[Finding]:
+ out = []
+ for rel in REQUIRED_FILES:
+ if not (site / rel).exists():
+ out.append(Finding("L", "error", rel, "required file is missing"))
+ for old, new in WRONG_NAMES.items():
+ if (site / old).exists():
+ out.append(Finding("N", "error", old, f"rename to {new}"))
+ return out
+
+
+def check_generated(site: Path, files: list[Path]) -> list[Finding]:
+ out = []
+ rels = {str(p.relative_to(site)).replace("\\", "/") for p in files}
+ for gen in GENERATED:
+ hit = sorted(r for r in rels if r == gen or r.startswith(gen + "/"))
+ if hit:
+ out.append(Finding("L", "error", gen, f"generated at build time but committed ({len(hit)} file(s))"))
+ gitignore = site / ".gitignore"
+ text = gitignore.read_text(encoding="utf-8") if gitignore.is_file() else ""
+ for gen in GENERATED:
+ if gen not in text and gen + "/" not in text:
+ out.append(Finding("L", "warning", ".gitignore", f"does not list the generated path {gen}"))
+ return out
+
+
+def check_gitmodules(site: Path) -> list[Finding]:
+ out = []
+ cfg = read_gitmodules(site)
+ for section in cfg.sections():
+ name = section
+ if not cfg.has_option(section, "branch"):
+ out.append(Finding("C", "error", ".gitmodules", f"{name}: no branch is set"))
+ if cfg.get(section, "shallow", fallback="").lower() != "true":
+ out.append(Finding("C", "error", ".gitmodules", f"{name}: shallow = true is not set"))
+ return out
+
+
+def find_line(text: str, pattern: str) -> int | None:
+ for i, line in enumerate(text.splitlines(), 1):
+ if re.search(pattern, line):
+ return i
+ return None
+
+
+def check_readthedocs(site: Path) -> list[Finding]:
+ path = site / ".readthedocs.yaml"
+ if not path.is_file():
+ return []
+ text = path.read_text(encoding="utf-8")
+ out = []
+ if not re.search(r"^submodules:\s*\n\s+include:\s*all", text, re.M):
+ out.append(Finding("C", "error", ".readthedocs.yaml", "submodules.include must be all"))
+ if not re.search(r"^\s+recursive:\s*true", text, re.M):
+ out.append(Finding("C", "error", ".readthedocs.yaml", "submodules.recursive must be true"))
+ if not re.search(r"^\s+fail_on_warning:\s*true", text, re.M):
+ out.append(Finding("C", "error", ".readthedocs.yaml", "sphinx.fail_on_warning must be true"))
+ if not re.search(r"requirements:\s*requirements\.txt", text):
+ out.append(Finding("C", "error", ".readthedocs.yaml", "python.install must use requirements.txt"))
+ return out
+
+
+def check_conf(site: Path) -> list[Finding]:
+ path = site / "docs" / "conf.py"
+ if not path.is_file():
+ return []
+ text = path.read_text(encoding="utf-8")
+ out = []
+ expected = {
+ r"^smartquotes\s*=\s*False": "smartquotes = False",
+ r"^html_show_sourcelink\s*=\s*False": "html_show_sourcelink = False",
+ r'^html_theme\s*=\s*"pydata_sphinx_theme"': 'html_theme = "pydata_sphinx_theme"',
+ r'"collapse_navigation":\s*True': '"collapse_navigation": True',
+ r'"default_mode":\s*"auto"': '"default_mode": "auto"',
+ }
+ for pattern, label in expected.items():
+ if not re.search(pattern, text, re.M):
+ out.append(Finding("C", "error", "docs/conf.py", f"{label} is not set"))
+ for ext in ("myst_parser", "sphinx_design", "sphinx_copybutton"):
+ if f'"{ext}"' not in text:
+ out.append(Finding("C", "error", "docs/conf.py", f"extension {ext} is not enabled"))
+ if re.search(r"html_(logo|favicon)\s*=\s*\"assets/", text) or re.search(r'"image_(light|dark)":\s*"assets/', text):
+ out.append(Finding("C", "error", "docs/conf.py", "logo and favicon paths are relative to _static/, not assets/"))
+ if not re.search(r"html_js_files\s*=\s*\[[^\]]*[\"']language-sync\.js[\"']", text):
+ out.append(Finding("C", "error", "docs/conf.py", "html_js_files does not list language-sync.js"))
+ logo = re.search(r'"image_light":\s*"([^"]+)"\s*,\s*"image_dark":\s*"([^"]+)"', text, re.S)
+ if logo and logo.group(1) == logo.group(2):
+ out.append(Finding("C", "error", "docs/conf.py", "the dark-mode logo is the same file as the light-mode logo"))
+ return out
+
+
+def check_css(site: Path) -> list[Finding]:
+ path = site / "docs" / "_static" / "custom.css"
+ if not path.is_file():
+ return []
+ text = path.read_text(encoding="utf-8")
+ out = []
+ for cls in ("site-hero", "site-logo-row", "site-tag", "site-tagline", "site-logo-light", "site-logo-dark"):
+ if f".{cls}" not in text:
+ out.append(Finding("C", "error", "docs/_static/custom.css", f"class .{cls} is not defined"))
+ for var in ("--site-accent", "--pst-color-primary", "--pst-color-link"):
+ if var not in text:
+ out.append(Finding("C", "error", "docs/_static/custom.css", f"{var} is not set"))
+ for m in re.finditer(r"--pst-color-(?:primary|link)\s*:\s*([^;]+);", text):
+ value = m.group(1).strip()
+ if re.fullmatch(r"\d+\s*,\s*\d+\s*,\s*\d+", value):
+ out.append(Finding("C", "error", "docs/_static/custom.css", f"colour value '{value}' is a list of numbers, not a colour"))
+ return out
+
+
+def check_design(site: Path) -> list[Finding]:
+ path = site / "DESIGN.md"
+ if not path.is_file():
+ return []
+ text = path.read_text(encoding="utf-8")
+ headings = [re.sub(r"^[#\s\d.]+", "", h).strip().lower() for h in re.findall(r"^#{1,3} .*$", text, re.M)]
+ out = []
+ for want in DESIGN_HEADINGS:
+ if not any(h.startswith(want.lower()) for h in headings):
+ out.append(Finding("D", "error", "DESIGN.md", f"no section '{want}'"))
+ return out
+
+
+def check_text(site: Path, files: list[Path]) -> list[Finding]:
+ out = []
+ for p in files:
+ rel = str(p.relative_to(site)).replace("\\", "/")
+ if p.suffix not in TEXT_SUFFIXES:
+ continue
+ if rel.startswith("docs/api/") or rel.endswith("known_names.txt"):
+ continue
+ try:
+ text = p.read_text(encoding="utf-8")
+ except UnicodeDecodeError:
+ continue
+ for i, line in enumerate(text.splitlines(), 1):
+ if EM_DASH in line:
+ out.append(Finding("S", "error", rel, "em dash", i))
+ if rel in RULE_FILES:
+ continue
+ m = AI_WORDS.search(line)
+ if m and p.suffix != ".txt":
+ out.append(Finding("S", "error", rel, f"mentions '{m.group(0)}'", i))
+ if AI_BRANCH.search(line):
+ out.append(Finding("S", "error", rel, "branch name with a tool name", i))
+ return out
+
+
+def hard_wrapped_paragraphs(text: str) -> int:
+ """Count prose paragraphs of three or more consecutive lines."""
+ count = 0
+ run = 0
+ in_code = False
+ for raw in text.splitlines() + [""]:
+ line = raw.rstrip()
+ if line.lstrip().startswith(("```", ":::")):
+ in_code = not in_code if line.lstrip().startswith("```") else in_code
+ run = 0
+ continue
+ prose = (
+ bool(line.strip())
+ and not in_code
+ and not line.lstrip().startswith(("|", "#", "-", "*", "+", ">", "<", ":", "{", "!"))
+ and not re.match(r"\s*\d+[.)] ", line)
+ and not re.match(r"\s*\[.*\]:", line)
+ )
+ if prose:
+ run += 1
+ else:
+ if run >= 3:
+ count += 1
+ run = 0
+ return count
+
+
+def heading_skips(text: str) -> tuple[int, int, int] | None:
+ """(line, previous level, level) of the first heading that skips a level."""
+ prev = 0
+ in_code = False
+ for i, line in enumerate(text.splitlines(), 1):
+ if line.lstrip().startswith("```"):
+ in_code = not in_code
+ continue
+ m = re.match(r"(#{1,6}) \S", line)
+ if in_code or not m:
+ continue
+ level = len(m.group(1))
+ if prev and level > prev + 1:
+ return i, prev, level
+ prev = level
+ return None
+
+
+def check_pages(site: Path, files: list[Path]) -> list[Finding]:
+ out = []
+ for p in files:
+ rel = str(p.relative_to(site)).replace("\\", "/")
+ if p.suffix != ".md" or not rel.startswith("docs/") or rel.startswith("docs/api/"):
+ continue
+ text = p.read_text(encoding="utf-8")
+ n = hard_wrapped_paragraphs(text)
+ if n:
+ out.append(Finding("S", "warning", rel, f"{n} hard-wrapped paragraph(s); one line per paragraph"))
+ skip = heading_skips(text)
+ if skip:
+ out.append(Finding("P", "error", rel, f"heading level jumps from H{skip[1]} to H{skip[2]} (strict build warns)", skip[0]))
+ if rel.startswith("docs/guide/") and p.name != "index.md" and CONCEPT_MARKER not in text:
+ for h in GUIDE_EXPECTED:
+ if not re.search(rf"^#+ {re.escape(h)}", text, re.M):
+ out.append(Finding("P", "warning", rel, f"no '{h}' section"))
+ return out
+
+
+def aggregate(findings: list[Finding], examples: int = 3) -> list[str]:
+ """One line per kind of warning, so a site-wide habit is one line, not one per page."""
+ lines = []
+ groups: dict[tuple[str, str], list[Finding]] = {}
+ for f in findings:
+ if f.severity == "error":
+ lines.append(str(f))
+ else:
+ key = (f.code, re.sub(r"\d+ hard-wrapped paragraph\(s\)", "hard-wrapped paragraphs", f.message))
+ groups.setdefault(key, []).append(f)
+ for (code, message), group in groups.items():
+ if len(group) == 1:
+ lines.append(str(group[0]))
+ continue
+ shown = ", ".join(f.path for f in group[:examples])
+ more = f" and {len(group) - examples} more" if len(group) > examples else ""
+ lines.append(f"{code} warning {len(group)} files: {message} ({shown}{more})")
+ return lines
+
+
+def check_shared_scripts(site: Path, plugin_scripts: Path) -> list[Finding]:
+ out = []
+ for name in SHARED_SCRIPTS:
+ mine, theirs = site / "scripts" / name, plugin_scripts / name
+ if not mine.is_file() or not theirs.is_file():
+ continue
+ if mine.read_bytes() != theirs.read_bytes():
+ out.append(Finding("N", "error", f"scripts/{name}", "differs from the plugin copy"))
+ return out
+
+
+def run(site: Path, plugin_scripts: Path | None = None) -> list[Finding]:
+ files = tracked_files(site)
+ findings: list[Finding] = []
+ findings += check_layout(site)
+ findings += check_generated(site, files)
+ findings += check_gitmodules(site)
+ findings += check_readthedocs(site)
+ findings += check_conf(site)
+ findings += check_css(site)
+ findings += check_design(site)
+ findings += check_text(site, files)
+ findings += check_pages(site, files)
+ if plugin_scripts is not None:
+ findings += check_shared_scripts(site, plugin_scripts)
+ return findings
+
+
+def main(argv: list[str]) -> int:
+ args = argv[1:]
+ plugin_scripts = None
+ if "--against" in args:
+ i = args.index("--against")
+ plugin_scripts = Path(args[i + 1]).resolve()
+ del args[i:i + 2]
+ site = Path(args[0] if args else ".").resolve()
+ if not site.is_dir():
+ print(f"{site} is not a directory", file=sys.stderr)
+ return 2
+ findings = run(site, plugin_scripts)
+ for line in aggregate(findings):
+ print(line)
+ errors = sum(1 for f in findings if f.severity == "error")
+ warnings = len(findings) - errors
+ print(f"{errors} error(s), {warnings} warning(s)")
+ return 1 if errors else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/scripts/curated_pairs.yml b/scripts/curated_pairs.yml
index 774a81f..b4a2b65 100644
--- a/scripts/curated_pairs.yml
+++ b/scripts/curated_pairs.yml
@@ -1,6 +1,6 @@
# Hand-curated half of the MATLAB <-> Python function mapping.
#
-# gen_api_pages_sphinx.py pairs functions automatically when the two names
+# gen_api_pages.py pairs functions automatically when the two names
# normalise to the same string (loadEcModel <-> load_ec_model). That only
# catches the mechanical cases: geckopy renamed part of the API, so some real
# counterparts do not match by name and have to be recorded here.
diff --git a/scripts/gen_api_pages_sphinx.py b/scripts/gen_api_pages.py
similarity index 59%
rename from scripts/gen_api_pages_sphinx.py
rename to scripts/gen_api_pages.py
index 1dbee17..b258124 100644
--- a/scripts/gen_api_pages_sphinx.py
+++ b/scripts/gen_api_pages.py
@@ -1,18 +1,11 @@
-"""API reference generator for the Sphinx/pydata-sphinx-theme build.
-
-Run from ``docs/conf.py`` (Sphinx's ``setup()`` hook), before Sphinx reads the
-source tree.
-
-**No live rendering plugin.** MkDocs-material's version of this reference
-would defer per-function help text to live plugins: mkdocstrings' Python
-handler (griffe) and a separate mkdocstrings-matlab handler (tree-sitter).
-Neither has a drop-in Sphinx equivalent verified to work the same way -- the
-MATLAB one in particular is a bespoke handler with no direct autodoc-family
-counterpart. Rather than depend on an unverified third-party extension, this
-generator extracts the *full* help text itself (the collection layer already
-reads the source; ``api_index.py`` just discards everything past the first
-line) and writes fully static pages. No live directive, no package install,
-no MATLAB runtime.
+"""API reference generator.
+
+Runs from ``docs/conf.py`` (Sphinx's ``setup()`` hook), before Sphinx reads
+the source tree. It extracts the full help text of every function from the
+MATLAB and Python submodules (the collection layer is shared with
+``api_index.py``) and writes static pages: no live directive, no package
+install, no MATLAB runtime. Names, paths and categories come from
+``project.yml``.
"""
from __future__ import annotations
@@ -24,101 +17,38 @@
import yaml
+from project import COBRAPY_URL # noqa: E402
+
from api_index import ( # noqa: E402
- GECKO as MATLAB,
+ MATLAB,
MATLAB_CATEGORIES,
+ PROJECT,
PY_PACKAGE_TITLES,
PYPKG,
cell,
- is_matlab_classdef,
- is_matlab_function,
+ is_deprecated_alias,
+ is_documented_matlab_object,
+ matlab_help_block,
+ matlab_summary_and_body,
module_dotted,
norm,
slug,
)
import ast
+import re
+import textwrap
# --------------------------------------------------------------------------- #
# Full-text collection (api_index.py's matlab_summary / py_summary keep only
# the first line; these keep everything). #
# --------------------------------------------------------------------------- #
-def matlab_help(path: Path, fname: str) -> tuple[str, str]:
- """(summary, full help text) for one MATLAB function."""
- try:
- lines = path.read_text(encoding="utf-8-sig", errors="ignore").splitlines()
- except OSError:
- return "", ""
-
- help_lines: list[str] = []
- started = False
- for line in lines:
- stripped = line.strip()
- if not started:
- if stripped.startswith("function"):
- started = True
- continue
- if stripped.startswith("%"):
- help_lines.append(stripped.lstrip("%").strip())
- elif stripped == "" and not help_lines:
- continue
- else:
- break
-
- cleaned = [h for h in help_lines if h]
- if not cleaned:
- return "", ""
- first = cleaned[0]
- if first.lower().startswith(fname.lower()):
- rest = first[len(fname):].strip(" -:\t")
- if rest:
- summary, body = rest, cleaned[1:]
- elif len(cleaned) > 1:
- summary, body = cleaned[1], cleaned[2:]
- else:
- summary, body = "", []
- else:
- summary = first
- body = cleaned[1:]
- return summary, "\n".join(body)
-
-
-def matlab_classdef_help(path: Path, fname: str) -> tuple[str, str]:
- """(summary, full help text) for one MATLAB classdef, from its leading
- comment block (before the ``classdef`` line), the reverse of a
- function's trailing help block; see ``is_matlab_classdef``."""
- try:
- lines = path.read_text(encoding="utf-8-sig", errors="ignore").splitlines()
- except OSError:
- return "", ""
-
- help_lines: list[str] = []
- for line in lines:
- stripped = line.strip()
- if stripped.startswith("%"):
- help_lines.append(stripped.lstrip("%").strip())
- elif stripped == "" and not help_lines:
- continue
- else:
- break
-
- cleaned = [h for h in help_lines if h]
- if not cleaned:
- return "", ""
- first = cleaned[0]
- if first.lower().startswith(fname.lower()):
- rest = first[len(fname):].strip(" -:\t")
- if rest:
- summary, body = rest, cleaned[1:]
- elif len(cleaned) > 1:
- summary, body = cleaned[1], cleaned[2:]
- else:
- summary, body = "", []
- else:
- summary = first
- body = cleaned[1:]
- return summary, "\n".join(body)
+def matlab_help(path: Path, fname: str) -> tuple[str, str, str]:
+ """(signature, summary, rest of the help text) for one MATLAB function."""
+ signature, _lines = matlab_help_block(path)
+ summary, body = matlab_summary_and_body(path, fname)
+ return signature, summary, "\n".join(body)
def py_help(node: ast.AST) -> tuple[str, str]:
@@ -146,15 +76,13 @@ def collect_matlab_full() -> dict[str, list[dict]]:
if not base.is_dir():
continue
for m in base.rglob("*.m"):
- if m.stem == "Contents":
- continue
- if is_matlab_function(m):
- summary, body = matlab_help(m, m.stem)
- elif is_matlab_classdef(m):
- summary, body = matlab_classdef_help(m, m.stem)
- else:
+ if m.stem == "Contents" or not is_documented_matlab_object(m):
continue
- funcs.setdefault(m.stem, {"name": m.stem, "summary": summary, "body": body, "path": m})
+ signature, summary, body = matlab_help(m, m.stem)
+ funcs.setdefault(
+ m.stem,
+ {"name": m.stem, "signature": signature, "summary": summary, "body": body, "path": m},
+ )
cats[folder] = [funcs[n] for n in sorted(funcs, key=str.lower)]
return cats
@@ -175,15 +103,10 @@ def collect_python_full() -> list[dict]:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
if node.name.startswith("_"):
continue
+ ident = f"{dotted}.{node.name}" if dotted else node.name
summary, rest = py_help(node)
- # See the matching skip in api_index.py's collect_python():
- # geckopy's backward-compat aliases for the original MATLAB
- # names live inline next to their canonical replacements, not
- # in a separate excluded folder as on the MATLAB side, and
- # are marked with a "Deprecated alias for ..." docstring.
- if summary.startswith("Deprecated"):
+ if is_deprecated_alias(summary):
continue
- ident = f"{dotted}.{node.name}" if dotted else node.name
sig = ""
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
sig = f"({ast.unparse(node.args)})" if hasattr(ast, "unparse") else ""
@@ -201,14 +124,91 @@ def collect_python_full() -> list[dict]:
return objects
+# --------------------------------------------------------------------------- #
+# Help-text rendering #
+# --------------------------------------------------------------------------- #
+# The MATLAB % help blocks and the structured Python docstrings both use
+# numpydoc section headers ("Parameters" over a dashed underline), so one
+# parser serves both. Parameter sections become definition lists, Examples a
+# code block, and anything else passes through as prose. Help text without any
+# section header is kept as the author laid it out: in a text block for MATLAB,
+# whose help is written for the command window, and as prose for Python.
+PARAM_SECTIONS = {
+ "Parameters", "Name-Value Arguments", "Other Parameters", "Returns",
+ "Yields", "Raises", "Attributes",
+}
+_SECTION_HEADER = re.compile(r"^([A-Z][A-Za-z -]+)\n(-{3,})$", re.M)
+_RST_ROLE = re.compile(r":(?:py:)?(?:func|class|meth|attr|mod|data|obj|exc):`~?([^`]+)`")
+
+
+def myst_inline(text: str) -> str:
+ """RST roles and double-backtick literals as MyST code spans."""
+ text = _RST_ROLE.sub(lambda m: f"`{m.group(1)}`", text)
+ return re.sub(r"``([^`]+)``", r"`\1`", text)
+
+
+def split_sections(doc: str) -> list[tuple[str | None, str]]:
+ parts: list[tuple[str | None, str]] = []
+ pos, title = 0, None
+ for m in _SECTION_HEADER.finditer(doc):
+ if len(m.group(2)) < len(m.group(1).strip()) - 1:
+ continue # a dashed line too short to underline this title
+ parts.append((title, doc[pos:m.start()]))
+ title, pos = m.group(1).strip(), m.end()
+ parts.append((title, doc[pos:]))
+ return parts
+
+
+def param_entries(body: str) -> list[tuple[str, str, str]]:
+ entries: list[list] = []
+ for line in body.strip("\n").splitlines():
+ if line and not line[0].isspace():
+ name, _, typ = line.partition(" : ")
+ entries.append([name.strip(), typ.strip(), []])
+ elif entries:
+ entries[-1][2].append(line)
+ return [(n, t, textwrap.dedent("\n".join(d)).strip()) for n, t, d in entries]
+
+
+def render_help(doc: str, lang: str) -> list[str]:
+ """Markdown lines for a help text's body (everything after the summary)."""
+ doc = textwrap.dedent(doc).strip("\n")
+ if not doc.strip():
+ return []
+ sections = split_sections(doc)
+ if all(title is None for title, _ in sections):
+ if lang == "matlab":
+ return ["```text", doc, "```", ""]
+ return [myst_inline(doc), ""]
+ out: list[str] = []
+ for title, body in sections:
+ body = textwrap.dedent(body).strip("\n")
+ if title is None:
+ if body.strip():
+ out += [myst_inline(body), ""]
+ continue
+ out += [f":::{{rubric}} {title}", ":::", ""]
+ if title in PARAM_SECTIONS:
+ for name, typ, desc in param_entries(body):
+ term = f"`{name}`" + (f" : *{typ}*" if typ else "")
+ lines = myst_inline(desc).splitlines() or [""]
+ out += [term, f": {lines[0]}"] + [f" {line}" if line else "" for line in lines[1:]] + [""]
+ elif title == "Examples":
+ fence = "pycon" if ">>>" in body else lang
+ out += [f"```{fence}", body, "```", ""]
+ else:
+ out += [myst_inline(body), ""]
+ return out
+
+
# --------------------------------------------------------------------------- #
# Page rendering #
# --------------------------------------------------------------------------- #
def render_matlab_page(title: str, folder: str, funcs: list[dict]) -> str:
- out = [f"# {title}", "", f"MATLAB functions in `GECKO/src/{folder}` of the GECKO toolbox.", "",
+ out = [f"# {title}", "", f"MATLAB functions in `{PROJECT.matlab_rel}/{folder}` of the {PROJECT.matlab_name} toolbox.", "",
"## Functions", "", "| Function | Summary |", "|---|---|"]
for f in funcs:
- out.append(f"| [`{f['name']}`](#{slug(f['name'])}) | {cell(f['summary'])} |")
+ out.append(f"| [`{f['name']}`](#{slug(f['name'])}) | {cell(myst_inline(f['summary']))} |")
out += ["", "## Reference", ""]
for f in funcs:
# A cross-document markdown link with a #fragment resolves against
@@ -216,18 +216,19 @@ def render_matlab_page(title: str, folder: str, funcs: list[dict]) -> str:
# must auto-slug to exactly `slug(name)` -- true here since it is a
# bare MATLAB identifier, nothing an explicit target would improve.
out += [f"### {f['name']}", ""]
+ if f["signature"]:
+ out += ["```matlab", f["signature"], "```", ""]
if f["summary"]:
- out += [f["summary"], ""]
- if f["body"]:
- out += ["```text", f["body"], "```", ""]
+ out += [myst_inline(f["summary"]), ""]
+ out += render_help(f["body"], "matlab")
return "\n".join(out) + "\n"
def render_python_page(title: str, dotted: str, objs: list[dict]) -> str:
- out = [f"# {title} (Python)", "", f"`geckopy` objects in `{dotted}`.", "",
+ out = [f"# {title} (Python)", "", f"`{PROJECT.python_name}` objects in `{dotted}`.", "",
"## Functions", "", "| Function | Summary |", "|---|---|"]
for o in objs:
- out.append(f"| [`{o['name']}`](#{slug(o['name'])}) | {cell(o['summary'])} |")
+ out.append(f"| [`{o['name']}`](#{slug(o['name'])}) | {cell(myst_inline(o['summary']))} |")
out += ["", "## Reference", ""]
for o in objs:
# The heading is the bare name, not name+signature: a cross-document
@@ -239,15 +240,14 @@ def render_python_page(title: str, dotted: str, objs: list[dict]) -> str:
if o["signature"]:
out += [f"`{o['name']}{o['signature']}`", ""]
if o["summary"]:
- out += [o["summary"], ""]
- if o["body"]:
- out += ["```text", o["body"], "```", ""]
+ out += [myst_inline(o["summary"]), ""]
+ out += render_help(o["body"], "python")
return "\n".join(out) + "\n"
def render_group_index(title: str, intro: str, links: list[tuple[str, str]]) -> str:
stems = [href.rsplit(".", 1)[0] for _title, href in links]
- out = ["---", "icon: material/folder-open", "---", "", f"# {title}", "", intro, "",
+ out = [f"# {title}", "", intro, "",
"```{toctree}", ":hidden:"]
out += stems
out += ["```", ""]
@@ -277,7 +277,9 @@ def generate(docs_root: Path) -> None:
)
matlab_links.append((title, f"{folder}.md"))
(api_dir / "matlab" / "index.md").write_text(
- render_group_index("MATLAB API (GECKO)", "GECKO's MATLAB functions, by category.", matlab_links),
+ render_group_index(
+ f"MATLAB API ({PROJECT.matlab_name})", f"{PROJECT.matlab_name}'s MATLAB functions, by category.", matlab_links
+ ),
encoding="utf-8",
)
@@ -288,42 +290,39 @@ def generate(docs_root: Path) -> None:
for package in sorted(by_package, key=lambda p: (p == "_toplevel", PY_PACKAGE_TITLES.get(p, p).lower())):
objs = sorted(by_package[package], key=lambda o: o["name"].lower())
title = PY_PACKAGE_TITLES.get(package, package)
- dotted = "geckopy" if package == "_toplevel" else f"geckopy.{package}"
+ dotted = PROJECT.python_package if package == "_toplevel" else f"{PROJECT.python_package}.{package}"
(api_dir / "python" / f"{package}.md").write_text(
render_python_page(title, dotted, objs), encoding="utf-8"
)
python_links.append((title, f"{package}.md"))
(api_dir / "python" / "index.md").write_text(
render_group_index(
- "Python API (geckopy)", "geckopy's packages, by category.", python_links
+ f"Python API ({PROJECT.python_name})", f"{PROJECT.python_name}'s packages, by category.", python_links
),
encoding="utf-8",
)
- # --- matlab-vs-python.md: static write, generated at build time.
- # NOTE for Guide-page authors (docs/stage*.md etc.): where a Guide page
- # points a reader at a cobrapy function instead of a geckopy one (see the
- # "Box 2" callouts), link to this page rather than duplicating the
- # mapping in prose -- this generator, not the Guide, owns that table.
+ # --- matlab-vs-python.md
matlab_all = {f["name"]: folder for folder, _t in MATLAB_CATEGORIES for f in matlab.get(folder, [])}
py_by_name = {obj["name"]: obj for obj in python_objs}
curated = yaml.safe_load((Path(__file__).resolve().parent / "curated_pairs.yml").read_text(encoding="utf-8"))
+ M, P = PROJECT.matlab_name, PROJECT.python_name
unresolved: list[str] = []
for m_name, p_name in curated["aliases"].items():
if m_name not in matlab_all:
- unresolved.append(f"aliases: GECKO function '{m_name}' is not on the tracked branch")
+ unresolved.append(f"aliases: {M} function '{m_name}' is not on the tracked branch")
if p_name not in py_by_name:
- unresolved.append(f"aliases: geckopy function '{p_name}' (for {m_name}) does not exist")
+ unresolved.append(f"aliases: {P} function '{p_name}' (for {m_name}) does not exist")
for section in ("cobrapy", "not_ported"):
for m_name in curated[section]:
if m_name not in matlab_all:
- unresolved.append(f"{section}: GECKO function '{m_name}' is not on the tracked branch")
+ unresolved.append(f"{section}: {M} function '{m_name}' is not on the tracked branch")
if unresolved:
listed = "\n - ".join(unresolved)
- raise SystemExit(f"gen_api_pages_sphinx: scripts/curated_pairs.yml is out of date:\n - {listed}")
+ raise SystemExit(f"gen_api_pages: scripts/curated_pairs.yml is out of date:\n - {listed}")
- cobrapy_docs_url = "https://cobrapy.readthedocs.io/en/latest/autoapi/cobra/index.html"
+ cobrapy_docs_url = COBRAPY_URL
# Each row: (matlab name, matlab folder, python-column markdown, summary text).
rows: list[tuple[str, str, str, str]] = []
@@ -357,25 +356,29 @@ def generate(docs_root: Path) -> None:
seen_matlab.add(m_name)
n_cobrapy += 1
+ badge_note = (
+ f"and marked with a {{bdg-link-secondary}}`cobrapy <{cobrapy_docs_url}>` badge where cobrapy "
+ f"covers the function instead of {P}. "
+ if PROJECT.cobrapy else ""
+ )
+ cobrapy_count = f", {n_cobrapy} covered by cobrapy instead of {P}" if PROJECT.cobrapy else ""
lines = [
"# MATLAB vs Python", "",
- "GECKO ships as a MATLAB toolbox and as the Python package **geckopy**. "
+ f"{M} ships as a MATLAB toolbox and as the Python package **{P}**. "
"This page is generated from the source of both toolboxes at build time. "
"Pairs are found automatically where the names match, completed from a "
- "curated list where they do not, and marked with a "
- f"{{bdg-link-secondary}}`cobrapy <{cobrapy_docs_url}>` badge where cobrapy "
- "covers the function instead of geckopy. Every name is verified to exist.",
+ f"curated list where they do not, {badge_note}"
+ "Every name is verified to exist.",
"", "## Paired functions", "",
- f"**{len(rows)}** GECKO functions listed: {n_auto} matched automatically, "
- f"{n_curated} from a curated list, {n_cobrapy} covered by cobrapy instead of "
- "geckopy.",
- "", "| GECKO (MATLAB) | geckopy (Python) | Summary |", "|---|---|---|",
+ f"**{len(rows)}** {M} functions listed: {n_auto} matched automatically, "
+ f"{n_curated} from a curated list{cobrapy_count}.",
+ "", f"| {M} (MATLAB) | {P} (Python) | Summary |", "|---|---|---|",
]
for m_name, m_folder, p_cell, text in sorted(rows, key=lambda r: r[0].lower()):
m_link = f"[`{m_name}`](api/matlab/{m_folder}.md#{slug(m_name)})"
lines.append(f"| {m_link} | {p_cell} | {cell(text)} |")
- lines += ["", "## Deliberately not ported", "", "| GECKO (MATLAB) | Why not |", "|---|---|"]
+ lines += ["", "## Deliberately not ported", "", f"| {M} (MATLAB) | Why not |", "|---|---|"]
for m_name, reason in sorted(curated["not_ported"].items(), key=lambda kv: kv[0].lower()):
folder = matlab_all[m_name]
m_link = f"[`{m_name}`](api/matlab/{folder}.md#{slug(m_name)})"
@@ -385,7 +388,7 @@ def generate(docs_root: Path) -> None:
unmapped = sorted((n for n in matlab_all if n not in accounted), key=str.lower)
if unmapped:
lines += ["", "## Not yet mapped", "",
- f"**{len(unmapped)}** GECKO functions are not yet recorded here.", ""]
+ f"**{len(unmapped)}** {M} functions are not yet recorded here.", ""]
for chunk_start in range(0, len(unmapped), 6):
row = unmapped[chunk_start:chunk_start + 6]
lines.append(", ".join(f"`{n}`" for n in row) + ("," if chunk_start + 6 < len(unmapped) else ""))
diff --git a/scripts/known_names.txt b/scripts/known_names.txt
new file mode 100644
index 0000000..14a5cbe
--- /dev/null
+++ b/scripts/known_names.txt
@@ -0,0 +1,9 @@
+# Names that look like functions but are not functions of either toolbox.
+#
+# check_names.py resolves every camelCase / snake_case identifier in the
+# hand-written pages against the toolbox sources. Anything listed here is exempt.
+# Keep the list short and grouped: each entry is a name that nothing can verify
+# for you, so an entry added carelessly re-opens the hole this check closes.
+
+# -- prose terms ------------------------------------------------------------
+camelCase
diff --git a/scripts/project.py b/scripts/project.py
new file mode 100644
index 0000000..e7f37b3
--- /dev/null
+++ b/scripts/project.py
@@ -0,0 +1,71 @@
+"""Project settings of a documentation site, read from ``scripts/project.yml``.
+
+The scripts of the docs-site plugin (``api_index.py``, ``gen_api_pages.py``,
+``autolink.py``, ``check_names.py``) contain no project name, path or category
+list. Everything that differs between sites is in ``project.yml``; see
+``templates/scripts/project.yml`` for the keys.
+"""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+import yaml
+
+HERE = Path(__file__).resolve().parent
+ROOT = HERE.parent
+
+COBRAPY_URL = "https://cobrapy.readthedocs.io/en/latest/autoapi/cobra/index.html"
+
+# Folders of a MATLAB toolbox that hold no API.
+DEFAULT_EXCLUDED = ("legacy", "external", "software", "testing", "doc")
+
+
+@dataclass(frozen=True)
+class Project:
+ matlab_name: str
+ matlab_src: Path
+ categories: list[tuple[str, str]]
+ excluded_folders: frozenset[str]
+ python_name: str
+ python_package: str
+ python_src: Path
+ package_titles: dict[str, str]
+ cobrapy: bool
+ examples: dict
+
+ @property
+ def matlab_rel(self) -> str:
+ """The MATLAB source folder as written in page text, e.g. ``RAVEN/src``."""
+ try:
+ return self.matlab_src.relative_to(ROOT).as_posix()
+ except ValueError:
+ return self.matlab_src.name
+
+
+def load(config: Path | None = None) -> Project:
+ path = config or HERE / "project.yml"
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
+ matlab, python = data["matlab"], data["python"]
+
+ matlab_src = Path(os.environ.get("DOCS_MATLAB_SRC", ROOT / matlab["path"]))
+ subdir = matlab.get("src_subdir")
+ if subdir and (matlab_src / subdir).is_dir():
+ matlab_src = matlab_src / subdir
+ python_src = Path(os.environ.get("DOCS_PYTHON_SRC", ROOT / python["path"]))
+
+ categories = [(c["folder"], c["title"]) for c in matlab["categories"]]
+ return Project(
+ matlab_name=matlab["name"],
+ matlab_src=matlab_src,
+ categories=categories,
+ excluded_folders=frozenset(matlab.get("excluded_folders", DEFAULT_EXCLUDED)),
+ python_name=python["name"],
+ python_package=python["package"],
+ python_src=python_src,
+ package_titles=dict(python.get("package_titles", {})),
+ cobrapy=bool(data.get("cobrapy", True)),
+ examples=dict(data.get("examples") or {}),
+ )
diff --git a/scripts/project.yml b/scripts/project.yml
new file mode 100644
index 0000000..9cb8654
--- /dev/null
+++ b/scripts/project.yml
@@ -0,0 +1,45 @@
+# Settings of this documentation site that the scripts in this folder read.
+# Nothing else in scripts/ names the project.
+cobrapy: true
+matlab:
+ name: GECKO
+ path: GECKO/src
+ categories:
+ - folder: change_model
+ title: Build & edit ecModel
+ - folder: download_databases
+ title: Download databases
+ - folder: enzyme_usage
+ title: Enzyme usage
+ - folder: flux_analysis
+ title: Flux analysis
+ - folder: gather_kcats
+ title: Gather kcats
+ - folder: get_enzyme_data
+ title: Enzyme & EC data
+ - folder: io
+ title: Input / output
+ - folder: kcat_tuning
+ title: kcat sensitivity tuning
+ - folder: limit_proteins
+ title: Limit proteins
+ - folder: model_adapter
+ title: Model adapter
+ - folder: utilities
+ title: Utilities
+ excluded_folders:
+ - dlkcat-gecko
+python:
+ name: geckopy
+ package: geckopy
+ path: geckopy/src/geckopy
+ package_titles:
+ ec_model: Build & edit ecModel
+ gather_kcats: Gather kcats
+ get_enzyme_data: Enzyme & EC data
+ kcat_tuning: kcat sensitivity tuning
+ limit_proteins: Limit proteins
+ adapter: Model adapter
+ utilities: Utilities
+ databases: Databases
+ _toplevel: Top-level