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

+
+
+ GECKO + GECKO +

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