diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c56647..f39d66c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning][]. ## [Unreleased] +### Changed + +- brand accents are synced from the website’s `accents.json`. + ### Fixed - header shadow now only extends downward (#70) diff --git a/biome.jsonc b/biome.jsonc index fdb3aed..a130bbe 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -6,6 +6,7 @@ "includes": [ "**", "!src/scverse_doc/registry.json", + "!src/scverse_doc/_accents.json", "!src/scverse_doc/theme/scverse/static/_tokens.css", ], }, diff --git a/docs/kitchen-sink.md b/docs/kitchen-sink.md index d92aa9a..300e562 100644 --- a/docs/kitchen-sink.md +++ b/docs/kitchen-sink.md @@ -183,7 +183,7 @@ third = "plain" ```pycon >>> from scverse_doc import registry >>> registry.packages["scanpy"].accent -'#de367b' +'oklch(0.608 0.209 1)' ``` ## Table diff --git a/pyproject.toml b/pyproject.toml index 8e36d06..fb84c60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ dev = [ "twine>=4.0.2", ] test = [ + "coloraide", # to check the accent lightness bounds "coverage>=7.10", "defusedxml", # for sphinx.testing’s HTML assertions "pytest", diff --git a/scripts/sync_brand_tokens.py b/scripts/sync_brand_tokens.py index 08e2906..b7ab50e 100755 --- a/scripts/sync_brand_tokens.py +++ b/scripts/sync_brand_tokens.py @@ -11,15 +11,14 @@ would create exactly the kind of drift this package exists to remove. The website takes its neutral colours from Bootstrap’s tokens (``--bs-fg-*``, ``--bs-bg-*``, …), -which already carry dark values via ``light-dark()``, and defines only the brand hues itself. -This script looks up the tokens the theme needs in the website’s vendored Bootstrap and ``assets/main.scss``, +which already carry dark values via ``light-dark()``, and defines only the brand hues itself, +as OKLCH triples in ``assets/accents.json``. +This script looks up the tokens the theme needs in the website’s vendored Bootstrap and ``accents.json``, resolves every ``var()`` down to literals, and writes them into ``_tokens.css`` as ``--scverse-color-x``. -The per-package accents go to ``_accents.json``, because the registry needs their values in Python -to derive readable shades from them. - Only the region between the marker comments is touched; the rest of the file is hand-authored. -The scverse logo used for the navbar link back to the website is copied verbatim for the same reason. +The scverse logo used for the navbar link back to the website is copied verbatim for the same reason, +as is ``accents.json``, which the registry reads the package accents from. Usage ----- @@ -37,16 +36,16 @@ HERE = Path(__file__).parent STATIC = HERE.parent / "src" / "scverse_doc" / "theme" / "scverse" / "static" TARGET = STATIC / "_tokens.css" -ACCENTS_TARGET = HERE.parent / "src" / "scverse_doc" / "_accents.json" -#: Website file -> theme static file, copied verbatim. -ASSETS = {Path("static/img/logo/scverse-fa.svg"): STATIC / "scverse-fa.svg"} +#: Website file -> package file, copied verbatim. +ASSETS = { + Path("static/img/logo/scverse-fa.svg"): STATIC / "scverse-fa.svg", + Path("assets/accents.json"): HERE.parent / "src" / "scverse_doc" / "_accents.json", +} #: CSS custom property emitted here -> website custom property it is resolved from. #: The pairing mirrors the one scverse/scverse.github.io#329 used when replacing its SCSS variables. TOKEN_MAP = { - "--scverse-color-gradient-start": "--scverse-deep-blue", - "--scverse-color-gradient-end": "--scverse-sky-blue", "--scverse-color-background": "--bs-bg-body", "--scverse-color-text": "--bs-fg-body", "--scverse-color-heading": "--bs-fg-1", @@ -61,18 +60,10 @@ "--scverse-color-footer-bg": "--bs-bg-2", } -#: Package -> website custom property holding its accent; ``default`` is for packages without one. -ACCENT_MAP = { - "default": "--scverse-deep-blue", - "anndata": "--anndata-orange", - "mudata": "--mudata-green", - "muon": "--muon-aquamarine", - "pertpy": "--pertpy-blue", - "scanpy": "--scanpy-cerise", - "scirpy": "--scirpy-purple", - "scvi-tools": "--scvi-yellow", - "spatialdata": "--spatialdata-blue", - "squidpy": "--squidpy-violet", +#: CSS custom property emitted here -> (package, colour name) in the website’s ``accents.json``. +ACCENT_TOKEN_MAP = { + "--scverse-color-gradient-start": ("scverse", "deep-blue"), + "--scverse-color-gradient-end": ("scverse", "sky-blue"), } BEGIN = "/* BEGIN GENERATED BRAND TOKENS – DO NOT EDIT; regenerate with scripts/sync_brand_tokens.py */" @@ -89,36 +80,34 @@ def parse_bootstrap(css: str) -> dict[str, str]: return dict(DECL_RE.findall(";".join(blocks) + ";")) -def parse_scss(scss: str) -> dict[str, str]: - """Extract custom property declarations from the website’s SCSS.""" - return dict(DECL_RE.findall(scss)) - - def resolve(name: str, props: dict[str, str]) -> str: """Return the value of `name` with every ``var()`` in it recursively substituted.""" return re.sub(r"var\((--[\w-]+)\)", lambda m: resolve(m[1], props), props[name]) -def render_region(props: dict[str, str], indent: str) -> str: +def render_region(props: dict[str, str], accents: dict[str, list[tuple[str, list[float]]]], indent: str) -> str: """Render the generated region – marker comments included – indented by `indent`.""" if missing := sorted(set(TOKEN_MAP.values()) - set(props)): msg = f"custom properties vanished from the website: {', '.join(missing)}" raise KeyError(msg) - lines = [BEGIN, *(f"{prop}: {resolve(src, props)};" for prop, src in TOKEN_MAP.items()), END] + lines = [ + BEGIN, + *( + f"{prop}: oklch({' '.join(map(str, dict(accents[pkg])[name]))});" + for prop, (pkg, name) in ACCENT_TOKEN_MAP.items() + ), + *(f"{prop}: {resolve(src, props)};" for prop, src in TOKEN_MAP.items()), + END, + ] return "\n".join(indent + line for line in lines) -def render_accents(props: dict[str, str]) -> str: - """Render the accent JSON read by ``scverse_doc.registry``.""" - return json.dumps({pkg: resolve(src, props) for pkg, src in ACCENT_MAP.items()}, indent=4) + "\n" - - -def render(props: dict[str, str], current: str) -> str: - """Return `current` with its generated region replaced by one rendered from `props`.""" +def render(props: dict[str, str], accents: dict[str, list[tuple[str, list[float]]]], current: str) -> str: + """Return `current` with its generated region replaced by one rendered from `props` and `accents`.""" if (region := REGION_RE.search(current)) is None: msg = f"{TARGET} has no “{BEGIN}” … “{END}” region" raise LookupError(msg) - return current[: region.start()] + render_region(props, region[1]) + current[region.end() :] + return current[: region.start()] + render_region(props, accents, region[1]) + current[region.end() :] def main() -> int: @@ -128,12 +117,10 @@ def main() -> int: parser.add_argument("--check", action="store_true", help="fail instead of writing if the output would change") args = parser.parse_args() - props = parse_bootstrap( - (args.website / "static" / "bootstrap" / "css" / "bootstrap.min.css").read_text() - ) | parse_scss((args.website / "assets" / "main.scss").read_text()) + props = parse_bootstrap((args.website / "static" / "bootstrap" / "css" / "bootstrap.min.css").read_text()) + accents = json.loads((args.website / "assets" / "accents.json").read_text()) updates = { - TARGET: render(props, TARGET.read_text()).encode(), - ACCENTS_TARGET: render_accents(props).encode(), + TARGET: render(props, accents, TARGET.read_text()).encode(), **{dst: (args.website / src).read_bytes() for src, dst in ASSETS.items()}, } if args.check: diff --git a/src/scverse_doc/_accents.json b/src/scverse_doc/_accents.json index a4ca6c6..f8619d4 100644 --- a/src/scverse_doc/_accents.json +++ b/src/scverse_doc/_accents.json @@ -1,12 +1,25 @@ { - "default": "#262fb5", - "anndata": "#e5864b", - "mudata": "#4ab274", - "muon": "#6cf1a1", - "pertpy": "#30679a", - "scanpy": "#de367b", - "scirpy": "#da347f", - "scvi-tools": "#fbb822", - "spatialdata": "#40a9ff", - "squidpy": "#969dea" + "anndata": [["orange", [0.711, 0.138, 51]]], + "scanpy": [ + ["cerise", [0.608, 0.209, 1]], + ["mandy", [0.634, 0.1737, 22]] + ], + "mudata": [["green", [0.686, 0.134, 154.4]]], + "muon": [["aquamarine", [0.863, 0.163, 154.5]]], + "spatialdata": [["blue", [0.714, 0.158, 247.3]]], + "squidpy": [["violet", [0.72, 0.112, 279]]], + "scirpy": [ + ["purple", [0.601, 0.209, 358.5]], + ["orange", [0.7824, 0.156, 69]] + ], + "pertpy": [ + ["blue", [0.5, 0.1, 249]], + ["red", [0.595, 0.177, 14]] + ], + "scvi": [["yellow", [0.824, 0.1634, 81]]], + "scverse": [ + ["deep-blue", [0.405, 0.205, 270]], + ["sky-blue", [0.799, 0.108, 236]], + ["gold", [0.758, 0.2, 92]] + ] } diff --git a/src/scverse_doc/_color.py b/src/scverse_doc/_color.py deleted file mode 100644 index 8231500..0000000 --- a/src/scverse_doc/_color.py +++ /dev/null @@ -1,146 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -"""Colour maths for deriving accessible accent shades. - -Package accents are chosen for brand recognition, not for contrast. -Several of the accents scverse already uses (``#6cf1a1`` for muon, ``#fbb822`` for scvi-tools) -fail WCAG AA as text colours on white, and others fail on a dark background. -So the raw accent is only ever used for decorative surfaces, -and text/link colours are *derived* from it here by moving lightness until the contrast target is met, -keeping hue and saturation intact so the result still reads as the package’s colour. -""" - -from __future__ import annotations - -import colorsys -import re -from typing import NamedTuple - -__all__ = ["contrast_ratio", "derive_readable", "parse_hex", "relative_luminance"] - -#: Minimum contrast ratio for normal-size body text under WCAG 2.1 AA. -WCAG_AA_NORMAL = 4.5 - -_HEX_RE = re.compile(r"\A#?(?P[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\Z") - - -class RGB(NamedTuple): - """An sRGB colour with channels in ``[0, 1]``.""" - - r: float - g: float - b: float - - def to_hex(self) -> str: - """Render as a lowercase ``#rrggbb`` string.""" - return "#" + "".join(f"{round(c * 255):02x}" for c in self) - - -def parse_hex(color: str) -> RGB: - """Parse a CSS hex colour. - - Parameters - ---------- - color - A ``#rgb`` or ``#rrggbb`` string, with or without the leading ``#``. - - Returns - ------- - The colour, with channels normalised to ``[0, 1]``. - - Raises - ------ - ValueError - If `color` is not a three- or six-digit hex colour. - """ - if (m := _HEX_RE.match(color.strip())) is None: - msg = f"Not a hex colour: {color!r}" - raise ValueError(msg) - digits = m["digits"] - if len(digits) == 3: - digits = "".join(d * 2 for d in digits) - return RGB(*(c / 255 for c in bytes.fromhex(digits))) - - -def _to_linear(channel: float) -> float: - """Undo the sRGB transfer function for a single channel.""" - return channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4 - - -def relative_luminance(color: RGB | str) -> float: - """Compute the WCAG relative luminance of a colour. - - Parameters - ---------- - color - An :class:`RGB` colour or a hex string. - - Returns - ------- - Relative luminance in ``[0, 1]``, where 0 is black and 1 is white. - """ - r, g, b = parse_hex(color) if isinstance(color, str) else color - return 0.2126 * _to_linear(r) + 0.7152 * _to_linear(g) + 0.0722 * _to_linear(b) - - -def contrast_ratio(fg: RGB | str, bg: RGB | str) -> float: - """Compute the WCAG contrast ratio between two colours. - - Parameters - ---------- - fg - Foreground colour. - bg - Background colour. - - Returns - ------- - A ratio between 1 (identical) and 21 (black on white). - """ - lum_a, lum_b = relative_luminance(fg), relative_luminance(bg) - lighter, darker = max(lum_a, lum_b), min(lum_a, lum_b) - return (lighter + 0.05) / (darker + 0.05) - - -def derive_readable(accent: str, background: str, *, target: float = WCAG_AA_NORMAL) -> str: - """Derive a text-safe variant of `accent` that meets `target` contrast against `background`. - - Hue and saturation are preserved and only lightness is moved, - so the result still reads as the same colour. - Lightness moves away from the background: - darker on a light background, lighter on a dark one. - - Parameters - ---------- - accent - The brand accent to adjust, as a hex string. - background - The background the derived colour will sit on, as a hex string. - target - The contrast ratio to reach. - - Returns - ------- - A hex colour meeting `target` where that is achievable by lightness alone, - otherwise the closest achievable colour, which is black or white. - - Examples - -------- - >>> derive_readable("#fbb822", "#ffffff") # scvi-tools yellow, unreadable as-is - '#9d6d03' - """ - if contrast_ratio(accent, background) >= target: - return parse_hex(accent).to_hex() - - hue, lightness, saturation = colorsys.rgb_to_hls(*parse_hex(accent)) - darken = relative_luminance(background) > 0.5 - step = -1 / 512 if darken else 1 / 512 - - # Candidates are checked after rounding to 8-bit hex: - # a float lightness that clears the target can fall back under it once quantized, - # which is how #e5864b and #fbb822 ended up one hundredth short of AA. - while 0.0 <= (lightness := lightness + step) <= 1.0: - candidate = parse_hex(RGB(*colorsys.hls_to_rgb(hue, lightness, saturation)).to_hex()) - if contrast_ratio(candidate, background) >= target: - return candidate.to_hex() - - return "#000000" if darken else "#ffffff" diff --git a/src/scverse_doc/registry.py b/src/scverse_doc/registry.py index e9b047d..751e01b 100644 --- a/src/scverse_doc/registry.py +++ b/src/scverse_doc/registry.py @@ -55,11 +55,15 @@ def _build_cache(app: Sphinx) -> Path: return _cache_dir -#: Brand accents from the website, generated by ``scripts/sync_brand_tokens.py``. -_ACCENTS: dict[str, str] = json.loads(files(__package__).joinpath("_accents.json").read_text()) +#: Brand accents from the website, copied by ``scripts/sync_brand_tokens.py``: +#: each key lists ``[name, [L, C, H]]`` pairs, primary first. +_ACCENTS: dict[str, str] = { + {"scvi": "scvi-tools"}.get(pkg, pkg): "oklch({} {} {})".format(*colors[0][1]) + for pkg, colors in json.loads(files(__package__).joinpath("_accents.json").read_text()).items() +} #: The website’s ``--scverse-deep-blue``, used when a package has no accent of its own. -DEFAULT_ACCENT = _ACCENTS.pop("default") +DEFAULT_ACCENT = _ACCENTS.pop("scverse") #: Upstream ``category`` -> the ``kind`` recorded here. #: ``core-infrastructure`` is skipped: diff --git a/src/scverse_doc/theme/__init__.py b/src/scverse_doc/theme/__init__.py index 5d41aae..80c5da0 100644 --- a/src/scverse_doc/theme/__init__.py +++ b/src/scverse_doc/theme/__init__.py @@ -16,7 +16,6 @@ from sphinx.util.typing import ExtensionMetadata -from .._color import derive_readable from ..registry import DEFAULT_ACCENT, _build_cache, packages if TYPE_CHECKING: @@ -31,11 +30,19 @@ _LIGHT_BACKGROUND = "#ffffff" _DARK_BACKGROUND = "#14181e" -_ACCENT_CSS = """\ -:root {{ - --scverse-color-accent-decorative: {accent}; - --scverse-color-accent-text: light-dark({light}, {dark}); -}} +#: OKLCH lightness bounds at which any hue and chroma meets WCAG AA on `_LIGHT_BACKGROUND` / `_DARK_BACKGROUND`; +#: ``tests/test_color.py`` checks them. +_MAX_LIGHTNESS_ON_LIGHT = 0.54 +_MIN_LIGHTNESS_ON_DARK = 0.66 + +_ACCENT_CSS = f"""\ +:root {{{{ + --scverse-color-accent-decorative: {{accent}}; + --scverse-color-accent-text: light-dark( + oklch(from var(--scverse-color-accent-decorative) min(l, {_MAX_LIGHTNESS_ON_LIGHT}) c h), + oklch(from var(--scverse-color-accent-decorative) max(l, {_MIN_LIGHTNESS_ON_DARK}) c h) + ); +}}}} """ #: scverse.org itself is linked from the scverse menu beside them. @@ -78,16 +85,9 @@ def _configure(app: Sphinx) -> None: _add_chrome(config) - accent = _accent(config) static_dir = _build_cache(app) / "static" static_dir.mkdir(parents=True, exist_ok=True) - (static_dir / "scverse-accent.css").write_text( - _ACCENT_CSS.format( - accent=accent, - light=derive_readable(accent, _LIGHT_BACKGROUND), - dark=derive_readable(accent, _DARK_BACKGROUND), - ) - ) + (static_dir / "scverse-accent.css").write_text(_ACCENT_CSS.format(accent=_accent(config))) config.html_static_path = [*config.html_static_path, str(static_dir)] app.add_css_file("scverse-accent.css") app.add_js_file("scripts/readthedocs-integration.js", type="module") diff --git a/src/scverse_doc/theme/scverse/static/_tokens.css b/src/scverse_doc/theme/scverse/static/_tokens.css index cc63992..dd6c0b0 100644 --- a/src/scverse_doc/theme/scverse/static/_tokens.css +++ b/src/scverse_doc/theme/scverse/static/_tokens.css @@ -9,8 +9,8 @@ :root { /* BEGIN GENERATED BRAND TOKENS – DO NOT EDIT; regenerate with scripts/sync_brand_tokens.py */ - --scverse-color-gradient-start: #262fb5; - --scverse-color-gradient-end: #74c8fa; + --scverse-color-gradient-start: oklch(0.405 0.205 270); + --scverse-color-gradient-end: oklch(0.799 0.108 236); --scverse-color-background: light-dark(#fff,color-mix(in oklch, #000 76%, oklch(60% .02 245))); --scverse-color-text: light-dark(color-mix(in oklch, #000 64%, oklch(60% .02 245)),color-mix(in oklch, #fff 90%, oklch(60% .02 245))); --scverse-color-heading: light-dark(color-mix(in oklch, #000 48%, oklch(60% .02 245)),color-mix(in oklch, #fff 60%, oklch(60% .02 245))); diff --git a/src/scverse_doc/theme/scverse/static/styles/scverse.css b/src/scverse_doc/theme/scverse/static/styles/scverse.css index cb23964..9167626 100644 --- a/src/scverse_doc/theme/scverse/static/styles/scverse.css +++ b/src/scverse_doc/theme/scverse/static/styles/scverse.css @@ -8,7 +8,7 @@ * the mode. If a component needs a dark-specific rule, the token vocabulary is missing something – add the * token in _tokens.css instead. * 3. The raw package accent is decorative only. Text and links use the derived --scverse-color-accent-text, which - * the build guarantees meets WCAG AA against the current background. See scverse_doc._color. + * clamps the accent’s lightness to meet WCAG AA against the current background. See scverse_doc.theme. */ @import url("../_tokens.css"); diff --git a/tests/test_build.py b/tests/test_build.py index b50788b..5af9fe6 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -78,8 +78,8 @@ def test_ecosystem_dropdown_is_built_from_the_registry(minimal: tuple[SphinxTest def test_accent_is_derived_for_both_modes(minimal: tuple[SphinxTestApp, str]) -> None: app, _ = minimal css = (Path(app.outdir) / "_static" / "scverse-accent.css").read_text() - assert "--scverse-color-accent-decorative: #30679a;" in css - assert "--scverse-color-accent-text: light-dark(#" in css + assert "--scverse-color-accent-decorative: oklch(0.5 0.1 249);" in css + assert "oklch(from var(--scverse-color-accent-decorative) min(l, 0.54) c h)" in css @needs_full diff --git a/tests/test_color.py b/tests/test_color.py index 4aa2047..72d0a1b 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -6,43 +6,48 @@ from types import SimpleNamespace import pytest +from coloraide import Color -from scverse_doc._color import WCAG_AA_NORMAL, contrast_ratio, derive_readable, parse_hex from scverse_doc.registry import DEFAULT_ACCENT, packages -from scverse_doc.theme import _DARK_BACKGROUND, _LIGHT_BACKGROUND, _accent - -ACCENTS = sorted({pkg.accent for pkg in packages.values()}) - - -def test_every_derived_accent_meets_aa() -> None: - for accent in ACCENTS: - for background in (_LIGHT_BACKGROUND, _DARK_BACKGROUND): - derived = derive_readable(accent, background) - assert contrast_ratio(derived, background) >= WCAG_AA_NORMAL, (accent, background, derived) - - -def test_derivation_is_load_bearing() -> None: - """Several brand accents fail AA unmodified, which is why they are never used as link colours.""" - assert [a for a in ACCENTS if contrast_ratio(a, _LIGHT_BACKGROUND) < WCAG_AA_NORMAL] +from scverse_doc.theme import ( + _DARK_BACKGROUND, + _LIGHT_BACKGROUND, + _MAX_LIGHTNESS_ON_LIGHT, + _MIN_LIGHTNESS_ON_DARK, + _accent, +) +#: Minimum contrast ratio for normal-size body text under WCAG 2.1 AA. +WCAG_AA_NORMAL = 4.5 -def test_readable_accent_is_left_alone() -> None: - assert derive_readable("#000000", _LIGHT_BACKGROUND) == "#000000" +ACCENTS = sorted({pkg.accent for pkg in packages.values()}) -def test_short_hex_is_expanded() -> None: - """Users can write ``accent = "#c0f"`` in ``html_theme_options``.""" - assert parse_hex("#c0f") == parse_hex("#cc00ff") +def contrast(color: Color, background: str) -> float: + """WCAG contrast of `color` as a browser shows it: gamut-mapped to sRGB by reducing OKLCH chroma.""" + return color.clone().fit("srgb", method="oklch-chroma").contrast(background, method="wcag21") -def test_garbage_is_rejected() -> None: - with pytest.raises(ValueError, match="Not a hex colour"): - parse_hex("nope") +@pytest.mark.parametrize( + ("lightness", "background"), + [ + pytest.param(_MAX_LIGHTNESS_ON_LIGHT, _LIGHT_BACKGROUND, id="light"), + pytest.param(_MIN_LIGHTNESS_ON_DARK, _DARK_BACKGROUND, id="dark"), + ], +) +def test_lightness_bound_meets_aa_for_any_hue(lightness: float, background: str) -> None: + failing = [ + (chroma, hue) + for hue in range(0, 360, 2) + for chroma in (c / 100 for c in range(0, 41, 2)) + if contrast(Color("oklch", [lightness, chroma, hue]), background) < WCAG_AA_NORMAL + ] + assert not failing -@pytest.mark.parametrize(("background", "expected"), [("#ffffff", "#000000"), ("#000000", "#ffffff")]) -def test_unreachable_target_falls_back_to_the_extreme(background: str, expected: str) -> None: - assert derive_readable("#808080", background, target=22) == expected +def test_bounds_are_load_bearing() -> None: + """Several brand accents fail AA unmodified, which is why they are never used as link colours.""" + assert [a for a in ACCENTS if Color(a).contrast(_LIGHT_BACKGROUND, method="wcag21") < WCAG_AA_NORMAL] @pytest.mark.parametrize( diff --git a/tests/test_registry.py b/tests/test_registry.py index b0e7bd0..011c4cf 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -43,7 +43,7 @@ def test_entries_are_well_formed() -> None: # ``docs`` is upstream’s link for humans, so it may well name a page. # An inventory root may not. assert pkg.docs.startswith("https://"), pkg - assert pkg.accent.startswith("#"), pkg + assert pkg.accent.startswith("oklch("), pkg if pkg.inventory is not None: assert pkg.inventory.startswith("https://"), pkg assert pkg.inventory.endswith("/"), pkg