Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,11 @@ jobs:
- name: Prepare test report directory
run: mkdir -p tests-report

- name: Run public docs.bzl integration tests
run: .venv_docs/bin/python -m pytest -vv src/tests/docs_bzl --junitxml=tests-report/docs_bzl.xml
- name: Run cacheable public docs.bzl tests
run: .venv_docs/bin/python -m pytest -vv -m bazel_cached src/tests/docs_bzl --junitxml=tests-report/docs_bzl_cached.xml

- name: Run slow public docs.bzl tests
run: .venv_docs/bin/python -m pytest -vv -m bazel_slow src/tests/docs_bzl --junitxml=tests-report/docs_bzl_slow.xml

- name: Run bazel test targets
run: bazel test --lockfile_mode=error //... --build_tests_only
Expand Down
4 changes: 4 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ repos:
- id: check-toml
- id: check-json
exclude: ^\.vscode/ # those are actually jsonc files
# Golden HTML is compared byte-for-byte, so preserve the exact generated
# whitespace and EOF instead of normalizing these fixture files.
- id: end-of-file-fixer
exclude: ^src/tests/docs_bzl/scenarios/.*/_expected/.*\.html$
- id: trailing-whitespace
exclude: ^src/tests/docs_bzl/scenarios/.*/_expected/.*\.html$
- id: check-merge-conflict
- id: check-case-conflict
- id: mixed-line-ending
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ log_file_format = "[%(asctime)s.%(msecs)03d] [%(levelname)-3s] [%(name)s] %(mess
log_file_date_format = "%Y-%m-%d %H:%M:%S"

markers = [
"bazel_cached: successful build-only test expected to reuse Bazel actions",
"bazel_slow: runtime execution or an uncached expected Bazel failure",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not quiet understand this marker?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the idea was to run fast tests first and then the slow tests. however "fast" is not quite true. They are only fast because mostly they are cached. So I ended up with "cached" and "slow". Those are horrible categories, but so far I dont have a better approach.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But they are only cached on second run right?

"metadata",
"test_properties(dict): Add custom properties to test XML output",
]
Expand Down
143 changes: 142 additions & 1 deletion src/extensions/score_layout/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************
import logging
import os
from pathlib import Path
from typing import Any
from typing import Any, cast
from urllib.parse import quote

import html_options
import sphinx_options
Expand All @@ -22,11 +24,33 @@

logger = logging.getLogger(__name__)

# TEMP UNTIL UPSTREAM FIX - BEGIN
# Bug ref: https://github.com/useblocks/sphinx-needs/issues/1913
# Sphinx-Needs discovers these files with ``Path.glob``. The filesystem
# does not define the order returned by that operation, while Sphinx preserves
# registration order for stylesheets with the same priority. Keep the CSS
# cascade stable across Bazel runfiles, local virtual environments, and CI.
_NEEDS_COMMON_CSS_ORDER = (
"sphinx-needs/common_css/needstable.css",
"sphinx-needs/common_css/need_core.css",
"sphinx-needs/common_css/need_style.css",
"sphinx-needs/common_css/need_toggle.css",
"sphinx-needs/common_css/need_links.css",
)
_NEEDS_COMMON_CSS_POSITION = {
filename: position for position, filename in enumerate(_NEEDS_COMMON_CSS_ORDER)
}
# TEMP UNTIL UPSTREAM FIX - END


def setup(app: Sphinx) -> dict[str, str | bool]:
logger.debug("score_layout setup called")

app.connect("config-inited", update_config)
# Run after the PyData theme creates its edit-URL callback so mounted pages
# can replace the callback with a workspace-aware one.
app.connect("html-page-context", configure_mounted_source_controls, priority=800)
app.connect("html-page-context", normalize_needs_css_order)
return {
"version": "0.1",
"parallel_read_safe": True,
Expand Down Expand Up @@ -68,3 +92,120 @@ def update_config(app: Sphinx, _config: Any):
app.add_css_file("css/score.css", priority=500)
app.add_css_file("css/score_needs.css", priority=500)
app.add_css_file("css/score_design.css", priority=500)


# TEMP UNTIL UPSTREAM FIX - BEGIN
# Bug ref: https://github.com/useblocks/sphinx-mounts/issues/47
def configure_mounted_source_controls(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be in this PR?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need that to generate identical html every time, as currently /home/<username>/.cache/... is added to the urls

app: Sphinx,
pagename: str,
_templatename: str,
context: dict[str, Any],
_doctree: Any,
) -> None:
"""Configure safe source/edit controls for mounted documents.

``sphinx-mounts`` stores the absolute filesystem path of a mounted source
in Sphinx. Sphinx currently derives ``page_source_suffix`` from that path,
which makes themes such as PyData build malformed source and edit URLs.
A real source file inside the current workspace can still be mapped to a
safe repository-relative URL. Generated and external sources have no such
guaranteed URL, so their controls remain hidden until the mount extension
exposes a logical source path and repository mapping.
"""
source_path = Path(app.env.doc2path(pagename, base=False))
if not source_path.is_absolute():
return

source_path = source_path.resolve()
workspace_directory = os.environ.get("BUILD_WORKSPACE_DIRECTORY")
if workspace_directory:
workspace_root = Path(workspace_directory).resolve()
if (
source_path.is_relative_to(workspace_root)
and not {"bazel-bin", "bazel-out"}.intersection(source_path.parts)
and _configure_workspace_edit_url(context, source_path, workspace_root)
):
return

context["page_source_suffix"] = ""
context["sourcename"] = ""
context["secondary_sidebar_items"] = []


def _configure_workspace_edit_url(
context: dict[str, Any], source_path: Path, workspace_root: Path
) -> bool:
"""Install a GitHub edit callback for a source file in the workspace."""
if context.get("edit_page_url_template") is not None:
return False

github_values = tuple(
context.get(name) for name in ("github_user", "github_repo", "github_version")
)
if not all(
isinstance(value, str) and value not in {"", "dummy", "None"}
for value in github_values
):
return False

github_user, github_repo, github_version = cast(tuple[str, str, str], github_values)
relative_path = quote(source_path.relative_to(workspace_root).as_posix(), safe="/")
github_url = str(context.get("github_url", "https://github.com")).rstrip("/")
edit_url = (
f"{github_url}/{quote(github_user, safe='')}/{quote(github_repo, safe='')}"
f"/edit/{quote(github_version, safe='')}/{relative_path}"
)

def get_edit_provider_and_url() -> tuple[str, str]:
"""Return the edit URL for the mounted workspace source."""
return "GitHub", edit_url

context["get_edit_provider_and_url"] = get_edit_provider_and_url
return True


# TEMP UNTIL UPSTREAM FIX - END


# TEMP UNTIL UPSTREAM FIX - BEGIN
# Bug ref: https://github.com/useblocks/sphinx-needs/issues/1913
def normalize_needs_css_order(
_app: Sphinx,
_pagename: str,
_templatename: str,
context: dict[str, Any],
_doctree: Any,
) -> None:
"""Make Sphinx-Needs common stylesheets deterministic before rendering."""
css_files = context.get("css_files")
if not isinstance(css_files, list):
return
css_files = cast(list[Any], css_files)

common_css = [
(index, css_file)
for index, css_file in enumerate(css_files)
if _css_filename(css_file) in _NEEDS_COMMON_CSS_POSITION
]
if len(common_css) < 2:
return

positions = [index for index, _ in common_css]
ordered_common_css = sorted(
(css_file for _, css_file in common_css),
key=lambda css_file: _NEEDS_COMMON_CSS_POSITION[_css_filename(css_file)],
)
normalized_css_files = list(css_files)
for index, css_file in zip(positions, ordered_common_css, strict=True):
normalized_css_files[index] = css_file
context["css_files"] = normalized_css_files


def _css_filename(css_file: Any) -> str:
"""Return a stylesheet's path in the form used by Sphinx-Needs."""
filename = str(os.fspath(getattr(css_file, "filename", css_file)))
return Path(filename).as_posix().removeprefix("_static/")


# TEMP UNTIL UPSTREAM FIX - END
69 changes: 33 additions & 36 deletions src/tests/docs_bzl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,45 +6,42 @@
*******************************************************************************
-->

# Public `docs.bzl` integration tests

These tests run **outside** Bazel with pytest and drive the public `docs.bzl`
macros through real `bazel run` / `bazel build` commands. They cover exactly
what a consumer invokes: `docs()`, `docs_bundle()`, mounts, cross-module
compatibility reporting, and failure cases.

```text
docs_bzl/
├── scenarios/ # one fixture per consumer scenario
│ ├── basic_docs/
│ ├── reference_integration/
│ ├── metamodel_violation/
│ ├── nested_bundles/
│ ├── subdirectory_bundle/
│ ├── external_bundle/
│ ├── local_version_mismatch/
│ └── invalid_bundle_placements/
└── test_<scenario>.py
# Public `docs.bzl` scenario tests

These pytest tests exercise the public `docs()` and `docs_bundle()` macros
through real Bazel builds and runs. Fixtures live below `scenarios/`; the test
modules are `test_docs_bzl_scenarios.py` and
`test_expected_output_consistency.py`.

## Run

```sh
.venv_docs/bin/python -m pytest -vv src/tests/docs_bzl
```

Each scenario has a fixture folder and a matching pytest file. The names describe
consumer behavior, not the Bazel mechanism used to execute it. Positive rendering
uses `bazel run`; sandbox-only behavior uses `bazel build`; invalid package
definitions are expected build failures. Assertions retain rendered HTML,
manifest order and metadata, source links, toctree attachment, and diagnostics.
The cross-module compatibility test creates its consumer in a temporary
workspace, so this repository's production ``MODULE.bazel`` stays free of test
dependencies while the test still traverses real Bzlmod module boundaries.
The suite runs Bazel and should be run sequentially. CI splits it into:

```sh
.venv_docs/bin/python -m pytest -vv -m bazel_cached src/tests/docs_bzl
.venv_docs/bin/python -m pytest -vv -m bazel_slow src/tests/docs_bzl
```

Build-only expected outputs are marked `bazel_cached`; outputs that execute
Sphinx through `bazel run`, as well as expected-failure tests, are marked
`bazel_slow`.

## Expected outputs

Note that these tests run `bazel` commands, so they are slow. They need to be executed
sequentially. Use sparingly. They do not call `bazel clean`, so the persistent
Bazel server and its action, repository, and disk caches are reused between
cases. There is still a small analysis/startup cost per command; keep scenarios
coarse-grained and use `bazel run` only where runtime behavior matters.
Positive scenarios may check in files below a fixture's `_expected/` directory:

Run via:
- `_expected/<target>/...` checks selected files below a directory output.
- `_expected/<target>.<suffix>` checks one file output.

.venv_docs/bin/python -m pytest -vv src/tests/docs_bzl
Only files already present below `_expected/` are part of the contract. JSON is
compared as sorted, formatted data; other files, including HTML, are compared
byte-for-byte.

The suite is deliberately separate from `bazel test //...`, since pytest is its
driver. CI stores its JUnit XML together with the Bazel test reports.
Each expected output is its own pytest case. When generated content changes,
the case updates the checked-in file, prints the unified diff through pytest,
and fails with exit code 1. Review the change and run pytest again; an
unchanged output passes. Files are never added or removed automatically.
Loading
Loading