From 6b4121d47cad247922d0defbcc12c2821be01a97 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 15 Oct 2026 10:19:56 -0600 Subject: [PATCH 1/2] ci: Update plotly.js fetch location --- .github/workflows/build-package.yml | 40 ------ .github/workflows/test-plotlyjs-dev.yml | 78 +++++++++++ commands.py | 170 +++++++++--------------- plotly/offline/_plotlyjs_version.py | 2 +- 4 files changed, 144 insertions(+), 146 deletions(-) create mode 100644 .github/workflows/test-plotlyjs-dev.yml diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml index 0511e7e750f..02d65f8ed41 100644 --- a/.github/workflows/build-package.yml +++ b/.github/workflows/build-package.yml @@ -11,46 +11,6 @@ permissions: contents: read jobs: - plotlyjs-dev-build: - name: plotly.js dev build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.12" - - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "22" - - name: Set up uv - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - - name: Install dependencies - run: | - uv venv - source .venv/bin/activate - uv sync --locked --extra dev_optional - - name: Update plotly.js to dev - run: | - source .venv/bin/activate - python commands.py updateplotlyjsdev - - name: Test core (excluding nodev) - run: | - source .venv/bin/activate - pytest -k 'not nodev' tests/test_core - - name: Build source distribution packages - run: | - source .venv/bin/activate - uv sync --locked --extra dev_build - python -m build --sdist --wheel -o dist - - name: Upload dist artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: plotlyjs-dev-build-dist - path: dist/ - if-no-files-found: error - full-build: name: Full prod build runs-on: ubuntu-latest diff --git a/.github/workflows/test-plotlyjs-dev.yml b/.github/workflows/test-plotlyjs-dev.yml new file mode 100644 index 00000000000..2fc717f4480 --- /dev/null +++ b/.github/workflows/test-plotlyjs-dev.yml @@ -0,0 +1,78 @@ +name: Test plotly.js dev + +# Run codegen and the core tests against the plot-schema.json on plotly.js main +# once a week. This catches plotly.js schema changes that break codegen or +# validation before the next plotly.js release. +on: + schedule: + - cron: "0 9 * * 1" # Monday 09:00 UTC + workflow_dispatch: + inputs: + plotlyjs_ref: + description: "plotly.js branch, tag, or commit SHA to test" + required: false + default: "main" + +permissions: + contents: read + +jobs: + test-plotlyjs-dev: + name: Test against plotly.js dev schema + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Set up uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - name: Install dependencies + run: | + uv venv + source .venv/bin/activate + uv sync --locked --extra dev_optional + - name: Download plot-schema.json from plotly.js + # plotly.js CI keeps test/plot-schema.json identical to the built schema + env: + PLOTLYJS_REF: ${{ inputs.plotlyjs_ref || 'main' }} + run: | + curl -fsSL -o codegen/resources/plot-schema.json \ + "https://raw.githubusercontent.com/plotly/plotly.js/${PLOTLYJS_REF}/test/plot-schema.json" + - name: Run codegen + run: | + source .venv/bin/activate + python commands.py codegen + - name: Test core (excluding nodev) + run: | + source .venv/bin/activate + pytest -k 'not nodev' tests/test_core + + report-failure: + name: Open an issue for a failed scheduled run + needs: test-plotlyjs-dev + # Manual runs can test any plotly.js ref, so only report scheduled runs + if: failure() && github.event_name == 'schedule' + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Open or update the failure issue + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + TITLE: "Weekly plotly.js dev test failed" + run: | + BODY="The weekly test against the plot-schema.json on plotly.js main failed: ${RUN_URL}" + # Comment on an open issue from an earlier failure instead of opening a duplicate + EXISTING=$(gh issue list --state open --search "in:title \"${TITLE}\"" \ + --json number,title --jq "map(select(.title == \"${TITLE}\")) | .[0].number // empty") + if [ -n "${EXISTING}" ]; then + gh issue comment "${EXISTING}" --body "${BODY}" + else + gh issue create --title "${TITLE}" --body "${BODY}" + fi diff --git a/commands.py b/commands.py index a258980555a..269b484a871 100644 --- a/commands.py +++ b/commands.py @@ -1,6 +1,9 @@ """Utility command runner.""" import argparse +import base64 +import hashlib +import io import logging import json import os @@ -11,6 +14,7 @@ import subprocess from subprocess import check_call import sys +import tarfile import time from codegen import perform_codegen, lint_code, reformat_code @@ -116,16 +120,6 @@ def overwrite_schema_local(uri): shutil.copyfile(uri, path) -def overwrite_schema(url): - """Replace plot-schema.json with web copy.""" - - req = requests.get(url) - assert req.status_code == 200 - path = os.path.join(PROJECT_ROOT, "codegen", "resources", "plot-schema.json") - with open(path, "wb") as f: - f.write(req.content) - - def overwrite_bundle_local(uri): """Replace minified JavaScript bundle.json with local copy.""" @@ -133,17 +127,6 @@ def overwrite_bundle_local(uri): shutil.copyfile(uri, path) -def overwrite_bundle(url): - """Replace minified JavaScript bundle.json with web copy.""" - - req = requests.get(url) - print("url:", url) - assert req.status_code == 200 - path = os.path.join(PROJECT_ROOT, "plotly", "package_data", "plotly.min.js") - with open(path, "wb") as f: - f.write(req.content) - - def overwrite_plotlyjs_version_file(plotlyjs_version): """Replace plotly.js version file.""" @@ -152,28 +135,12 @@ def overwrite_plotlyjs_version_file(plotlyjs_version): f.write( """\ # DO NOT EDIT -# This file is generated by the updatebundle commands.py command +# This file is generated by the updateplotlyjs and updateplotlyjsdev commands in commands.py __plotlyjs_version__ = "{plotlyjs_version}" """.format(plotlyjs_version=plotlyjs_version) ) -def get_latest_commit_info(repo, branch): - """Get latest commit info from GitHub API.""" - - url = "https://api.github.com/repos/{repo}/commits/{branch}".format( - repo=repo, branch=branch - ) - req = requests.get(url) - assert req.status_code == 200, "Failed to fetch commit info: %s" % req.text - commit = req.json() - - return { - "vcs_revision": commit["sha"], - "committer_date": commit["commit"]["committer"]["date"], - } - - def get_bundle_schema_local(local): """Get paths to local build files.""" @@ -183,75 +150,74 @@ def get_bundle_schema_local(local): return plotly_archive, plotly_bundle, plotly_schemas -def get_github_urls(repo, revision): - """Get URLs for required files from GitHub.""" - - raw = f"https://raw.githubusercontent.com/{repo}/{revision}" - archive_url = f"https://github.com/{repo}/tarball/{revision}" - bundle_url = raw + "/dist/plotly.min.js" - schema_url = raw + "/dist/plot-schema.json" - - return archive_url, bundle_url, schema_url - - -def update_schema(plotly_js_version): - """Download latest version of the plot-schema JSON file.""" - - url = ( - "https://raw.githubusercontent.com/plotly/plotly.js/" - "v%s/dist/plot-schema.json" % plotly_js_version - ) - overwrite_schema(url) +def update_bundle_schema(plotly_js_version): + """ + Replace plotly.min.js and plot-schema.json with the copies from the + plotly.js npm package, and write the plotly.js version file. + Raise an AssertionError if a download fails, if the package does not match + the integrity hash that npm publishes, or if a file is missing. Write no + files in those cases. + """ -def update_bundle(plotly_js_version): - """Download latest version of the plotly.js bundle.""" + metadata_url = f"https://registry.npmjs.org/plotly.js/{plotly_js_version}" + req = requests.get(metadata_url) + assert req.status_code == 200, f"Failed to fetch npm metadata: {metadata_url}" + dist = req.json()["dist"] - url = ( - "https://raw.githubusercontent.com/plotly/plotly.js/" - "v%s/dist/plotly.min.js" % plotly_js_version - ) - overwrite_bundle(url) + url = dist["tarball"] + print("url:", url) + req = requests.get(url) + assert req.status_code == 200, f"Failed to download: {url}" + + # npm publishes the integrity as "sha512-" + algorithm, expected = dist["integrity"].split("-", 1) + actual = base64.b64encode(hashlib.new(algorithm, req.content).digest()).decode() + assert actual == expected, f"Integrity mismatch for {url}" + + destinations = { + "package/dist/plotly.min.js": os.path.join( + PROJECT_ROOT, "plotly", "package_data", "plotly.min.js" + ), + "package/dist/plot-schema.json": os.path.join( + PROJECT_ROOT, "codegen", "resources", "plot-schema.json" + ), + } + # Read every file before writing any, so a failure leaves no partial update + with tarfile.open(fileobj=io.BytesIO(req.content), mode="r:gz") as tar: + contents = { + path: tar.extractfile(member).read() + for member in tar + if (path := destinations.get(member.name)) + } + assert len(contents) == len(destinations), f"Missing files in {url}" + for path, data in contents.items(): + with open(path, "wb") as f: + f.write(data) - # Write plotly.js version file - plotlyjs_version = plotly_js_version - overwrite_plotlyjs_version_file(plotlyjs_version) + overwrite_plotlyjs_version_file(plotly_js_version) def update_plotlyjs(plotly_js_version, outdir): """Update project to a new version of plotly.js.""" - update_bundle(plotly_js_version) - update_schema(plotly_js_version) + update_bundle_schema(plotly_js_version) perform_codegen(outdir) install_js_deps(local=None, build=True) -# FIXME: switch to argparse -def update_schema_bundle_from_main(args): - """Update the plotly.js schema and bundle from main.""" - if args.local is None: - build_info = get_latest_commit_info(args.devrepo, args.devbranch) - archive_url, bundle_url, schema_url = get_github_urls( - args.devrepo, build_info["vcs_revision"] - ) - - # Update bundle in package data - overwrite_bundle(bundle_url) - - # Update schema in package data - overwrite_schema(schema_url) - else: - # this info could be more informative but it doesn't seem as - # useful in a local context and requires dependencies and - # programming. - build_info = {"vcs_revision": "local", "committer_date": str(time.time())} - args.devrepo = args.local - args.devbranch = "" +def update_schema_bundle_from_local(plotlyjs_path): + """ + Update the plotly.js schema and bundle from a local plotly.js repository. - archive_uri, bundle_uri, schema_uri = get_bundle_schema_local(args.local) - overwrite_bundle_local(bundle_uri) - overwrite_schema_local(schema_uri) + The repository must contain a build in dist/ and a plotly.js.tgz archive + from `npm pack`. + """ + # npm runs from js/, so a relative path would resolve from the wrong folder + plotlyjs_path = os.path.abspath(plotlyjs_path) + archive_uri, bundle_uri, schema_uri = get_bundle_schema_local(plotlyjs_path) + overwrite_bundle_local(bundle_uri) + overwrite_schema_local(schema_uri) # Update plotly.js url in package.json package_json_path = os.path.join(NODE_ROOT, "package.json") @@ -259,24 +225,20 @@ def update_schema_bundle_from_main(args): package_json = json.load(f) # Replace version with bundle url - package_json["dependencies"]["plotly.js"] = ( - archive_url if args.local is None else archive_uri - ) + package_json["dependencies"]["plotly.js"] = archive_uri with open(package_json_path, "w") as f: json.dump(package_json, f, indent=2) # update plotly.js version in _plotlyjs_version - rev = build_info["vcs_revision"] - date = str(build_info["committer_date"]) - version = "_".join([args.devrepo, args.devbranch, date[:10], rev[:8]]) - overwrite_plotlyjs_version_file(version) - install_js_deps(args.local) + timestamp = str(int(time.time())) + overwrite_plotlyjs_version_file(f"{plotlyjs_path}__{timestamp}_local") + install_js_deps(plotlyjs_path) def update_plotlyjs_dev(args, outdir): """Update project to a new development version of plotly.js.""" - update_schema_bundle_from_main(args) + update_schema_bundle_from_local(args.plotlyjs_path) perform_codegen(outdir) @@ -482,10 +444,8 @@ def make_parser(): "updateplotlyjsdev", help="update plotly.js for development" ) p_update_dev.add_argument( - "--devrepo", default="plotly/plotly.js", help="repository" + "plotlyjs_path", help="path to a local plotly.js repository" ) - p_update_dev.add_argument("--devbranch", default="main", help="branch") - p_update_dev.add_argument("--local", default=None, help="local path") subparsers.add_parser("updateplotlyjs", help="update plotly.js") diff --git a/plotly/offline/_plotlyjs_version.py b/plotly/offline/_plotlyjs_version.py index 839c8395e4d..3e26b3dffcf 100644 --- a/plotly/offline/_plotlyjs_version.py +++ b/plotly/offline/_plotlyjs_version.py @@ -1,3 +1,3 @@ # DO NOT EDIT -# This file is generated by the updatebundle commands.py command +# This file is generated by the updateplotlyjs and updateplotlyjsdev commands in commands.py __plotlyjs_version__ = "4.1.1" From 1a34e16d846c13e5919ea697736194d7758cb19f Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 15 Oct 2026 10:20:08 -0600 Subject: [PATCH 2/2] Update docs --- CONTRIBUTING.md | 24 +++++------------------- doc/python/figure-structure.md | 2 +- doc/python/graph-objects.md | 2 +- 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5dbe483dd80..0eb7189bcbb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -283,7 +283,7 @@ Steps to update plotly.js: This command does the following: - - Downloads new versions of `plot-schema.json` and `plotly.min.js` from the [plotly.js GitHub repository](https://github.com/plotly/plotly.js) + - Downloads new versions of `plot-schema.json` and `plotly.min.js` from the [`plotly.js` npm package](https://www.npmjs.com/package/plotly.js) and places them in `codegen/resources/` and `plotly/package_data/`, respectively. - Updates `plotly/offline/_plotlyjs_version.py` with the new version - Regenerates all of the classes under `graph_objs/` (`go.Figure`, `go.Layout`, etc.), and `plotly/validators/_validators.json`, based on the new schema @@ -318,23 +318,9 @@ and places them in `codegen/resources/` and `plotly/package_data/`, respectively 7. Add a changelog entry to `CHANGELOG.md` with a link to the PR. We typically mention the version update, link to the plotly.js GitHub release page, and list out the most important changes. Follow the format of previous plotly.js version bump changelog updates. -### Using a Development Branch of Plotly.js +### Using a Development Version of Plotly.js -If your development branch is in [the plotly.js repository](https://github.com/plotly/plotly.js) -you can update to development versions of `plotly.js` with this command: - -```bash -python commands.py updateplotlyjsdev --devrepo reponame --devbranch branchname -``` - -This downloads `dist/plotly.min.js` and `dist/plot-schema.json` from the latest commit of the branch `branchname` of the repo `reponame`. -If `--devrepo` or `--devbranch` are omitted, -`updateplotlyjsdev` defaults to `plotly/plotly.js` and `main` respectively. - -### Local Repository - -If you have a local repository of `plotly.js` you'd like to try, -you can prepare the package by running the following commands +To test plotly.py against a local repository of `plotly.js`, prepare the package by running the following commands *in your local plotly.js repository*: ```bash @@ -347,7 +333,7 @@ You can then run the following command *in your local plotly.py repository*: ```bash -python commands.py updateplotlyjsdev --local /path/to/your/plotly.js/ +python commands.py updateplotlyjsdev /path/to/your/plotly.js/ ``` ## Documentation for `commands.py` @@ -362,5 +348,5 @@ Usage: `python commands.py ` | `lint` | Lint all Python code in `plotly/`. | | `format` | Format all Python code in `plotly/`. | | `updateplotlyjs` | Update `plotly.min.js` and `plot-schema.json` to match the `plotly.js` version specified in `js/package.json`, run codegen to regenerate the Python files, then run `npm install` in `js/` to refresh `js/package-lock.json`. Set `SKIP_NPM=1` to skip the npm step. | -| `updateplotlyjsdev [--devrepo REPONAME --devbranch BRANCHNAME] \| [--local PATH]` | Update `plot-schema.json` and `plotly.min.js` to match the version in the provided plotly.js repo name and branch name, OR local path. Then, run codegen to regenerate the Python files. | +| `updateplotlyjsdev PATH` | Update `plot-schema.json` and `plotly.min.js` from the local plotly.js repository at `PATH`. Then, run codegen to regenerate the Python files. | | `bumpversion X.Y.Z` | Update the plotly.py version number to X.Y.Z across all files where it needs to be updated. | diff --git a/doc/python/figure-structure.md b/doc/python/figure-structure.md index 495f1a4497e..9102b110a30 100644 --- a/doc/python/figure-structure.md +++ b/doc/python/figure-structure.md @@ -68,7 +68,7 @@ IFrame(snippet_url + 'figure-structure', width='100%', height=1200) ### Figures as Trees of Attributes -Plotly.js supports inputs adhering to a well-defined schema, whose overall architecture is explained in this page and which is exhaustively documented in the [Figure Reference](/python/reference/index/) (which is itself generated from a [machine-readable JSON representation of the schema](https://raw.githubusercontent.com/plotly/plotly.js/master/dist/plot-schema.json)). Figures are represented as trees with named nodes called "attributes". The root node of the tree has three top-level attributes: `data`, `layout` and `frames` (see below). +Plotly.js supports inputs adhering to a well-defined schema, whose overall architecture is explained in this page and which is exhaustively documented in the [Figure Reference](/python/reference/index/) (which is itself generated from a [machine-readable JSON representation of the schema](https://raw.githubusercontent.com/plotly/plotly.js/main/test/plot-schema.json)). Figures are represented as trees with named nodes called "attributes". The root node of the tree has three top-level attributes: `data`, `layout` and `frames` (see below). Attributes are referred to in text and in the [Figure Reference](/python/reference/index/) by their full "path" i.e. the dot-delimited concatenation of their parents. For example `"layout.width"` refers to the attribute whose key is `"width"` inside a dict which is the value associated with a key `"layout"` at the root of the figure. If one of the parents is a list rather than a dict, a set of brackets is inserted in the path when referring to the attribute in the abstract, e.g. `"layout.annotations[].text"`. Finally, as explained below, the top-level "data" attribute defines a list of typed objects called "traces" with the schema dependent upon the type, and these attributes' paths are listed in the [Figure Reference](/python/reference/index/) as `"data[type=scatter].name"`. diff --git a/doc/python/graph-objects.md b/doc/python/graph-objects.md index 92f34fdfb8e..d9aaa945073 100644 --- a/doc/python/graph-objects.md +++ b/doc/python/graph-objects.md @@ -35,7 +35,7 @@ jupyter: ### What Are Graph Objects? -The figures created, manipulated and rendered by the plotly Python library are [represented by tree-like data structures](/python/figure-structure/) which are automatically serialized to JSON for rendering by the Plotly.js JavaScript library. These trees are composed of named nodes called "attributes", with their structure defined by the Plotly.js figure schema, which is available in [machine-readable form](https://raw.githubusercontent.com/plotly/plotly.js/master/dist/plot-schema.json). **The `plotly.graph_objects` module (typically imported as `go`) contains an [automatically-generated hierarchy of Python classes](https://plotly.com/python-api-reference/plotly.graph_objects.html#graph-objects) which represent non-leaf nodes in this figure schema. The term "graph objects" refers to instances of these classes. ** +The figures created, manipulated and rendered by the plotly Python library are [represented by tree-like data structures](/python/figure-structure/) which are automatically serialized to JSON for rendering by the Plotly.js JavaScript library. These trees are composed of named nodes called "attributes", with their structure defined by the Plotly.js figure schema, which is available in [machine-readable form](https://raw.githubusercontent.com/plotly/plotly.js/main/test/plot-schema.json). **The `plotly.graph_objects` module (typically imported as `go`) contains an [automatically-generated hierarchy of Python classes](https://plotly.com/python-api-reference/plotly.graph_objects.html#graph-objects) which represent non-leaf nodes in this figure schema. The term "graph objects" refers to instances of these classes. ** The primary classes defined in the `plotly.graph_objects` module are [`Figure`](https://plotly.com/python-api-reference/generated/plotly.graph_objects.Figure.html) and an [`ipywidgets`-compatible variant called `FigureWidget`](/python/figurewidget/), which both represent entire figures. Instances of these classes have many convenience methods for Pythonically [manipulating their attributes](/python/creating-and-updating-figures/) (e.g. `.update_layout()` or `.add_trace()`, which all accept ["magic underscore" notation](/python/creating-and-updating-figures/#magic-underscore-notation)) as well as [rendering them](/python/renderers/) (e.g. `.show()`) and [exporting them to various formats](/python/static-image-export/) (e.g. `.to_json()` or `.write_image()` or `.write_html()`).