From 84b71cee034586f608e8ca0403ccfcbc6077c666 Mon Sep 17 00:00:00 2001 From: Alex Jones Date: Wed, 13 May 2026 11:28:25 +0100 Subject: [PATCH 01/11] feat: add block information to the instrumentation job metadata Although it can currently be inferred/derived from the full name of the job, having the block (& block variant) information explicitly available in the recorded metadata is much more useful. This means that we can make visualizations and calculations of regression results partitioned by block or block variant. Signed-off-by: Alex Jones --- src/dvsim/instrumentation/metadata.py | 2 ++ src/dvsim/instrumentation/records.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/dvsim/instrumentation/metadata.py b/src/dvsim/instrumentation/metadata.py index 1b1e549b..457a0514 100644 --- a/src/dvsim/instrumentation/metadata.py +++ b/src/dvsim/instrumentation/metadata.py @@ -41,6 +41,8 @@ def get_job_data(self) -> Mapping[str, JobInstrumentationMetadata]: job_type=spec.job_type, target=spec.target, tool=spec.tool.name, + block=spec.block.name, + block_variant=spec.block.variant, backend=spec.backend, dependencies=list(spec.dependencies), status=status_str, diff --git a/src/dvsim/instrumentation/records.py b/src/dvsim/instrumentation/records.py index 39a1fac0..710d02be 100644 --- a/src/dvsim/instrumentation/records.py +++ b/src/dvsim/instrumentation/records.py @@ -52,6 +52,8 @@ class JobInstrumentationMetadata(JobMetrics): job_type: str target: str tool: str + block: str + block_variant: str | None backend: str | None dependencies: list[str] status: str From 11be999a38b6b67f36a5f78de49532b7be7b72b3 Mon Sep 17 00:00:00 2001 From: Alex Jones Date: Wed, 13 May 2026 12:07:32 +0100 Subject: [PATCH 02/11] feat: introduce instrumentation report visualisation protocol Define a common protocol for instrumentation report visualisations with a rendering interface. The idea is to produce a single HTML instrumentation report with various different visualisations (which themselves could be graphs, images, text, etc.) which can be switched between. The rendered outputs themselves are just embedded HTML fragments (or `None` on failure). This should be simple enough whilst allowing the level of extensibility and flexibility that is needed for the current level of instrumentation reporting. Signed-off-by: Alex Jones --- src/dvsim/instrumentation/report/__init__.py | 12 +++++ src/dvsim/instrumentation/report/base.py | 49 ++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 src/dvsim/instrumentation/report/__init__.py create mode 100644 src/dvsim/instrumentation/report/base.py diff --git a/src/dvsim/instrumentation/report/__init__.py b/src/dvsim/instrumentation/report/__init__.py new file mode 100644 index 00000000..87e54362 --- /dev/null +++ b/src/dvsim/instrumentation/report/__init__.py @@ -0,0 +1,12 @@ +# Copyright lowRISC contributors (OpenTitan project). +# Licensed under the Apache License, Version 2.0, see LICENSE for details. +# SPDX-License-Identifier: Apache-2.0 + +"""DVSim Scheduler Instrumentation report.""" + +from dvsim.instrumentation.report.base import InstrumentationVisualizer, RenderProfile + +__all__ = ( + "InstrumentationVisualizer", + "RenderProfile", +) diff --git a/src/dvsim/instrumentation/report/base.py b/src/dvsim/instrumentation/report/base.py new file mode 100644 index 00000000..e10c8d2b --- /dev/null +++ b/src/dvsim/instrumentation/report/base.py @@ -0,0 +1,49 @@ +# Copyright lowRISC contributors (OpenTitan project). +# Licensed under the Apache License, Version 2.0, see LICENSE for details. +# SPDX-License-Identifier: Apache-2.0 + +"""DVSim scheduler instrumentation reporting & visualizations.""" + +from enum import Enum +from typing import Protocol + +from typing_extensions import Self + +from dvsim.instrumentation import InstrumentationResults +from dvsim.logging import log + +__all__ = ( + "InstrumentationVisualizer", + "RenderProfile", +) + + +class RenderProfile(Enum): + """Levels of visualization rendering detail, which impact report size & responsiveness.""" + + NORMAL = "normal" + HIGH = "high" + FULL = "full" + + +class InstrumentationVisualizer(Protocol): + """Builder & renderer for HTML instrumentation visualizations.""" + + # A short name / title of the visualization, used in the HTML report navigation tab + title: str + + def render(self, results: InstrumentationResults) -> str | None: + """Render a visualization from the instrumentation results as a HTML fragment. + + If the required data is not provide in the instrumentation results (e.g. not enough + data, or not the correct type of data recorded), or the visualization should not be + generated, this can also optionally return `None`. + + """ + ... + + @classmethod + def for_profile(cls, profile: RenderProfile) -> Self: + """Create a visualizer instance configured for a given rendering profile (if supported).""" + log.debug("Render profile %s not used by visualization '%s'", profile.name, cls.__name__) + return cls() From 72f58364ab1a067a4cd4d373ab65b7cc155d96a9 Mon Sep 17 00:00:00 2001 From: Alex Jones Date: Wed, 13 May 2026 12:13:19 +0100 Subject: [PATCH 03/11] feat: add instrumentation report visualisation registry Add a registry (like the existing registry/factory for the different instrumentation types) that we can use to add new instrumentation visualisations. In the same manner that we currently support plugins to extend the instrumentation functionality of DVSim, this will allow users extending DVSim to hook in and add their own custom instrumentation visualisations as well. Signed-off-by: Alex Jones --- src/dvsim/instrumentation/report/__init__.py | 2 + src/dvsim/instrumentation/report/registry.py | 47 ++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/dvsim/instrumentation/report/registry.py diff --git a/src/dvsim/instrumentation/report/__init__.py b/src/dvsim/instrumentation/report/__init__.py index 87e54362..abf41b82 100644 --- a/src/dvsim/instrumentation/report/__init__.py +++ b/src/dvsim/instrumentation/report/__init__.py @@ -5,8 +5,10 @@ """DVSim Scheduler Instrumentation report.""" from dvsim.instrumentation.report.base import InstrumentationVisualizer, RenderProfile +from dvsim.instrumentation.report.registry import ReportVisualizationRegistry __all__ = ( "InstrumentationVisualizer", "RenderProfile", + "ReportVisualizationRegistry", ) diff --git a/src/dvsim/instrumentation/report/registry.py b/src/dvsim/instrumentation/report/registry.py new file mode 100644 index 00000000..548f807a --- /dev/null +++ b/src/dvsim/instrumentation/report/registry.py @@ -0,0 +1,47 @@ +# Copyright lowRISC contributors (OpenTitan project). +# Licensed under the Apache License, Version 2.0, see LICENSE for details. +# SPDX-License-Identifier: Apache-2.0 + +"""DVSim scheduler instrumentation report visualization registry.""" + +from typing import ClassVar + +from dvsim.instrumentation.report.base import InstrumentationVisualizer, RenderProfile + +__all__ = ("ReportVisualizationRegistry",) + + +class ReportVisualizationRegistry: + """Registry for scheduler instrumentation visualizer classes.""" + + _registry: ClassVar[dict[str, type[InstrumentationVisualizer]]] = {} + + @classmethod + def register(cls, vis_cls: type[InstrumentationVisualizer]) -> None: + """Register a new instrumentation visualization type.""" + cls._registry[vis_cls.title] = vis_cls + + @classmethod + def clear(cls) -> None: + """Clear any registered instrumentation visualization types.""" + cls._registry.clear() + + @classmethod + def registered(cls) -> dict[str, type[InstrumentationVisualizer]]: + """Get the current state of the registered instrumentation types.""" + return cls._registry.copy() + + @classmethod + def create(cls, profile: RenderProfile | None = None) -> list[InstrumentationVisualizer]: + """Create instances of registered visualization types for a given (optional) profile. + + Args: + profile: The rendering profile (level of detail) to target, if provided. + + Returns: + A list of InstrumentationVisualizer implementations created for the given profile. + + """ + if profile is None: + return [vis_cls() for vis_cls in cls._registry.values()] + return [vis_cls.for_profile(profile) for vis_cls in cls._registry.values()] From 5b3905bb94b3e16a01221324779ca6c144541ae6 Mon Sep 17 00:00:00 2001 From: Alex Jones Date: Wed, 13 May 2026 12:24:45 +0100 Subject: [PATCH 04/11] feat: add head block to the HTML report wrapper template This will allow reports to add custom content to the HTML head if desired for e.g. styling purposes. Signed-off-by: Alex Jones --- src/dvsim/templates/reports/wrapper.html | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dvsim/templates/reports/wrapper.html b/src/dvsim/templates/reports/wrapper.html index 3f84d392..0e98b5a8 100644 --- a/src/dvsim/templates/reports/wrapper.html +++ b/src/dvsim/templates/reports/wrapper.html @@ -16,6 +16,7 @@ + {% block head_content %}{% endblock %} From 72282dec2234728ad16d6b0d7e55ee7a4edec29f Mon Sep 17 00:00:00 2001 From: Alex Jones Date: Thu, 30 Apr 2026 16:38:57 +0100 Subject: [PATCH 05/11] build: add plotly dependency for instrumentation graph visualisations Signed-off-by: Alex Jones --- pyproject.toml | 1 + uv.lock | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d1c4e1d5..c089fa46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "gitpython>=3.1.45", "hjson>=3.1.0", "jinja2>=3.1.6", + "plotly>=6.7.0", "psutil>=7.2.2", "pydantic>=2.9.2", "pyyaml>=6.0.2", diff --git a/uv.lock b/uv.lock index 8a481817..5784a873 100644 --- a/uv.lock +++ b/uv.lock @@ -374,6 +374,7 @@ dependencies = [ { name = "gitpython" }, { name = "hjson" }, { name = "jinja2" }, + { name = "plotly" }, { name = "psutil" }, { name = "pydantic" }, { name = "pyyaml" }, @@ -465,6 +466,7 @@ requires-dist = [ { name = "hjson", specifier = ">=3.1.0" }, { name = "ipython", marker = "extra == 'debug'", specifier = ">=8.18.1" }, { name = "jinja2", specifier = ">=3.1.6" }, + { name = "plotly", specifier = ">=6.7.0" }, { name = "psutil", specifier = ">=7.2.2" }, { name = "pydantic", specifier = ">=2.9.2" }, { name = "pyhamcrest", marker = "extra == 'test'", specifier = ">=2.1.0" }, @@ -828,6 +830,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "narwhals" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/f3/257adc69a71011b4c8cda321b00f02c5bf1980ae38ffd05a58d9632d4de8/narwhals-2.20.0.tar.gz", hash = "sha256:c10994975fa7dc5a68c2cffcddbd5908fc8ebb2d463c5bab085309c0ee1f551e", size = 627848, upload-time = "2026-04-20T12:11:45.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -867,6 +878,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] +[[package]] +name = "plotly" +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/7f/0f100df1172aadf88a929a9dbb902656b0880ba4b960fe5224867159d8f4/plotly-6.7.0.tar.gz", hash = "sha256:45eea0ff27e2a23ccd62776f77eb43aa1ca03df4192b76036e380bb479b892c6", size = 6911286, upload-time = "2026-04-09T20:36:45.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444, upload-time = "2026-04-09T20:36:39.812Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" From da47cc71ac705f5775496aa7f4aeed0f4f1162ca Mon Sep 17 00:00:00 2001 From: Alex Jones Date: Wed, 13 May 2026 13:59:56 +0100 Subject: [PATCH 06/11] feat: add HTML instrumentation report generation Whenever a DVSim scheduler run completes and we flush the JSON instrumentation report, add additional logic to create the HTML instrumentation report as well - under the same report directory that is currently being used for the existing simulation HTML report outputs. It is not 100% clear whether this is the best place to integrate the instrumentation reporting yet. It seems like it might be more sensible to do it alongside the simulation reporting to keep common functionality grouped together, but this then means that we do not get the same benefits for other flows (e.g. linting, formal) that do not have their own custom HTML reports. So for now, we keep this in the generic scheduler running logic. Signed-off-by: Alex Jones --- src/dvsim/instrumentation/__init__.py | 11 ++++++- src/dvsim/instrumentation/runtime.py | 42 ++++++++++++++++++++++++++- src/dvsim/scheduler/runner.py | 2 +- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/dvsim/instrumentation/__init__.py b/src/dvsim/instrumentation/__init__.py index 1f13f8d2..7f6fd9e0 100644 --- a/src/dvsim/instrumentation/__init__.py +++ b/src/dvsim/instrumentation/__init__.py @@ -12,7 +12,14 @@ JobMetrics, SchedulerMetrics, ) -from dvsim.instrumentation.runtime import flush, get, set_instrumentation, set_report_path +from dvsim.instrumentation.runtime import ( + flush, + gen_html_report, + get, + get_report, + set_instrumentation, + set_report_path, +) __all__ = ( "InstrumentationAggregator", @@ -23,7 +30,9 @@ "SchedulerInstrumentation", "SchedulerMetrics", "flush", + "gen_html_report", "get", + "get_report", "set_instrumentation", "set_report_path", ) diff --git a/src/dvsim/instrumentation/runtime.py b/src/dvsim/instrumentation/runtime.py index d7447e6b..2ee25ef6 100644 --- a/src/dvsim/instrumentation/runtime.py +++ b/src/dvsim/instrumentation/runtime.py @@ -2,15 +2,21 @@ # Licensed under the Apache License, Version 2.0, see LICENSE for details. # SPDX-License-Identifier: Apache-2.0 -"""DVSim scheduler instrumentation for timing-related information.""" +"""DVSim scheduler instrumentation runtime implementation.""" from pathlib import Path from dvsim.instrumentation.base import InstrumentationAggregator, InstrumentationResults +from dvsim.instrumentation.report import ( + RenderProfile, + ReportVisualizationRegistry, + render_html_report, +) from dvsim.logging import log __all__ = ( "flush", + "gen_html_report", "get", "get_report", "set_instrumentation", @@ -19,7 +25,10 @@ class _Runtime: + """Runtime singleton for the configured scheduler instrumentation.""" + def __init__(self) -> None: + """Construct a Runtime for the scheduler instrumentation.""" self.instrumentation: InstrumentationAggregator | None = None self.report_path: Path | None = None self.report: InstrumentationResults | None = None @@ -71,3 +80,34 @@ def flush() -> InstrumentationResults | None: def get_report() -> InstrumentationResults | None: """Get the latest flushed instrumentation report contents, if any exist.""" return _runtime.report + + +def gen_html_report( + results: InstrumentationResults, + *, + profile: RenderProfile | None = None, + outdir: Path | None = None, + json_path: Path | None = None, +) -> None: + """Generate a HTML report of the instrumentation results with rendered visualizations. + + Args: + results: The instrumentation results to render a HTML report for. + profile: Optional rendering profile to customize level of detail vs. report optimization. + outdir: The path to the directory to write the generated HTML report files to. If not + provided, this defaults to the parent directory of the configured report path. + json_path: The path to the metrics.json file. If not provided, this defaults to the + configured report path (if it exists). + + """ + if outdir is None: + if _runtime.report_path is None: + return + outdir = _runtime.report_path.parent + + if json_path is None: + json_path = _runtime.report_path + + log.debug("HTML instrumentation report will be written to %s", outdir) + visualizations = ReportVisualizationRegistry.create(profile) + render_html_report(results, visualizations=visualizations, outdir=outdir, json_path=json_path) diff --git a/src/dvsim/scheduler/runner.py b/src/dvsim/scheduler/runner.py index 3fc5876a..587f5c8e 100644 --- a/src/dvsim/scheduler/runner.py +++ b/src/dvsim/scheduler/runner.py @@ -141,6 +141,6 @@ async def run_scheduler( # Finalize instrumentation if inst is not None: inst.stop() - instrumentation.flush() + instrumentation.gen_html_report(instrumentation.flush()) return results From d80ae6249ca7bda8c9490ea83802c4d8b0b3612b Mon Sep 17 00:00:00 2001 From: Alex Jones Date: Wed, 13 May 2026 12:29:47 +0100 Subject: [PATCH 07/11] feat: add instrumentation report rendering from template This commit introduces the instrumentation report template itself, and a function `render_html_report` for rendering the instrumentation report with a list of given instrumentation report visualization implementations. Some key things to note: - We use `plotly.offline` to get the version of the minified plotly.js that is packaged with the plotly Python package. Like vendored static files this will still work in an airgapped environment, but this way it should remain in sync even when the plotly dependency has its version updated. - We only include plotly JS (which is around 4MB minified) as a dependency when we actually have renders that might use it. - For now we include all visualizations as different tabs on the same page. This is to keep things simple as the goal is to keep all the report logic relatively small and self-contained, but this could be expanded upon in the future if it is deemed too restrictive of an interface. Signed-off-by: Alex Jones --- src/dvsim/instrumentation/report/__init__.py | 7 +- src/dvsim/instrumentation/report/base.py | 83 +++++++++++++++++++ .../reports/instrumentation_report.html | 73 ++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 src/dvsim/templates/reports/instrumentation_report.html diff --git a/src/dvsim/instrumentation/report/__init__.py b/src/dvsim/instrumentation/report/__init__.py index abf41b82..624c1e31 100644 --- a/src/dvsim/instrumentation/report/__init__.py +++ b/src/dvsim/instrumentation/report/__init__.py @@ -4,11 +4,16 @@ """DVSim Scheduler Instrumentation report.""" -from dvsim.instrumentation.report.base import InstrumentationVisualizer, RenderProfile +from dvsim.instrumentation.report.base import ( + InstrumentationVisualizer, + RenderProfile, + render_html_report, +) from dvsim.instrumentation.report.registry import ReportVisualizationRegistry __all__ = ( "InstrumentationVisualizer", "RenderProfile", "ReportVisualizationRegistry", + "render_html_report", ) diff --git a/src/dvsim/instrumentation/report/base.py b/src/dvsim/instrumentation/report/base.py index e10c8d2b..95e9a545 100644 --- a/src/dvsim/instrumentation/report/base.py +++ b/src/dvsim/instrumentation/report/base.py @@ -4,17 +4,23 @@ """DVSim scheduler instrumentation reporting & visualizations.""" +from collections.abc import Sequence from enum import Enum +from pathlib import Path from typing import Protocol +import plotly.offline from typing_extensions import Self from dvsim.instrumentation import InstrumentationResults from dvsim.logging import log +from dvsim.report.artifacts import ReportArtifacts, render_static_content +from dvsim.templates.render import render_template __all__ = ( "InstrumentationVisualizer", "RenderProfile", + "render_html_report", ) @@ -47,3 +53,80 @@ def for_profile(cls, profile: RenderProfile) -> Self: """Create a visualizer instance configured for a given rendering profile (if supported).""" log.debug("Render profile %s not used by visualization '%s'", profile.name, cls.__name__) return cls() + + +def render_html_report( + results: InstrumentationResults, + *, + visualizations: Sequence[InstrumentationVisualizer] | None = None, + outdir: Path | None = None, + json_path: Path | None = None, +) -> ReportArtifacts: + """Render a HTML instrumentation report for some results & visualizations. + + Args: + results: The instrumentation results to generate a report from. + visualizations: The list of visualizations (if any) to display in the report. + outdir: The optional directory to write the 'metrics.html' report to, if desired. + json_path: Optional path to the 'metrics.json' file. + + Returns: + The generated file contents for the report - 'metrics.html' and static CSS/JS content. + + """ + log.info("Rendering instrumentation HTML report...") + + visualizations = visualizations or [] + renders: list[tuple[InstrumentationVisualizer, str]] = [] + for i, vis in enumerate(visualizations or [], start=1): + log.debug( + "Attempting to render instrumentation visualization: %s [%d/%d]", + vis.title, + i, + len(visualizations), + ) + render = vis.render(results) + if render is not None: + log.info("Rendered instrumentation visualization: %s", vis.title) + renders.append((vis, render)) + + metrics_json_path = None if json_path is None else json_path + if metrics_json_path and outdir and metrics_json_path.is_relative_to(outdir): + metrics_json_path = metrics_json_path.relative_to(outdir) + if outdir is not None: + outdir.mkdir(parents=True, exist_ok=True) + + artifacts = {} + + # Render the visualizations to a single metrics.html file + artifacts["metrics.html"] = render_template( + path="reports/instrumentation_report.html", + data={"renders": renders, "metrics_json": metrics_json_path}, + ) + if outdir is not None: + report_path = outdir / "metrics.html" + report_path.write_text(artifacts["metrics.html"]) + log.info("HTML instrumentation report written to %s", report_path) + + # Render static content needed for the report + artifacts.update( + render_static_content( + static_files=[ + "css/style.css", + "css/bootstrap.min.css", + "js/bootstrap.bundle.min.js", + "js/htmx.min.js", + ], + outdir=outdir, + ) + ) + + # Render static plotly.js separately. We generate the static minified JS from the plotly + # library itself to make sure we are using the correct version. + if renders: + plotly_js_path = "js/plotly.min.js" + artifacts[plotly_js_path] = plotly.offline.get_plotlyjs() + if outdir is not None: + (outdir / plotly_js_path).write_text(artifacts[plotly_js_path]) + + return artifacts diff --git a/src/dvsim/templates/reports/instrumentation_report.html b/src/dvsim/templates/reports/instrumentation_report.html new file mode 100644 index 00000000..7747a285 --- /dev/null +++ b/src/dvsim/templates/reports/instrumentation_report.html @@ -0,0 +1,73 @@ + +{% extends "reports/wrapper.html" %} +{% block head_content %} +{% if renders %} + +{% endif %} +{% endblock %} +{% block content %} +
+
+
+

Instrumentation report

+
+ {% if metrics_json %} +
+ JSON +
+ {% endif %} +
+ +
+
+ {% if not renders %} +

No instrumentation visualizations matched the available report data.

+ {% else %} + +
+ {% for vis, fragment in renders %} + {% set class = "tab-pane fade" + (" show active" if loop.index0 == 0 else "") %} + {% set slug = vis.title.lower() | replace("_", "-") | replace (" ", "-") %} +
+
+ {{ fragment | safe }} +
+
+ {% endfor %} +
+ {% endif %} +
+
+
+ + +{% endblock %} From 520f57a7d052cd8cabb465922a533fda8f51a3af Mon Sep 17 00:00:00 2001 From: Alex Jones Date: Wed, 13 May 2026 11:52:49 +0100 Subject: [PATCH 08/11] feat: add time formatting functions for metric display Intended to be used for the instrumentation reporting logic, but this is general enough that it can just be placed in the time utils. The `format_time_as_hms` function gets a time format like `12h 34m 56.79s`. As far as I could tell, there is no nice way to do this using e.g. the existing datetime standard library, and this is simple to implement manually, so add it as a utility here. The `format_time_metric` is an extension of this intended for the display of time metrics in the instrumentation reports. It reports that time in hours, minutes and seconds, but also puts the time in raw seconds alongside it (e.g. `2h 15m 37.21s (8,137.21s)`). Signed-off-by: Alex Jones --- src/dvsim/utils/__init__.py | 10 +++++++++- src/dvsim/utils/time.py | 40 +++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/dvsim/utils/__init__.py b/src/dvsim/utils/__init__.py index b893ecb2..66bb5ed0 100644 --- a/src/dvsim/utils/__init__.py +++ b/src/dvsim/utils/__init__.py @@ -8,7 +8,13 @@ from dvsim.utils.fs import clean_odirs, mk_path, mk_symlink, rm_path from dvsim.utils.hjson import parse_hjson from dvsim.utils.subprocess import run_cmd, run_cmd_with_timeout -from dvsim.utils.time import TS_FORMAT, TS_FORMAT_LONG, TS_HMS_FORMAT +from dvsim.utils.time import ( + TS_FORMAT, + TS_FORMAT_LONG, + TS_HMS_FORMAT, + format_time_as_hms, + format_time_metric, +) from dvsim.utils.wildcards import ( find_and_substitute_wildcards, subst_wildcards, @@ -22,6 +28,8 @@ "check_int", "clean_odirs", "find_and_substitute_wildcards", + "format_time_as_hms", + "format_time_metric", "mk_path", "mk_symlink", "parse_hjson", diff --git a/src/dvsim/utils/time.py b/src/dvsim/utils/time.py index 2a464e17..08028a15 100644 --- a/src/dvsim/utils/time.py +++ b/src/dvsim/utils/time.py @@ -12,3 +12,43 @@ # Timestamp format for Hours:Minutes:Seconds display as used by the scheduler TS_HMS_FORMAT = "%H:%M:%S" + + +def format_time_as_hms(seconds: float, *, decimals: int = 2, omit_zero: bool = False) -> str: + """Format a time in seconds like '12h 34m 56.79s'. + + Args: + seconds: The time in seconds to format. + decimals: The number of decimal places to use for non-integer seconds (default 2). + omit_zero: True if zero fields (e.g. '0h 0m') should be omitted, false otherwise. + + Returns: + A formatted time string. + + """ + hours, remainder = divmod(seconds, 3600) + minutes, secs = divmod(remainder, 60) + if omit_zero and hours == 0 and minutes == 0: + return f"{secs:.{decimals}f}s" + if omit_zero and hours == 0: + return f"{int(minutes)}m {secs:.{decimals}f}s" + return f"{int(hours)}h {int(minutes)}m {secs:.{decimals}f}s" + + +def format_time_metric( + seconds: float, *, hms_decimals: int = 2, second_decimals: int = 2, omit_zero: bool = False +) -> str: + """Return a metric time formatted as e.g. '2h 15m 37.21s (8,137.21s)'. + + Args: + seconds: The time in seconds to format. + hms_decimals: The number of decimal places to use for the 'Xh Ym Zs' output. + second_decimals: The number of decimal places to use for the '(Xs)' output. + omit_zero: True if zero fields (e.g. '0h 0m') should be omitted, false otherwise. + + Returns: + A formatted string for the time metric. + + """ + hms = format_time_as_hms(seconds, decimals=hms_decimals, omit_zero=omit_zero) + return f"{hms} ({seconds:,.{second_decimals}f}s)" From abba948fe23f9889d142a86476c9ed3f11fa7feb Mon Sep 17 00:00:00 2001 From: Alex Jones Date: Wed, 13 May 2026 11:57:00 +0100 Subject: [PATCH 09/11] feat: add instrumentation report timing helper methods This commit adds some helper methods to the full instrumentation report pydantic model to make generation of instrumentation report visualizations easier. Notably, it introduces a new `ConcreteJobTimingMetrics` model. A common pattern within the instrumentation logic is to only get jobs for which we definitely know the start and end time (and thus the duration), and to discard other jobs. We must keep the partial model since this guarantee does not hold during instrumentation, but we can make a helper that can filter the jobs and return the concrete models. This saves a lot of trouble with static type analysis and the need to constantly re-assert the presence of timing information throughout instrumentation logic. The other `get_run_time_info` is an additional cautious helper method. Generally we would always expect the scheduler timing info to be populated, but in case it is not for whatever reason, we can derive a near approximation for these times by simply examining the start and end times of the jobs themselves. Signed-off-by: Alex Jones --- src/dvsim/instrumentation/records.py | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/dvsim/instrumentation/records.py b/src/dvsim/instrumentation/records.py index 710d02be..7ff730bd 100644 --- a/src/dvsim/instrumentation/records.py +++ b/src/dvsim/instrumentation/records.py @@ -15,6 +15,7 @@ ) __all__ = ( + "ConcreteJobTimingMetrics", "InstrumentationMetrics", "InstrumentationResults", "JobComputeMetrics", @@ -114,6 +115,16 @@ def drop_computed_fields(cls, data: Any) -> Any: # noqa: ANN401 return data +class ConcreteJobTimingMetrics(JobMetrics): + """Concrete job timing information with all known fields populated.""" + + model_config = ConfigDict(frozen=True, extra="ignore") + + start_time: float + end_time: float + duration: float + + # Compute Resource Metrics @@ -180,3 +191,27 @@ class InstrumentationResults(BaseModel): scheduler: SchedulerInstrumentationResults jobs: dict[str, JobInstrumentationResults] = Field(default_factory=dict) + + def job_timings(self) -> dict[str, ConcreteJobTimingMetrics]: + """Get any job timing information that exists in the instrumentation report.""" + return { + job_id: ConcreteJobTimingMetrics( + start_time=results.timing.start_time, + end_time=results.timing.end_time, + duration=(results.timing.end_time - results.timing.start_time), + ) + for job_id, results in self.jobs.items() + if results.timing is not None + and results.timing.start_time is not None + and results.timing.end_time is not None + } + + def get_run_time_info(self) -> tuple[float, float]: + """Get the overall time, using job timing as a fallback if scheduler info is missing.""" + timing = self.scheduler.timing + if timing is None or timing.start_time is None or timing.end_time is None: + job_timings = self.job_timings() + start_time = min((time.start_time for time in job_timings.values()), default=0.0) + end_time = max((time.end_time for time in job_timings.values()), default=0.0) + return start_time, end_time + return timing.start_time, timing.end_time From 073b4bdeb3b429249a7833dcc3ef93c63dc78e1c Mon Sep 17 00:00:00 2001 From: Alex Jones Date: Wed, 13 May 2026 11:26:42 +0100 Subject: [PATCH 10/11] build: add kaleido dependency for rendering plotly graphs to images The additional kaleido dependency is needed so that plotly is able to render & export graphs to image formats (e.g. PNG) instead of directly rendering some dynamic HTML graph. This is needed for graphs for large runs (> 10K jobs), where the directly rendered graphs grow to enormous sizes if not compressed via conversion to an image. Signed-off-by: Alex Jones --- pyproject.toml | 1 + uv.lock | 206 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index c089fa46..b48bb5b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "gitpython>=3.1.45", "hjson>=3.1.0", "jinja2>=3.1.6", + "kaleido>=1.2.0", "plotly>=6.7.0", "psutil>=7.2.2", "pydantic>=2.9.2", diff --git a/uv.lock b/uv.lock index 5784a873..cc1b0b09 100644 --- a/uv.lock +++ b/uv.lock @@ -182,6 +182,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, ] +[[package]] +name = "choreographer" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "logistro" }, + { name = "platformdirs" }, + { name = "simplejson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/69/3058cd4f16d6b75c80e8f95e5b713d930526353ce294df9a7887453ba215/choreographer-1.3.0.tar.gz", hash = "sha256:6c44a0e48e9b37977344d40bfa5a9ed88575fe4bc0fd836771bf702bc24d6884", size = 48291, upload-time = "2026-04-28T22:57:45.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/6c/ff8bf52315064dbeb55cb5067e191120a5b2e58bb648d0d34cf7969dc2c2/choreographer-1.3.0-py3-none-any.whl", hash = "sha256:cea4cb739e4f61625e4b53888a8d3fa1d3bf73948b56753e460ab44da7d8d44f", size = 52622, upload-time = "2026-04-28T22:57:44.015Z" }, +] + [[package]] name = "click" version = "8.1.8" @@ -374,6 +388,7 @@ dependencies = [ { name = "gitpython" }, { name = "hjson" }, { name = "jinja2" }, + { name = "kaleido" }, { name = "plotly" }, { name = "psutil" }, { name = "pydantic" }, @@ -466,6 +481,7 @@ requires-dist = [ { name = "hjson", specifier = ">=3.1.0" }, { name = "ipython", marker = "extra == 'debug'", specifier = ">=8.18.1" }, { name = "jinja2", specifier = ">=3.1.6" }, + { name = "kaleido", specifier = ">=1.2.0" }, { name = "plotly", specifier = ">=6.7.0" }, { name = "psutil", specifier = ">=7.2.2" }, { name = "pydantic", specifier = ">=2.9.2" }, @@ -712,6 +728,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/e3/0e0014d6ab159d48189e92044ace13b1e1fe9aa3024ba9f4e8cf172aa7c2/jinxed-1.3.0-py2.py3-none-any.whl", hash = "sha256:b993189f39dc2d7504d802152671535b06d380b26d78070559551cbf92df4fc5", size = 33085, upload-time = "2024-07-31T22:39:17.426Z" }, ] +[[package]] +name = "kaleido" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "choreographer" }, + { name = "logistro" }, + { name = "orjson" }, + { name = "packaging" }, + { name = "pytest-timeout" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ad/76eec859b71eda803a88ea50ed3f270281254656bb23d19eb0a39aa706a0/kaleido-1.2.0.tar.gz", hash = "sha256:fa621a14423e8effa2895a2526be00af0cf21655be1b74b7e382c171d12e71ef", size = 64160, upload-time = "2025-11-04T21:24:23.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/97/f6de8d4af54d6401d6581a686cce3e3e2371a79ba459a449104e026c08bc/kaleido-1.2.0-py3-none-any.whl", hash = "sha256:c27ed82b51df6b923d0e656feac221343a0dbcd2fb9bc7e6b1db97f61e9a1513", size = 68997, upload-time = "2025-11-04T21:24:21.704Z" }, +] + +[[package]] +name = "logistro" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/90/bfd7a6fab22bdfafe48ed3c4831713cb77b4779d18ade5e248d5dbc0ca22/logistro-2.0.1.tar.gz", hash = "sha256:8446affc82bab2577eb02bfcbcae196ae03129287557287b6a070f70c1985047", size = 8398, upload-time = "2025-11-01T02:41:18.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/6aa79ba3570bddd1bf7e951c6123f806751e58e8cce736bad77b2cf348d7/logistro-2.0.1-py3-none-any.whl", hash = "sha256:06ffa127b9fb4ac8b1972ae6b2a9d7fde57598bf5939cd708f43ec5bba2d31eb", size = 8555, upload-time = "2025-11-01T02:41:17.587Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -848,6 +889,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "orjson" +version = "3.11.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/90/5d81f61fe3e4270da80c71442864c091cee3003cc8984c75f413fe742a07/orjson-3.11.8-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e6693ff90018600c72fd18d3d22fa438be26076cd3c823da5f63f7bab28c11cb", size = 229663, upload-time = "2026-03-31T16:14:30.708Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/85e06b0eb11de6fb424120fd5788a07035bd4c5e6bb7841ae9972a0526d1/orjson-3.11.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93de06bc920854552493c81f1f729fab7213b7db4b8195355db5fda02c7d1363", size = 132321, upload-time = "2026-03-31T16:14:32.317Z" }, + { url = "https://files.pythonhosted.org/packages/86/71/089338ee51b3132f050db0864a7df9bdd5e94c2a03820ab8a91e8f655618/orjson-3.11.8-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe0b8c83e0f36247fc9431ce5425a5d95f9b3a689133d494831bdbd6f0bceb13", size = 130658, upload-time = "2026-03-31T16:14:33.935Z" }, + { url = "https://files.pythonhosted.org/packages/10/0d/f39d8802345d0ad65f7fd4374b29b9b59f98656dc30f21ca5c773265b2f0/orjson-3.11.8-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:97d823831105c01f6c8029faf297633dbeb30271892bd430e9c24ceae3734744", size = 135708, upload-time = "2026-03-31T16:14:35.224Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b5/40aae576b3473511696dcffea84fde638b2b64774eb4dcb8b2c262729f8a/orjson-3.11.8-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60c0423f15abb6cf78f56dff00168a1b582f7a1c23f114036e2bfc697814d5f", size = 147047, upload-time = "2026-03-31T16:14:36.489Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f0/778a84458d1fdaa634b2e572e51ce0b354232f580b2327e1f00a8d88c38c/orjson-3.11.8-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01928d0476b216ad2201823b0a74000440360cef4fed1912d297b8d84718f277", size = 133072, upload-time = "2026-03-31T16:14:37.715Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/1bbf2fc3ffcc4b829ade554b574af68cec898c9b5ad6420a923c75a073d3/orjson-3.11.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a4a639049c44d36a6d1ae0f4a94b271605c745aee5647fa8ffaabcdc01b69a6", size = 133867, upload-time = "2026-03-31T16:14:39.356Z" }, + { url = "https://files.pythonhosted.org/packages/08/94/6413da22edc99a69a8d0c2e83bf42973b8aa94d83ef52a6d39ac85da00bc/orjson-3.11.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3222adff1e1ff0dce93c16146b93063a7793de6c43d52309ae321234cdaf0f4d", size = 142268, upload-time = "2026-03-31T16:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5f/aa5dbaa6136d7ba55f5461ac2e885efc6e6349424a428927fd46d68f4396/orjson-3.11.8-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3223665349bbfb68da234acd9846955b1a0808cbe5520ff634bf253a4407009b", size = 424008, upload-time = "2026-03-31T16:14:42.637Z" }, + { url = "https://files.pythonhosted.org/packages/fa/aa/2c1962d108c7fe5e27aa03a354b378caf56d8eafdef15fd83dec081ce45a/orjson-3.11.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:61c9d357a59465736022d5d9ba06687afb7611dfb581a9d2129b77a6fcf78e59", size = 147942, upload-time = "2026-03-31T16:14:44.256Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/65f404f4c47eb1b0b4476f03ec838cac0c4aa933920ff81e5dda4dee14e7/orjson-3.11.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58fb9b17b4472c7b1dcf1a54583629e62e23779b2331052f09a9249edf81675b", size = 136640, upload-time = "2026-03-31T16:14:45.884Z" }, + { url = "https://files.pythonhosted.org/packages/90/5f/7b784aea98bdb125a2f2da7c27d6c2d2f6d943d96ef0278bae596d563f85/orjson-3.11.8-cp310-cp310-win32.whl", hash = "sha256:b43dc2a391981d36c42fa57747a49dae793ef1d2e43898b197925b5534abd10a", size = 132066, upload-time = "2026-03-31T16:14:47.397Z" }, + { url = "https://files.pythonhosted.org/packages/92/ec/2e284af8d6c9478df5ef938917743f61d68f4c70d17f1b6e82f7e3b8dba1/orjson-3.11.8-cp310-cp310-win_amd64.whl", hash = "sha256:c98121237fea2f679480765abd566f7713185897f35c9e6c2add7e3a9900eb61", size = 127609, upload-time = "2026-03-31T16:14:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/67/41/5aa7fa3b0f4dc6b47dcafc3cea909299c37e40e9972feabc8b6a74e2730d/orjson-3.11.8-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:003646067cc48b7fcab2ae0c562491c9b5d2cbd43f1e5f16d98fd118c5522d34", size = 229229, upload-time = "2026-03-31T16:14:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/0a/d7/57e7f2458e0a2c41694f39fc830030a13053a84f837a5b73423dca1f0938/orjson-3.11.8-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:ed193ce51d77a3830cad399a529cd4ef029968761f43ddc549e1bc62b40d88f8", size = 128871, upload-time = "2026-03-31T16:14:51.888Z" }, + { url = "https://files.pythonhosted.org/packages/53/4a/e0fdb9430983e6c46e0299559275025075568aad5d21dd606faee3703924/orjson-3.11.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30491bc4f862aa15744b9738517454f1e46e56c972a2be87d70d727d5b2a8f8", size = 132104, upload-time = "2026-03-31T16:14:53.142Z" }, + { url = "https://files.pythonhosted.org/packages/08/4a/2025a60ff3f5c8522060cda46612d9b1efa653de66ed2908591d8d82f22d/orjson-3.11.8-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eda5b8b6be91d3f26efb7dc6e5e68ee805bc5617f65a328587b35255f138bf4", size = 130483, upload-time = "2026-03-31T16:14:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3c/b9cde05bdc7b2385c66014e0620627da638d3d04e4954416ab48c31196c5/orjson-3.11.8-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee8db7bfb6fe03581bbab54d7c4124a6dd6a7f4273a38f7267197890f094675f", size = 135481, upload-time = "2026-03-31T16:14:55.901Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f2/a8238e7734de7cb589fed319857a8025d509c89dc52fdcc88f39c6d03d5a/orjson-3.11.8-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d8b5231de76c528a46b57010bbd83fb51e056aa0220a372fd5065e978406f1c", size = 146819, upload-time = "2026-03-31T16:14:57.548Z" }, + { url = "https://files.pythonhosted.org/packages/db/10/dbf1e2a3cafea673b1b4350e371877b759060d6018a998643b7040e5de48/orjson-3.11.8-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58a4a208a6fbfdb7a7327b8f201c6014f189f721fd55d047cafc4157af1bc62a", size = 132846, upload-time = "2026-03-31T16:14:58.91Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fc/55e667ec9c85694038fcff00573d221b085d50777368ee3d77f38668bf3c/orjson-3.11.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f8952d6d2505c003e8f0224ff7858d341fa4e33fef82b91c4ff0ef070f2393c", size = 133580, upload-time = "2026-03-31T16:15:00.519Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a6/c08c589a9aad0cb46c4831d17de212a2b6901f9d976814321ff8e69e8785/orjson-3.11.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0022bb50f90da04b009ce32c512dc1885910daa7cb10b7b0cba4505b16db82a8", size = 142042, upload-time = "2026-03-31T16:15:01.906Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/2f78ea241d52b717d2efc38878615fe80425bf2beb6e68c984dde257a766/orjson-3.11.8-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ff51f9d657d1afb6f410cb435792ce4e1fe427aab23d2fcd727a2876e21d4cb6", size = 423845, upload-time = "2026-03-31T16:15:03.703Z" }, + { url = "https://files.pythonhosted.org/packages/70/07/c17dcf05dd8045457538428a983bf1f1127928df5bf328cb24d2b7cddacb/orjson-3.11.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6dbe9a97bdb4d8d9d5367b52a7c32549bba70b2739c58ef74a6964a6d05ae054", size = 147729, upload-time = "2026-03-31T16:15:05.203Z" }, + { url = "https://files.pythonhosted.org/packages/90/6c/0fb6e8a24e682e0958d71711ae6f39110e4b9cd8cab1357e2a89cb8e1951/orjson-3.11.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5c370674ebabe16c6ccac33ff80c62bf8a6e59439f5e9d40c1f5ab8fd2215b7", size = 136425, upload-time = "2026-03-31T16:15:07.052Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/4d3cc3a3d616035beb51b24a09bb872942dc452cf2df0c1d11ab35046d9f/orjson-3.11.8-cp311-cp311-win32.whl", hash = "sha256:0e32f7154299f42ae66f13488963269e5eccb8d588a65bc839ed986919fc9fac", size = 131870, upload-time = "2026-03-31T16:15:08.678Z" }, + { url = "https://files.pythonhosted.org/packages/13/26/9fe70f81d16b702f8c3a775e8731b50ad91d22dacd14c7599b60a0941cd1/orjson-3.11.8-cp311-cp311-win_amd64.whl", hash = "sha256:25e0c672a2e32348d2eb33057b41e754091f2835f87222e4675b796b92264f06", size = 127440, upload-time = "2026-03-31T16:15:09.994Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c6/b038339f4145efd2859c1ca53097a52c0bb9cbdd24f947ebe146da1ad067/orjson-3.11.8-cp311-cp311-win_arm64.whl", hash = "sha256:9185589c1f2a944c17e26c9925dcdbc2df061cc4a145395c57f0c51f9b5dbfcd", size = 127399, upload-time = "2026-03-31T16:15:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/01/f6/8d58b32ab32d9215973a1688aebd098252ee8af1766c0e4e36e7831f0295/orjson-3.11.8-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1cd0b77e77c95758f8e1100139844e99f3ccc87e71e6fc8e1c027e55807c549f", size = 229233, upload-time = "2026-03-31T16:15:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/2ffe35e71f6b92622e8ea4607bf33ecf7dfb51b3619dcfabfd36cbe2d0a5/orjson-3.11.8-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6a3d159d5ffa0e3961f353c4b036540996bf8b9697ccc38261c0eac1fd3347a6", size = 128772, upload-time = "2026-03-31T16:15:14.237Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/1f8682ae50d5c6897a563cb96bc106da8c9cb5b7b6e81a52e4cc086679b9/orjson-3.11.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76070a76e9c5ae661e2d9848f216980d8d533e0f8143e6ed462807b242e3c5e8", size = 131946, upload-time = "2026-03-31T16:15:15.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/4b/5500f76f0eece84226e0689cb48dcde081104c2fa6e2483d17ca13685ffb/orjson-3.11.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:54153d21520a71a4c82a0dbb4523e468941d549d221dc173de0f019678cf3813", size = 130368, upload-time = "2026-03-31T16:15:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/da/4e/58b927e08fbe9840e6c920d9e299b051ea667463b1f39a56e668669f8508/orjson-3.11.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:469ac2125611b7c5741a0b3798cd9e5786cbad6345f9f400c77212be89563bec", size = 135540, upload-time = "2026-03-31T16:15:18.404Z" }, + { url = "https://files.pythonhosted.org/packages/56/7c/ba7cb871cba1bcd5cd02ee34f98d894c6cea96353ad87466e5aef2429c60/orjson-3.11.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14778ffd0f6896aa613951a7fbf4690229aa7a543cb2bfbe9f358e08aafa9546", size = 146877, upload-time = "2026-03-31T16:15:19.833Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/eb9c25fc1386696c6a342cd361c306452c75e0b55e86ad602dd4827a7fd7/orjson-3.11.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea56a955056a6d6c550cf18b3348656a9d9a4f02e2d0c02cabf3c73f1055d506", size = 132837, upload-time = "2026-03-31T16:15:21.282Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/5ddeb7fc1fbd9004aeccab08426f34c81a5b4c25c7061281862b015fce2b/orjson-3.11.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53a0f57e59a530d18a142f4d4ba6dfc708dc5fdedce45e98ff06b44930a2a48f", size = 133624, upload-time = "2026-03-31T16:15:22.641Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/90048793db94ee4b2fcec4ac8e5ddb077367637d6650be896b3494b79bb7/orjson-3.11.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b48e274f8824567d74e2158199e269597edf00823a1b12b63d48462bbf5123e", size = 141904, upload-time = "2026-03-31T16:15:24.435Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cf/eb284847487821a5d415e54149a6449ba9bfc5872ce63ab7be41b8ec401c/orjson-3.11.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3f262401086a3960586af06c054609365e98407151f5ea24a62893a40d80dbbb", size = 423742, upload-time = "2026-03-31T16:15:26.155Z" }, + { url = "https://files.pythonhosted.org/packages/44/09/e12423d327071c851c13e76936f144a96adacfc037394dec35ac3fc8d1e8/orjson-3.11.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e8c6218b614badf8e229b697865df4301afa74b791b6c9ade01d19a9953a942", size = 147806, upload-time = "2026-03-31T16:15:27.909Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6d/37c2589ba864e582ffe7611643314785c6afb1f83c701654ef05daa8fcc7/orjson-3.11.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093d489fa039ddade2db541097dbb484999fcc65fc2b0ff9819141e2ab364f25", size = 136485, upload-time = "2026-03-31T16:15:29.749Z" }, + { url = "https://files.pythonhosted.org/packages/be/c9/135194a02ab76b04ed9a10f68624b7ebd238bbe55548878b11ff15a0f352/orjson-3.11.8-cp312-cp312-win32.whl", hash = "sha256:e0950ed1bcb9893f4293fd5c5a7ee10934fbf82c4101c70be360db23ce24b7d2", size = 131966, upload-time = "2026-03-31T16:15:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9a/9796f8fbe3cf30ce9cb696748dbb535e5c87be4bf4fe2e9ca498ef1fa8cf/orjson-3.11.8-cp312-cp312-win_amd64.whl", hash = "sha256:3cf17c141617b88ced4536b2135c552490f07799f6ad565948ea07bef0dcb9a6", size = 127441, upload-time = "2026-03-31T16:15:33.333Z" }, + { url = "https://files.pythonhosted.org/packages/cc/47/5aaf54524a7a4a0dd09dd778f3fa65dd2108290615b652e23d944152bc8e/orjson-3.11.8-cp312-cp312-win_arm64.whl", hash = "sha256:48854463b0572cc87dac7d981aa72ed8bf6deedc0511853dc76b8bbd5482d36d", size = 127364, upload-time = "2026-03-31T16:15:34.748Z" }, + { url = "https://files.pythonhosted.org/packages/66/7f/95fba509bb2305fab0073558f1e8c3a2ec4b2afe58ed9fcb7d3b8beafe94/orjson-3.11.8-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3f23426851d98478c8970da5991f84784a76682213cd50eb73a1da56b95239dc", size = 229180, upload-time = "2026-03-31T16:15:36.426Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9d/b237215c743ca073697d759b5503abd2cb8a0d7b9c9e21f524bcf176ab66/orjson-3.11.8-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ebaed4cef74a045b83e23537b52ef19a367c7e3f536751e355a2a394f8648559", size = 128754, upload-time = "2026-03-31T16:15:38.049Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/27d65b6d11e63f133781425f132807aef793ed25075fec686fc8e46dd528/orjson-3.11.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97c8f5d3b62380b70c36ffacb2a356b7c6becec86099b177f73851ba095ef623", size = 131877, upload-time = "2026-03-31T16:15:39.484Z" }, + { url = "https://files.pythonhosted.org/packages/dd/cc/faee30cd8f00421999e40ef0eba7332e3a625ce91a58200a2f52c7fef235/orjson-3.11.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:436c4922968a619fb7fef1ccd4b8b3a76c13b67d607073914d675026e911a65c", size = 130361, upload-time = "2026-03-31T16:15:41.274Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bb/a6c55896197f97b6d4b4e7c7fd77e7235517c34f5d6ad5aadd43c54c6d7c/orjson-3.11.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ab359aff0436d80bfe8a23b46b5fea69f1e18aaf1760a709b4787f1318b317f", size = 135521, upload-time = "2026-03-31T16:15:42.758Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ca3a3525aa32ff636ebb1778e77e3587b016ab2edb1b618b36ba96f8f2c0/orjson-3.11.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89b6d0b3a8d81e1929d3ab3d92bbc225688bd80a770c49432543928fe09ac55", size = 146862, upload-time = "2026-03-31T16:15:44.341Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0c/18a9d7f18b5edd37344d1fd5be17e94dc652c67826ab749c6e5948a78112/orjson-3.11.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c009e7a2ca9ad0ed1376ce20dd692146a5d9fe4310848904b6b4fee5c5c137", size = 132847, upload-time = "2026-03-31T16:15:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/23/91/7e722f352ad67ca573cee44de2a58fb810d0f4eb4e33276c6a557979fd8a/orjson-3.11.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b895b781b3e395c067129d8551655642dfe9437273211d5404e87ac752b53", size = 133637, upload-time = "2026-03-31T16:15:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/af/04/32845ce13ac5bd1046ddb02ac9432ba856cc35f6d74dde95864fe0ad5523/orjson-3.11.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88006eda83858a9fdf73985ce3804e885c2befb2f506c9a3723cdeb5a2880e3e", size = 141906, upload-time = "2026-03-31T16:15:49.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/5e/c551387ddf2d7106d9039369862245c85738b828844d13b99ccb8d61fd06/orjson-3.11.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:55120759e61309af7fcf9e961c6f6af3dde5921cdb3ee863ef63fd9db126cae6", size = 423722, upload-time = "2026-03-31T16:15:51.176Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/ecfe62434096f8a794d4976728cb59bcfc4a643977f21c2040545d37eb4c/orjson-3.11.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98bdc6cb889d19bed01de46e67574a2eab61f5cc6b768ed50e8ac68e9d6ffab6", size = 147801, upload-time = "2026-03-31T16:15:52.939Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/0dce10b9f6643fdc59d99333871a38fa5a769d8e2fc34a18e5d2bfdee900/orjson-3.11.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:708c95f925a43ab9f34625e45dcdadf09ec8a6e7b664a938f2f8d5650f6c090b", size = 136460, upload-time = "2026-03-31T16:15:54.431Z" }, + { url = "https://files.pythonhosted.org/packages/01/d6/6dde4f31842d87099238f1f07b459d24edc1a774d20687187443ab044191/orjson-3.11.8-cp313-cp313-win32.whl", hash = "sha256:01c4e5a6695dc09098f2e6468a251bc4671c50922d4d745aff1a0a33a0cf5b8d", size = 131956, upload-time = "2026-03-31T16:15:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f9/4e494a56e013db957fb77186b818b916d4695b8fa2aa612364974160e91b/orjson-3.11.8-cp313-cp313-win_amd64.whl", hash = "sha256:c154a35dd1330707450bb4d4e7dd1f17fa6f42267a40c1e8a1daa5e13719b4b8", size = 127410, upload-time = "2026-03-31T16:15:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/803203d00d6edb6e9e7eef421d4e1adbb5ea973e40b3533f3cfd9aeb374e/orjson-3.11.8-cp313-cp313-win_arm64.whl", hash = "sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e", size = 127338, upload-time = "2026-03-31T16:15:59.106Z" }, + { url = "https://files.pythonhosted.org/packages/6d/35/b01910c3d6b85dc882442afe5060cbf719c7d1fc85749294beda23d17873/orjson-3.11.8-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4", size = 229171, upload-time = "2026-03-31T16:16:00.651Z" }, + { url = "https://files.pythonhosted.org/packages/c2/56/c9ec97bd11240abef39b9e5d99a15462809c45f677420fd148a6c5e6295e/orjson-3.11.8-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625", size = 128746, upload-time = "2026-03-31T16:16:02.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/66d4f30a90de45e2f0cbd9623588e8ae71eef7679dbe2ae954ed6d66a41f/orjson-3.11.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5", size = 131867, upload-time = "2026-03-31T16:16:04.342Z" }, + { url = "https://files.pythonhosted.org/packages/19/30/2a645fc9286b928675e43fa2a3a16fb7b6764aa78cc719dc82141e00f30b/orjson-3.11.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db", size = 124664, upload-time = "2026-03-31T16:16:05.837Z" }, + { url = "https://files.pythonhosted.org/packages/db/44/77b9a86d84a28d52ba3316d77737f6514e17118119ade3f91b639e859029/orjson-3.11.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b", size = 129701, upload-time = "2026-03-31T16:16:07.407Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/eff3d9bfe47e9bc6969c9181c58d9f71237f923f9c86a2d2f490cd898c82/orjson-3.11.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d", size = 141202, upload-time = "2026-03-31T16:16:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/52/c8/90d4b4c60c84d62068d0cf9e4d8f0a4e05e76971d133ac0c60d818d4db20/orjson-3.11.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858", size = 127194, upload-time = "2026-03-31T16:16:11.02Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c7/ea9e08d1f0ba981adffb629811148b44774d935171e7b3d780ae43c4c254/orjson-3.11.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f", size = 133639, upload-time = "2026-03-31T16:16:13.434Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/ddbbfd6ba59453c8fc7fe1d0e5983895864e264c37481b2a791db635f046/orjson-3.11.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d", size = 141914, upload-time = "2026-03-31T16:16:14.955Z" }, + { url = "https://files.pythonhosted.org/packages/4e/31/dbfbefec9df060d34ef4962cd0afcb6fa7a9ec65884cb78f04a7859526c3/orjson-3.11.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc", size = 423800, upload-time = "2026-03-31T16:16:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/87/cf/f74e9ae9803d4ab46b163494adba636c6d7ea955af5cc23b8aaa94cfd528/orjson-3.11.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf", size = 147837, upload-time = "2026-03-31T16:16:18.585Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/9214f017b5db85e84e68602792f742e5dc5249e963503d1b356bee611e01/orjson-3.11.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600", size = 136441, upload-time = "2026-03-31T16:16:20.151Z" }, + { url = "https://files.pythonhosted.org/packages/24/dd/3590348818f58f837a75fb969b04cdf187ae197e14d60b5e5a794a38b79d/orjson-3.11.8-cp314-cp314-win32.whl", hash = "sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade", size = 131983, upload-time = "2026-03-31T16:16:21.823Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/b6cb692116e05d058f31ceee819c70f097fa9167c82f67fabe7516289abc/orjson-3.11.8-cp314-cp314-win_amd64.whl", hash = "sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca", size = 127396, upload-time = "2026-03-31T16:16:23.685Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d1/facb5b5051fabb0ef9d26c6544d87ef19a939a9a001198655d0d891062dd/orjson-3.11.8-cp314-cp314-win_arm64.whl", hash = "sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817", size = 127330, upload-time = "2026-03-31T16:16:25.496Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -878,6 +1000,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + [[package]] name = "plotly" version = "6.7.0" @@ -1401,6 +1532,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "simplejson" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/2a/54837395a3487c725669428d513293612a48d82b95a0642c936932e5d898/simplejson-4.1.1.tar.gz", hash = "sha256:c08eb9f7a90f77ae470e19a07472e9a79ebc0d1c2315d86a72767665bd5ba79f", size = 118860, upload-time = "2026-04-24T19:24:59.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/da/3ba5e87e917094961e7b51b541c88f735f1ca37d580ac78a9302b468f64e/simplejson-4.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7f61eefab86235c800e7f4e37d977080ec424bb2bf0b74e95a2d17ecb48eac0a", size = 111675, upload-time = "2026-04-24T19:22:30.344Z" }, + { url = "https://files.pythonhosted.org/packages/fe/8a/d0c08f4b8934b64469a63d461a68a01d5cc32faf313400dda2bdc1075a29/simplejson-4.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4484960512db9c8124bfa91e0d8a9f9c302338f1c5454e74c21d7d022df10f46", size = 90544, upload-time = "2026-04-24T19:22:32.095Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2d/7832ed91cf4900f86c783d589bfac53358abfccb278f1c8b55eec167b395/simplejson-4.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b75c7ef874dbb350f41827cdf3cee23f5257bdcb0df46d4c01b34badb62dcfe8", size = 90895, upload-time = "2026-04-24T19:22:34.412Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d6/a2a7a482fa43aaeaefc001491d381960f5e685ee4645343e0e037cebb57c/simplejson-4.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c7494c75b95171194f965ea609e97081837a26494d91dcc046ad27dd9c3503e2", size = 168660, upload-time = "2026-04-24T19:22:35.717Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/7a6482f336338dbdb6ca6d3099b2fdc1c74c47eea3c6511975751e9198df/simplejson-4.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1778e09a6e4bb4ef304627915dc4a838569d9e6b737c787925b4e98244bbbc16", size = 167264, upload-time = "2026-04-24T19:22:37.415Z" }, + { url = "https://files.pythonhosted.org/packages/c9/43/039982e956b06c6b019d48bdf9d4ec06f298adf6136552ad1979b94be0fd/simplejson-4.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:67e43e7c0555e10de6d83e1408035652fad28c983516e38c4e3a9a748c9af129", size = 176909, upload-time = "2026-04-24T19:22:38.872Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f5/e3ad592d089922abce2c2ea377548953ac55ffcbe061d600f01b9db2e6b6/simplejson-4.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:93bf6653420258372444de90194dab8de8ff13d74b5d4263a5fefbbe8b8d2060", size = 165930, upload-time = "2026-04-24T19:22:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/f830b648ae04601e6813306535d8e0a4c178d6453cec539b85dafdac80ed/simplejson-4.1.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0662cfe0482c9796bd097213b27f006815bfdc9b671264c3c0b7fc0e72b71d00", size = 174710, upload-time = "2026-04-24T19:22:42.437Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3e/82c8997c4ef2ef6c832fbfc3bb2ed14a212616a284100af03b552ea7e072/simplejson-4.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a9ab55d2459f6d0fdf9984a7a0fb0280dae12979f4fcc3171f5096a4fcf5fafe", size = 167685, upload-time = "2026-04-24T19:22:44.023Z" }, + { url = "https://files.pythonhosted.org/packages/4d/03/80e67a6c63fe812094c681917a5c5d403e34904d200570416863fe2e8328/simplejson-4.1.1-cp310-cp310-win32.whl", hash = "sha256:dfb84ace97acbdf1916c5a675387493fc5a7f67c2e15d4a7687143f8c73024d4", size = 88317, upload-time = "2026-04-24T19:22:45.547Z" }, + { url = "https://files.pythonhosted.org/packages/f4/05/d4fa2c024d566bddff732a2aa437faa4cbee15ee277e2a855faf91a9d906/simplejson-4.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8eb821ef27f688f59ed4a93b17a666a7ebacf8dd65fecaa2b3c531a3aea62eaf", size = 90461, upload-time = "2026-04-24T19:22:47.447Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/39013ffe279d90093ec1c848565b3683c586906c10fa55d9000ec29d046b/simplejson-4.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2867c64d92abd1992c15666fae198203093f593e43d6b81adf176bae530d493a", size = 111538, upload-time = "2026-04-24T19:22:49.051Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ae/2c272971c8a87e2539c54a98eb6ff037bee1e2e93943c3986cf7500a4f3a/simplejson-4.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c47c46e16c8ea9e4850061e6ed5aa2b9cd2074cb2274bfd9c138cba15ce7453", size = 90594, upload-time = "2026-04-24T19:22:50.408Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a2/6eebfb99dedc139f549200f61ade6d1890ac5707c5d427bdfa6fe39c9313/simplejson-4.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e294e33dbf316a9bbdd4030d46503c9b0f19470ae7ad6af5bae6c426bc2e869f", size = 90718, upload-time = "2026-04-24T19:22:51.694Z" }, + { url = "https://files.pythonhosted.org/packages/80/7e/c9e6c0c4ad8415e64dad0c47f619b556b02680a41631b4dbc281d55dc54d/simplejson-4.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7ce252b28fddbdd83db5bd7d93dad2a8a591d7ada098afec9c1b23d6b722a7a4", size = 180901, upload-time = "2026-04-24T19:22:53.025Z" }, + { url = "https://files.pythonhosted.org/packages/34/09/69e331e3994b1ed9be6ce9ace4ade704e7ed503edf869929ca7bb404eda8/simplejson-4.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c44ef6b02a4eb67ed17a72342341792149b3ff46f15426c26e970e49addf327", size = 178133, upload-time = "2026-04-24T19:22:54.574Z" }, + { url = "https://files.pythonhosted.org/packages/5d/40/ed806f24afef295c1032448f5ff6f6f2979392d5645ddb9f4fed7f38194d/simplejson-4.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82bfca2b85a34178c25829c703f0a9e9f113a5af7539285bd3efb583a0bf1ba3", size = 188155, upload-time = "2026-04-24T19:22:56.044Z" }, + { url = "https://files.pythonhosted.org/packages/38/94/8d6f515b827b0f7881a49c8c1ac6920b7ae9428939ef04238c973278b42a/simplejson-4.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0e4b23f71dd781f8830f1663dc01a4944d3dbf87a1f93d78fba1cf64722d0ccf", size = 176225, upload-time = "2026-04-24T19:22:57.981Z" }, + { url = "https://files.pythonhosted.org/packages/c9/fd/6dffb4956563d48bbe46b91ff341adae34920e94008fd6b8d728072abfc7/simplejson-4.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:82fee635d7b73ad801030b05a75fbd34a098da0c2ecf600667a03636d09e1e42", size = 185535, upload-time = "2026-04-24T19:22:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/de/d2/a509ee37763e79aec75d68f8521db1440306edeba3b8b4064ab4ee8bf1d9/simplejson-4.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:68e62eda21192c5ea9bb92d571ca46a4477fef48762f50d433de2b4253051551", size = 179302, upload-time = "2026-04-24T19:23:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/d8/23/5b343bfd2a79d3b6818e4db3586c405a001a090d4c89d336e31273ce7177/simplejson-4.1.1-cp311-cp311-win32.whl", hash = "sha256:ffd3d82294b47f5ec64050021ace95fd62628a0c1cc8bbf4d06d2d1fb697e055", size = 88408, upload-time = "2026-04-24T19:23:02.808Z" }, + { url = "https://files.pythonhosted.org/packages/38/04/df9b37aedbd524dca20840d25ebe01d6ae486b89792aeff5d15b9c4114f7/simplejson-4.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:78a3fe0995be42bed62a26aa78e0e0b4d87c6545785346b9cc898f3389569a35", size = 90526, upload-time = "2026-04-24T19:23:04.408Z" }, + { url = "https://files.pythonhosted.org/packages/60/25/e90998fe8e480eb43b966c09e835379887d427567ebd496563d3b1e16b19/simplejson-4.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:19040a17154dc03d289bab68d73ce0a6a0be01de30c584bbdd93490bead14b22", size = 112414, upload-time = "2026-04-24T19:23:06.084Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a0/abd4785f36c3400f1fbb21f517be39295a750a714f04b7ee175adf6ef580/simplejson-4.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a94ebaecdbaa80d9551a3ec6bf0c9302fc8b53ab6c1b2bfd498a1df4cb28158d", size = 91120, upload-time = "2026-04-24T19:23:07.877Z" }, + { url = "https://files.pythonhosted.org/packages/b8/78/fc060d2e3b13c6ec59288574b8efac64075e316b2afba4396a56b2422f78/simplejson-4.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:67341c95c0a168ab4a6d1e807e50463f1c8da932c3286d81e201266c427061fa", size = 91055, upload-time = "2026-04-24T19:23:09.264Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b6/156a8de1e1b47694f0e7de6675866936608d45dc68388fd017d36f8693be/simplejson-4.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45ec18e337fec538b7e902d489505c450b2454653d1290f3f50385e6fd8aa607", size = 190297, upload-time = "2026-04-24T19:23:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/86/1c/e4d0eab695be3eb21d0f46bce820752031f03e7113f9c80a9b3c73ee7157/simplejson-4.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:820c69a4710400e9b248d5670647d60be58824369282d3925e516b3ff1a7cd82", size = 187002, upload-time = "2026-04-24T19:23:12.982Z" }, + { url = "https://files.pythonhosted.org/packages/76/0e/7f5a59d29426b062d5928fb88b403c3f797129d53be7102f955dbe51aa44/simplejson-4.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e708d373a10e4378ef2d59f8361850c7150fd907ed49efe49bc5492160476d1", size = 195146, upload-time = "2026-04-24T19:23:14.517Z" }, + { url = "https://files.pythonhosted.org/packages/78/18/9943db224dd4d5fa3c090c3e56a94c37b254338c83995ec5680285111c40/simplejson-4.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:980fc33353f81fd12d8c49d44f8c2760d1dc8192285e627c5180d141035b228a", size = 183931, upload-time = "2026-04-24T19:23:16.742Z" }, + { url = "https://files.pythonhosted.org/packages/c2/08/9a690da9a766161c06c627d805362cf159f1abe480969372b2897649b955/simplejson-4.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:de2ed102fff88dacf543699f53ee3a533cc11539a39baa176b7e09dd783069d6", size = 192228, upload-time = "2026-04-24T19:23:18.33Z" }, + { url = "https://files.pythonhosted.org/packages/05/88/bd8aad36b451ffb0e0a3f721d695a88befa6d1ac7d1e02ae788ca7ff4029/simplejson-4.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785ff8edc0e28bf773a32543a6bbed46351453c997b3f6709c744e3c2f7eabb", size = 187808, upload-time = "2026-04-24T19:23:21.165Z" }, + { url = "https://files.pythonhosted.org/packages/04/ee/14f91db0d1f481533b651dafbf8cd0da088d9817f7af30c68f7f19f9c847/simplejson-4.1.1-cp312-cp312-win32.whl", hash = "sha256:2e0d5ead6d14610467ec356ec1f6b5d8a56aa216abaad8d41c8b873b16cf313f", size = 88512, upload-time = "2026-04-24T19:23:22.764Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c4/90de06b2d8737c68c05ff9274113f854dbf6a5f28b7a955212111672cb57/simplejson-4.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:63a5451f557d6be48a231bae932458655c620902b868170b2f1c8afed496f6b4", size = 90748, upload-time = "2026-04-24T19:23:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/37/a9/47b445eeb559c9593453a0648e0fd6d08e8adff64dd5e5ced66726da8a09/simplejson-4.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dff52fc7af272e84fc21cc5a06c927c823ca6ae00af14f3b0d7707b42775ed98", size = 113160, upload-time = "2026-04-24T19:23:26.033Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/cb72db31523c164dea5dc55b02dad065a40c478856bc7534b279d2b51906/simplejson-4.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:971aed0647ad6e840a3943bec812fcda5f2d26a5497a4981d1fb49aa4f9a396c", size = 91521, upload-time = "2026-04-24T19:23:27.572Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e5/54cb7c50ad5fdc1e0a86b7df4b135c2cbd5c4623605aa94466659098e8da/simplejson-4.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:249e2e220aa6d9b9d936bde84eb7bf79d5b6c5a8273c6e411f8b1635a9073f2d", size = 91407, upload-time = "2026-04-24T19:23:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/21a3ede87f0bf82d6c7bcb90480d50a6490eb974c6ab20881188e440957c/simplejson-4.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e5cdd6a5d52299f345c15ab5678cc4249e24f383f361d986afbc3c7072a6b6b", size = 192451, upload-time = "2026-04-24T19:23:30.56Z" }, + { url = "https://files.pythonhosted.org/packages/59/df/9903edd3102bf0b5984edfcb90c88612330996efa3b4fbf8a971d6e17839/simplejson-4.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642cec364e0676e2d5a73fa4d31d0c7c55886997caa2fde24e8292ca44d32728", size = 189015, upload-time = "2026-04-24T19:23:32.647Z" }, + { url = "https://files.pythonhosted.org/packages/98/cd/33230927a780e1398b857e3944abb914556994d252b1d765ae40d112cb25/simplejson-4.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:76fe296ca1df23d290033f10aaacf534fd1b3e3007e7f9ff8aa68b21413aaa78", size = 196658, upload-time = "2026-04-24T19:23:34.563Z" }, + { url = "https://files.pythonhosted.org/packages/cd/84/2c5a7444eb53e9a86d3738299bffddd9f53aeed799ded2f45368221fdb19/simplejson-4.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f0ad25b7dc4e0fb23858355819f2e994f1a5badcdcde8737eac7921c2f1ed2a", size = 185967, upload-time = "2026-04-24T19:23:36.191Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/454378e06d059cd412a7ed5d87fb6d29fd5b60f13a4d89fc1f764ff434df/simplejson-4.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a59ebd0533f03fd06ff0c42ba0f02d93cbcdd7944922bf3b93911327a95b901f", size = 193940, upload-time = "2026-04-24T19:23:38.151Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d5/a15bf915f623a2c5a079d6e3be8256fdb8ef06f110669493a09b9d6933e0/simplejson-4.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bccbf4419676b517939852e5aeff2af6aee4dc046881c67a1581fa6f1cb01abd", size = 189795, upload-time = "2026-04-24T19:23:40.139Z" }, + { url = "https://files.pythonhosted.org/packages/d2/c9/37212ae7dc4b607f0978c408e8633f05c810884e054c33113184c6c2c8a2/simplejson-4.1.1-cp313-cp313-win32.whl", hash = "sha256:6c845363eb5fd166fb7c72243da38f4fcfde666ede7fdf2cc6fd7762894626f7", size = 88773, upload-time = "2026-04-24T19:23:41.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c7a0a47883a9015b54c9d8a4b62f2aba17bd4335b1787b9b8a0fc2fa6d52/simplejson-4.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:104d8324c34f25b4b90800bc5fa363780cbc3d8496aef061cba7ce1af9162270", size = 90888, upload-time = "2026-04-24T19:23:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/4a118a6a92eb33bb08c8e2fe7ec85cb96f0673491bb2b829930831ee4fbe/simplejson-4.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ed7473602b6625de793b6acba49aa949f144a475f538792067e4cf2fda2071f5", size = 110492, upload-time = "2026-04-24T19:23:44.957Z" }, + { url = "https://files.pythonhosted.org/packages/07/f4/84d160e9fa8cada1e0a9381cae4fa81eecd573577a5b34366d8ced59bdf7/simplejson-4.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:225c9caa324c5b554d009fb9cac22aee7711e71bd96f487938c659af467e828e", size = 90152, upload-time = "2026-04-24T19:23:46.355Z" }, + { url = "https://files.pythonhosted.org/packages/68/31/9a5432c433a7671107182cdc9a20ea78a70f99c4e5334aa54b6d4d0d79ed/simplejson-4.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:95407269340c7f22f09776ea7b717a52cf56cfcf119b5e45f66faa4a26445bea", size = 90115, upload-time = "2026-04-24T19:23:47.743Z" }, + { url = "https://files.pythonhosted.org/packages/78/91/3635cdb13318cb0a328abaa69e2b91251caad39d6779aa308098f341f6cb/simplejson-4.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3851658d642c1184d2023f0e6c9ce44a21eb1629e74e7c84ef956b128841fe12", size = 184036, upload-time = "2026-04-24T19:23:49.472Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/149b6ec5393f6849d98c59cadba888b710a8ef4b805ab91e11a566960d40/simplejson-4.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95a3bb0f78e85f4937f99092239f2011ce06f0f2d803df5c299cc05abbeae008", size = 180543, upload-time = "2026-04-24T19:23:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/df/7c/a5d968d0b527a748b667e62bea94309ccbcb1e2b108e8f0cf8547efaa12b/simplejson-4.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbfdaa7c0603f75b7b14b211b7f2be44696d4e26833ad2d91d5c87bf5fb9a920", size = 188725, upload-time = "2026-04-24T19:23:52.995Z" }, + { url = "https://files.pythonhosted.org/packages/db/e3/6a8d11181d587ef00e2db9112357e6832111e56dd56b01b5c11758a1965d/simplejson-4.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e3c584071dced8c21b4689f0254303521daeb9b5bc1f4289755d71fa3cb0d3", size = 177492, upload-time = "2026-04-24T19:23:54.581Z" }, + { url = "https://files.pythonhosted.org/packages/67/e3/8b0eb8b06e8198cfbd1270487da163d0093df05cc4f557350cd65e2f7e79/simplejson-4.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:036a27bd0469b9d79557cbddb392969f876cd7f278cfbd0fba81534927a06575", size = 185281, upload-time = "2026-04-24T19:23:56.13Z" }, + { url = "https://files.pythonhosted.org/packages/dc/5f/64990f07ec9e2cb1a814c674e2e21b5693207f74ac70eb72151b847ea4e6/simplejson-4.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b70bfd2f67f3351baba08aa3ae9233c83f21fd95ae5e6b3d0ecb8c647929112f", size = 181848, upload-time = "2026-04-24T19:23:57.92Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/bbc1bc0447f339f79f99ab8c37f7f037cb2f1f93af75d6a4d553096bb0c3/simplejson-4.1.1-cp314-cp314-win32.whl", hash = "sha256:37233c72ce88d06acb92747347742b3c07871eba6789f060c179c9302dde8efe", size = 88761, upload-time = "2026-04-24T19:23:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/18/72/ec1b5cbdcb140c132e6c7bdf99bd73e4f675439e77126c88f472fcffa09c/simplejson-4.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:cc0442dea71cd9cbf30a0b8b9929ab5aa6c02c0443a3d977351e6ec5bada4388", size = 91018, upload-time = "2026-04-24T19:24:00.85Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/4fa437f68ff72219bac3bf3d050de9c6265691f3a170e16954bd69d7cddd/simplejson-4.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:c996a4d38290c515af347740659ce095b425449c164a5c9fa3977caa6eff5dbe", size = 113919, upload-time = "2026-04-24T19:24:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/c2/83/59de041d09eb4a9577f7015d7263c32095dfb7fde49717dff62145d89809/simplejson-4.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c65c763fb20d7ca113c1c14dce2fc04a0fc3a57aceff533d6fdac707c7bffb40", size = 91904, upload-time = "2026-04-24T19:24:03.812Z" }, + { url = "https://files.pythonhosted.org/packages/03/8e/46bb345d540f6eb31427d984a4e518cdb182d0621814fee4fee045e8815b/simplejson-4.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0da5c9f57206ee7ef280ff7f1d924937b0a64f9a271a5ef371a2ecdbebba7421", size = 91752, upload-time = "2026-04-24T19:24:05.622Z" }, + { url = "https://files.pythonhosted.org/packages/83/e2/1b2ce97f068835eb3d253c116a4df7a3f436b7bf2fb5ff1ba29287e8b0ec/simplejson-4.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ea3426e786425d10e9e82f8a6eda74a7d6eb10d99165ac3d0d3bbcb65c0ea343", size = 214021, upload-time = "2026-04-24T19:24:07.447Z" }, + { url = "https://files.pythonhosted.org/packages/48/70/d93e556df6a0786298644a7c08304fcbeddc248325f23f38acbebeb21165/simplejson-4.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d75cea7a1025edd7e439b2966b3d977c45b5b899e2adaf422811b3ac702ed9fb", size = 213530, upload-time = "2026-04-24T19:24:09.289Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/c93bf305b9f00d7259e09e713d60e75bd0f7f53da970f716ab90491770e7/simplejson-4.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63c2ada8e58f266491f19eed2eeeb7c25c6141e52f8f9e820f6bb94156cf8dbc", size = 218282, upload-time = "2026-04-24T19:24:10.991Z" }, + { url = "https://files.pythonhosted.org/packages/0c/20/a9b5d2e27ec44b069ee251bd55544fc76929a067107b1050001566ba86f3/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d1fffb56305c5b475ee746cf9e04f97423ba5aaacd292dc1255bd75b1d3b124b", size = 209249, upload-time = "2026-04-24T19:24:12.662Z" }, + { url = "https://files.pythonhosted.org/packages/97/e4/e06ee682ed5df67592181f5ecb062e35878967e27f5b6e087237d4548d95/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a6525ec733f43d0541206cffa64fd2aad5a7ae3eb76566aff49cd4db6382209a", size = 213963, upload-time = "2026-04-24T19:24:14.302Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9f/1e160e4cd8cdbf062bf6a454cdf814dc7a48eb47e566fdb8f80ccb202605/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:861e393260508efa64d8805a8e49c416c3484907e3f146ce966c69552b49b9a3", size = 210474, upload-time = "2026-04-24T19:24:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e6/cecd913df322df5bbe7ebb8ba39e0708e505a165553900da8a7761026d6f/simplejson-4.1.1-cp314-cp314t-win32.whl", hash = "sha256:d083b89d30948a751d3d97476c2ed91e4caaa24a1a1459bdbadb8876242c71fe", size = 91134, upload-time = "2026-04-24T19:24:17.635Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/f540dde99cc1d393bd062ab3b5735b777561a5d8f8a5f2e241164444d77a/simplejson-4.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4cbb299d0528ec0447fe366d8c9641860e28f997a62730690fef905f1f41046e", size = 94467, upload-time = "2026-04-24T19:24:19.109Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6a/8b74c52ffd33dbbde00fe7251fee6a0acdc8cea33f7a43805aed258fb79b/simplejson-4.1.1-py3-none-any.whl", hash = "sha256:2ce92b3748f02423e26d2bfb636fb9d7a8f67c8f5854dcae69d350d123b2eee2", size = 69195, upload-time = "2026-04-24T19:24:57.962Z" }, +] + [[package]] name = "smmap" version = "5.0.3" From 23f8d7bbfc101e5e275d026873e767dcee7b00db Mon Sep 17 00:00:00 2001 From: Alex Jones Date: Wed, 13 May 2026 14:13:30 +0100 Subject: [PATCH 11/11] feat: add base instrumentation report utilities & constants Add some utility functions and constant values to assist in instrumentation report visualisation creation. This includes the ability to make (cyclically) repeating colour mappings for datasets with a large number of categories, generic logic to make a hover tooltip that gives information about a job (optionally augmented by the presence of metadata), and a helper for rendering large figures which dynamically switches to rendering a PNG based on whether a threshold number of displayed "entities" (points / bars / etc.) are being rendered. Note that for now we just embed the PNG as a base64 encoded image in the HTML directly. In the future it would probably be a good idea to save these images separately and load them in via HTML src references - but for now this complicates the abstraction interface to be able to handle the edge case of very large graphs, so this is left as a TODO. Also define some useful constants that should remain true across many visualisations: target heights, thresholds & layout/presentation configuration options. Signed-off-by: Alex Jones --- src/dvsim/instrumentation/report/base.py | 169 ++++++++++++++++++++++- 1 file changed, 167 insertions(+), 2 deletions(-) diff --git a/src/dvsim/instrumentation/report/base.py b/src/dvsim/instrumentation/report/base.py index 95e9a545..5d467f1d 100644 --- a/src/dvsim/instrumentation/report/base.py +++ b/src/dvsim/instrumentation/report/base.py @@ -4,25 +4,61 @@ """DVSim scheduler instrumentation reporting & visualizations.""" -from collections.abc import Sequence +import base64 +from collections.abc import Iterable, Mapping, Sequence from enum import Enum from pathlib import Path -from typing import Protocol +from typing import Any, Protocol, TypeVar import plotly.offline +from plotly.graph_objs import Figure from typing_extensions import Self from dvsim.instrumentation import InstrumentationResults +from dvsim.instrumentation.records import JobInstrumentationMetadata from dvsim.logging import log from dvsim.report.artifacts import ReportArtifacts, render_static_content from dvsim.templates.render import render_template __all__ = ( + "DEFAULT_PNG_THRESHOLD", + "DEFAULT_VISUALIZATION_HEIGHT_PX", + "PLOTLY_HTML_FRAGMENT_CONFIG", + "PLOTLY_TIMING_AXIS_CONFIG", "InstrumentationVisualizer", "RenderProfile", + "make_job_metadata_hover", + "make_repeating_color_map", "render_html_report", + "render_large_figure", ) +# The default figure height limit in pixels that visualizations should target, if possible. +DEFAULT_VISUALIZATION_HEIGHT_PX: int = 1000 + +# The default number of jobs / data points above which graphs should be rendered as PNGs, instead +# of dynamic HTML, to improve performance and generated report size. +DEFAULT_PNG_THRESHOLD: int = 1000 + +# The rendering configuration to use when rendering a graph as a PNG +PNG_SCALE_FACTOR: float = 2.0 + +# Standard plotly kwargs for rendering a HTML figure as an instrumentation report fragment. +PLOTLY_HTML_FRAGMENT_CONFIG: dict[str, Any] = { + "full_html": False, + "include_plotlyjs": False, +} + +# Standard plotly timing tick config options +PLOTLY_TIMING_AXIS_CONFIG: dict[str, Any] = { + "title": "Time (s)", + "tickformat": ",", + "ticks": "outside", + "tickwidth": 1, + "ticklen": 4, + "tickcolor": "black", +} + class RenderProfile(Enum): """Levels of visualization rendering detail, which impact report size & responsiveness.""" @@ -130,3 +166,132 @@ def render_html_report( (outdir / plotly_js_path).write_text(artifacts[plotly_js_path]) return artifacts + + +def render_large_figure( + fig: Figure, + *, + num_points: int | None = None, + interactivity_limit: int | None = DEFAULT_PNG_THRESHOLD, + png_width: int | None = None, + png_height: int | None = None, +) -> str: + """Render a potentially large plotly figure depending upon its size compared to some threshold. + + Args: + fig: The figure to render. + num_points: The number of points/bars/entities in the figure. + interactivity_limit: Over this limit, render as a PNG instead of dynamic HTML. + If None, there is no limit and this will always render as dynamic HTML. + png_width: If rendering as a PNG, use this width. If not given, try to use the figure's + width defined on its layout. As a last resort, use `DEFAULT_VISUALIZATION_HEIGHT_PX`. + png_height: If rendering as a PNG, use this height. If not given, try to use the figure's + height defined on its layout. As a last resort, use `DEFAULT_VISUALIZATION_HEIGHT_PX`. + + Returns: + A HTML string fragment comprising either the dynamic graph HTML or the encoded PNG. + + """ + if num_points is None or interactivity_limit is None or num_points <= interactivity_limit: + return fig.to_html(**PLOTLY_HTML_FRAGMENT_CONFIG) + + log.debug( + "Plotly figure with %d points is larger than threshold %d.", num_points, interactivity_limit + ) + + width = fig.layout.width if png_width is None else png_width + width = DEFAULT_VISUALIZATION_HEIGHT_PX if width is None else width + height = fig.layout.height if png_height is None else png_height + height = DEFAULT_VISUALIZATION_HEIGHT_PX if height is None else height + log.debug( + "Rendering the figure as a PNG with dimensions (%dx%d) with scale %g...", + width, + height, + PNG_SCALE_FACTOR, + ) + + png = fig.to_image(format="png", width=width, height=height, scale=PNG_SCALE_FACTOR) + b64 = base64.b64encode(png).decode("ascii") + + # For now, to keep this abstraction simple, encode the image as a base64 png. + # TODO: it might be nice to render this image as a separate report artifact in the future. + return ( + f'' + f'
' + f" Click to open the full-resolution image" + f"
" + ) + + +def make_job_metadata_hover( + title: str, + extra: Iterable[str] | Mapping[str, str] | None, + metadata: JobInstrumentationMetadata | None, + *, + omit_status: bool = False, +) -> str: + """Make a hover tooltip for a graph item corresponding to some DVSim job. + + Args: + title: The tooltip title - normally the ID / full name of the job. + extra: Any extra lines or key/value mapping to put after the title & before the metadata. + metadata: Any optional metadata to include at the end of the tooltip. + omit_status: A flag to disable display of status metadata. Useful for combined job views. + + Returns: + The hover tooltip HTML for the job to use with plotly's `hovertemplate`. + + """ + lines = [f"{title}"] + + if extra is not None: + if isinstance(extra, Mapping): + for key, value in extra.items(): + lines.append(f"{key.capitalize()}: {value}") + else: + lines += list(extra) + + if metadata is not None: + block_info = metadata.block + if metadata.block_variant: + block_info += f" ({metadata.block_variant})" + lines += [ + "------------------", + f"Name: {metadata.name}", + f"Tool: {metadata.tool}", + f"Block: {block_info}", + f"Target: {metadata.target}", + ] + if not omit_status: + lines.append(f"Status: {metadata.status}") + if metadata.backend is not None: + lines.append(f"Backend: {metadata.backend}") + + return "
".join(lines) + + +T = TypeVar("T") + + +def make_repeating_color_map(data: Iterable[str], colors: Iterable[T]) -> dict[str, T]: + """Make a color mapping that repeats for as long as is needed to fit the amount of data. + + Args: + data: The category strings to map to the colors. + colors: The non-empty color items to be mapped. Repeated if needed to match `data`. + + Returns: + The mapping from data items -> color in a cyclically repeating order. + + """ + data = list(data) + colors = list(colors) + if len(colors) == 0: + raise ValueError("Given an empty list of colors to repeat?") + + while len(colors) < len(data): + colors += colors.copy() + return dict(zip(data, colors, strict=False))