diff --git a/intbot/core/analysis/submissions.py b/intbot/core/analysis/submissions.py
new file mode 100644
index 0000000..c8802e1
--- /dev/null
+++ b/intbot/core/analysis/submissions.py
@@ -0,0 +1,126 @@
+"""
+Basic analysis of Pretalx Submissions data
+"""
+
+from datetime import datetime
+from typing import ClassVar, Iterable
+
+import plotly.express as px
+import polars as pl
+from core.models import PretalxData
+from pydantic import BaseModel, model_validator
+
+
+class LocalisedFieldsMixin:
+ # Marking as ClassVar here is important. It doens't work without it :)
+ _localised_fields: ClassVar[Iterable[str]] = ()
+
+ @model_validator(mode="before")
+ @classmethod
+ def extract(cls, values):
+ for field in cls._localised_fields:
+ if isinstance(values[field], dict) and "en" in values[field]:
+ values[field] = values[field]["en"]
+ continue
+
+ return values
+
+
+class Submission(LocalisedFieldsMixin, BaseModel):
+ code: str
+ title: str
+ submission_type: str | dict
+ track: str | None
+ state: str
+ abstract: str
+ duration: int
+ created: datetime
+ level: str = ""
+ outline: str | None = None
+ event: str = "ep2025"
+
+ _localised_fields = ["submission_type", "track"]
+
+ class Questions:
+ level = "Expected audience expertise"
+ outline = "Outline"
+
+ @model_validator(mode="before")
+ def extract_answers(cls, values):
+ # Some things are available as answers to questions and we can extract
+ # them here
+ # Using .get since this should be optional when creating Submission
+ # objects manually
+ for answer in values.get("answers", ""):
+ # Submission in the API will include answers to questions asked on
+ # submission and on the speaker. Let's explicitly filter out only
+ # submission questions.
+ is_submission_question = answer["submission"] is not None
+
+ if is_submission_question and cls.matches_question(answer, cls.Questions.level):
+ values["level"] = answer["answer"]
+
+ if is_submission_question and cls.matches_question(answer, cls.Questions.outline):
+ values["outline"] = answer["answer"]
+
+ return values
+
+ @staticmethod
+ def matches_question(answer: dict, question: str) -> bool:
+ """
+ Returns True if the answer corresponds to the question passed as the second
+ argument.
+
+ Answers come in a nested structure that includes localised question
+ text. This function is a small wrapper to encapsulate that behaviour.
+ """
+ return question == answer.get("question", {}).get("question", {}).get("en")
+
+
+def get_latest_submissions_data() -> PretalxData:
+ return PretalxData.objects.filter(
+ resource=PretalxData.PretalxResources.submissions
+ ).latest("created_at")
+
+
+def parse_latest_submissions_to_objects(pretalx_data: PretalxData) -> list[Submission]:
+ data = pretalx_data.content
+ # NOTE: add event as context here
+ submissions = [Submission.model_validate(entry) for entry in data]
+ return submissions
+
+
+def flat_submissions_data(submissions: list[Submission]) -> pl.DataFrame:
+ """
+ Returns a polars data frame with flat description of Submissions
+
+ This functions mostly exists to make the API consistent between different
+ types of data
+ """
+ return pl.DataFrame(submissions)
+
+
+def latest_flat_submissions_data() -> pl.DataFrame:
+ """
+ Thin wrapper on getting latest information from the database, and
+ converting into a polars data frame
+ """
+ pretalx_data = get_latest_submissions_data()
+ submissions = parse_latest_submissions_to_objects(pretalx_data)
+ return flat_submissions_data(submissions)
+
+
+def group_submissions_by_state(submissions: pl.DataFrame) -> pl.DataFrame:
+ return submissions.group_by("state").len().sort("len", descending=True)
+
+
+def piechart_submissions_by_state(submissions_by_state: pl.DataFrame):
+ fig = px.pie(
+ submissions_by_state,
+ values="len",
+ names="state",
+ title="state of the submission",
+ color_discrete_sequence=px.colors.qualitative.Pastel,
+ )
+
+ return fig
diff --git a/intbot/core/bot/main.py b/intbot/core/bot/main.py
index fb64c08..37df687 100644
--- a/intbot/core/bot/main.py
+++ b/intbot/core/bot/main.py
@@ -1,6 +1,13 @@
+import io
+
import discord
from asgiref.sync import sync_to_async
from core.analysis.products import latest_flat_product_data
+from core.analysis.submissions import (
+ group_submissions_by_state,
+ latest_flat_submissions_data,
+ piechart_submissions_by_state,
+)
from core.models import DiscordMessage, InboxItem
from discord.ext import commands, tasks
from django.conf import settings
@@ -220,6 +227,26 @@ async def products(ctx):
await ctx.send(f"```{str(data)}```")
+@bot.command()
+async def submissions_status(ctx):
+ df = await sync_to_async(latest_flat_submissions_data)()
+ by_state = group_submissions_by_state(df)
+
+ await ctx.send(f"```{str(by_state)}```")
+
+
+@bot.command()
+async def submissions_status_pie_chart(ctx):
+ df = await sync_to_async(latest_flat_submissions_data)()
+ by_state = group_submissions_by_state(df)
+ piechart = piechart_submissions_by_state(by_state)
+
+ png_bytes = piechart.to_image(format="png")
+ file = discord.File(io.BytesIO(png_bytes), filename="submissions_by_state.png")
+
+ await ctx.send(file=file)
+
+
def run_bot():
bot_token = settings.DISCORD_BOT_TOKEN
bot.run(bot_token)
diff --git a/intbot/core/views.py b/intbot/core/views.py
index 9a30308..8408d0c 100644
--- a/intbot/core/views.py
+++ b/intbot/core/views.py
@@ -1,8 +1,14 @@
from core.analysis.products import latest_flat_product_data
+from core.analysis.submissions import (
+ group_submissions_by_state,
+ latest_flat_submissions_data,
+ piechart_submissions_by_state,
+)
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.template.response import TemplateResponse
from django.utils import timezone
+from django.utils.safestring import mark_safe
def days_until(request):
@@ -37,3 +43,27 @@ def products(request):
"rows": rows,
},
)
+
+
+@login_required
+def submissions(request):
+ """
+ Show some basic aggregation of submissions data
+ """
+
+ df = latest_flat_submissions_data()
+ by_state = group_submissions_by_state(df)
+ piechart = piechart_submissions_by_state(by_state)
+
+ return TemplateResponse(
+ request,
+ "submissions.html",
+ {
+ "piechart": mark_safe(
+ piechart.to_html(
+ full_html=False,
+ include_plotlyjs="cdn",
+ )
+ ),
+ },
+ )
diff --git a/intbot/intbot/urls.py b/intbot/intbot/urls.py
index ec71fc9..df7e719 100644
--- a/intbot/intbot/urls.py
+++ b/intbot/intbot/urls.py
@@ -4,7 +4,7 @@
internal_webhook_endpoint,
zammad_webhook_endpoint,
)
-from core.views import days_until, products
+from core.views import days_until, products, submissions
from django.contrib import admin
from django.urls import path
@@ -18,4 +18,5 @@
# Public Pages
path("days-until/", days_until),
path("products/", products),
+ path("submissions/", submissions),
]
diff --git a/intbot/templates/submissions.html b/intbot/templates/submissions.html
new file mode 100644
index 0000000..b04e024
--- /dev/null
+++ b/intbot/templates/submissions.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
Submissions
+
+
+ {{ piechart }}
+
+
This is not particularly useful now, but it's here as a proof of concept :)
+
+
+
+ {% for column in columns %}
+ {{ column }} |
+ {% endfor %}
+
+
+ {% for row in rows %}
+
+ {% for cell in row %}
+ {{ cell }} |
+ {% endfor %}
+
+ {% endfor %}
+
+
+
+
+
diff --git a/intbot/tests/test_analysis/test_submissions.py b/intbot/tests/test_analysis/test_submissions.py
new file mode 100644
index 0000000..ac424b8
--- /dev/null
+++ b/intbot/tests/test_analysis/test_submissions.py
@@ -0,0 +1,179 @@
+from datetime import datetime
+
+import polars as pl
+import pytest
+from core.analysis.submissions import (
+ Submission,
+ group_submissions_by_state,
+ latest_flat_submissions_data,
+ piechart_submissions_by_state,
+)
+from core.models import PretalxData
+from polars.testing import assert_frame_equal
+
+
+def _create_pretalx_data():
+ PretalxData.objects.create(
+ resource=PretalxData.PretalxResources.submissions,
+ content=[
+ {
+ "code": "ABCDEF",
+ "slot": None,
+ "tags": [],
+ "image": None,
+ "notes": "",
+ "state": "submitted",
+ "title": "Title",
+ "track": {"en": "Machine Learning, NLP and CV"},
+ "created": "2025-01-14T01:24:36",
+ "answers": [],
+ "tag_ids": [],
+ "abstract": "Abstract",
+ "duration": 30,
+ "speakers": [],
+ "submission_type": "Talk",
+ },
+ {
+ "code": "FGHIJKL",
+ "slot": None,
+ "tags": [],
+ "image": None,
+ "notes": "",
+ "state": "submitted",
+ "title": "Title",
+ "track": {"en": "Machine Learning, NLP and CV"},
+ "created": "2025-01-14T01:24:36",
+ "answers": [],
+ "tag_ids": [],
+ "abstract": "Abstract",
+ "duration": 30,
+ "speakers": [],
+ "submission_type": "Talk",
+ },
+ {
+ "code": "XYZF12",
+ "slot": None,
+ "tags": [],
+ "image": None,
+ "notes": "Notes",
+ "state": "withdrawn",
+ "title": "Title 2",
+ "track": {"en": "Track 2"},
+ "answers": [],
+ "created": "2025-01-16T11:44:26",
+ "tag_ids": [],
+ "abstract": "Minimal Abstract",
+ "duration": 45,
+ "speakers": [],
+ "submission_type": {"en": "Talk (long session)"},
+ },
+ ],
+ )
+
+
+def test_submission_is_answer_to():
+ answers = [
+ {
+ "id": 123,
+ "answer": "1. Introduction - 5 minutes",
+ "person": None,
+ "review": None,
+ "options": [],
+ "question": {"id": 1111, "question": {"en": "Outline"}},
+ "submission": "ABCDE",
+ "answer_file": None,
+ },
+ ]
+
+ if answers[0]["question"]["question"]["en"] == "Outline":
+ assert Submission.matches_question(answers[0], "Outline")
+
+
+@pytest.mark.django_db
+def test_latest_flat_product_data():
+ """
+ Bigger integrated tests going through everything from getting data from the
+ database to returning a polars dataframe
+ """
+ _create_pretalx_data()
+ expected = pl.DataFrame(
+ [
+ Submission(
+ code="ABCDEF",
+ title="Title",
+ submission_type="Talk",
+ track="Machine Learning, NLP and CV",
+ state="submitted",
+ abstract="Abstract",
+ duration=30,
+ created=datetime(2025, 1, 14, 1, 24, 36),
+ level="",
+ outline=None,
+ event="ep2025",
+ ),
+ Submission(
+ code="FGHIJKL",
+ title="Title",
+ submission_type="Talk",
+ track="Machine Learning, NLP and CV",
+ state="submitted",
+ abstract="Abstract",
+ duration=30,
+ created=datetime(2025, 1, 14, 1, 24, 36),
+ level="",
+ outline=None,
+ event="ep2025",
+ ),
+ Submission(
+ code="XYZF12",
+ title="Title 2",
+ submission_type="Talk (long session)",
+ track="Track 2",
+ state="withdrawn",
+ abstract="Minimal Abstract",
+ duration=45,
+ created=datetime(2025, 1, 16, 11, 44, 26),
+ level="",
+ outline=None,
+ event="ep2025",
+ ),
+ ]
+ )
+
+ df = latest_flat_submissions_data()
+
+ assert_frame_equal(df, expected)
+
+
+@pytest.mark.django_db
+def test_group_submissions_by_state():
+ """
+ Bigger integrated tests going through everything from getting data from the
+ database to returning a polars dataframe
+ """
+ _create_pretalx_data()
+ expected = pl.DataFrame(
+ {
+ "state": ["submitted", "withdrawn"],
+ "len": [2, 1],
+ }
+ )
+ expected = expected.cast({"len": pl.UInt32}) # need cast to make it consistent
+
+ df = group_submissions_by_state(latest_flat_submissions_data())
+
+ assert_frame_equal(df, expected)
+
+
+@pytest.mark.django_db
+def test_piechart_submissions_by_state():
+ """
+ Bigger integrated tests going through everything from getting data from the
+ database to returning a polars dataframe
+ """
+ _create_pretalx_data()
+ df = group_submissions_by_state(latest_flat_submissions_data())
+
+ # There are actually no assertions here, just running this code in case it
+ # fails :D
+ piechart_submissions_by_state(df)
diff --git a/intbot/tests/test_bot/test_main.py b/intbot/tests/test_bot/test_main.py
index 012dc4e..c7f760b 100644
--- a/intbot/tests/test_bot/test_main.py
+++ b/intbot/tests/test_bot/test_main.py
@@ -1,10 +1,22 @@
from unittest.mock import AsyncMock, patch
import discord
+import polars as pl
import pytest
from asgiref.sync import sync_to_async
-from core.bot.main import close, ping, poll_database, qlen, source, until, version, wiki
-from core.models import DiscordMessage
+from core.bot.main import (
+ close,
+ ping,
+ poll_database,
+ qlen,
+ source,
+ submissions_status,
+ submissions_status_pie_chart,
+ until,
+ version,
+ wiki,
+)
+from core.models import DiscordMessage, PretalxData
from django.utils import timezone
from freezegun import freeze_time
@@ -200,3 +212,134 @@ async def test_until():
await until(ctx)
ctx.send.assert_called_once_with("100 days left until the conference")
+
+
+@pytest.mark.asyncio
+@pytest.mark.django_db
+async def test_submissions_status():
+ ctx = AsyncMock()
+ await PretalxData.objects.acreate(
+ resource=PretalxData.PretalxResources.submissions,
+ content=[
+ {
+ "code": "ABCDEF",
+ "slot": None,
+ "tags": [],
+ "image": None,
+ "notes": "",
+ "state": "submitted",
+ "title": "Title",
+ "track": {"en": "Machine Learning, NLP and CV"},
+ "created": "2025-01-14T01:24:36.328974+01:00",
+ "answers": [],
+ "tag_ids": [],
+ "abstract": "Abstract",
+ "duration": 30,
+ "speakers": [],
+ "submission_type": "Talk",
+ },
+ {
+ "code": "DEFGHI",
+ "slot": None,
+ "tags": [],
+ "image": None,
+ "notes": "",
+ "state": "submitted",
+ "title": "Title",
+ "track": {"en": "Machine Learning, NLP and CV"},
+ "created": "2025-01-14T01:24:36.328974+01:00",
+ "answers": [],
+ "tag_ids": [],
+ "abstract": "Abstract",
+ "duration": 30,
+ "speakers": [],
+ "submission_type": "Talk",
+ },
+ {
+ "code": "XYZF12",
+ "slot": None,
+ "tags": [],
+ "image": None,
+ "notes": "Notes",
+ "state": "withdrawn",
+ "title": "Title 2",
+ "track": {"en": "Track 2"},
+ "answers": [],
+ "created": "2025-01-16T11:44:26.328974+01:00",
+ "tag_ids": [],
+ "abstract": "Minimal Abstract",
+ "duration": 45,
+ "speakers": [],
+ "submission_type": {"en": "Talk (long session)"},
+ },
+ ],
+ )
+ # Sorted from bigger to smaller
+ expected = pl.DataFrame({"state": ["submitted", "withdrawn"], "len": [2, 1]})
+ expected = expected.cast({"len": pl.UInt32}) # need cast to make it consistent
+
+ await submissions_status(ctx)
+
+ ctx.send.assert_called_once_with(f"```{str(expected)}```")
+
+
+@pytest.mark.asyncio
+@pytest.mark.django_db
+async def test_submissions_status_pie_chart():
+ ctx = AsyncMock()
+ await PretalxData.objects.acreate(
+ resource=PretalxData.PretalxResources.submissions,
+ content=[
+ {
+ "code": "ABCDEF",
+ "slot": None,
+ "tags": [],
+ "image": None,
+ "notes": "",
+ "state": "submitted",
+ "title": "Title",
+ "track": {"en": "Machine Learning, NLP and CV"},
+ "created": "2025-01-14T01:24:36.328974+01:00",
+ "answers": [],
+ "tag_ids": [],
+ "abstract": "Abstract",
+ "duration": 30,
+ "speakers": [],
+ "submission_type": "Talk",
+ },
+ {
+ "code": "XYZF12",
+ "slot": None,
+ "tags": [],
+ "image": None,
+ "notes": "Notes",
+ "state": "withdrawn",
+ "title": "Title 2",
+ "track": {"en": "Track 2"},
+ "answers": [],
+ "created": "2025-01-16T11:44:26.328974+01:00",
+ "tag_ids": [],
+ "abstract": "Minimal Abstract",
+ "duration": 45,
+ "speakers": [],
+ "submission_type": {"en": "Talk (long session)"},
+ },
+ ],
+ )
+
+ class FakeFig:
+ def to_image(self, *, format):
+ return b"PNG GOES HERE"
+
+ expected = FakeFig()
+
+ with patch("core.bot.main.piechart_submissions_by_state", return_value=expected):
+ await submissions_status_pie_chart(ctx)
+
+ ctx.send.assert_called_once()
+ sent_file = ctx.send.call_args.kwargs["file"]
+
+ assert isinstance(sent_file, discord.File)
+ assert sent_file.filename == "submissions_by_state.png"
+ sent_file.fp.seek(0)
+ assert sent_file.fp.read() == b"PNG GOES HERE"
diff --git a/intbot/tests/test_views.py b/intbot/tests/test_views.py
index e18c577..4df66b3 100644
--- a/intbot/tests/test_views.py
+++ b/intbot/tests/test_views.py
@@ -1,4 +1,5 @@
-from pytest_django.asserts import assertTemplateUsed
+from core.models import PretalxData, PretixData
+from pytest_django.asserts import assertRedirects, assertTemplateUsed
def test_days_until_view(client):
@@ -6,3 +7,84 @@ def test_days_until_view(client):
assert response.status_code == 200
assertTemplateUsed(response, "days_until.html")
+
+
+class TestPorductsView:
+ def test_products_view_requires_login(self, client):
+ response = client.get("/products/")
+
+ assertRedirects(
+ response, "/accounts/login/?next=/products/", target_status_code=404
+ )
+ assert response.status_code == 302
+
+ def test_products_sanity_check(self, admin_client):
+ PretixData.objects.create(
+ resource=PretixData.PretixResources.products, content=[]
+ )
+
+ response = admin_client.get("/products/")
+
+ assert response.status_code == 200
+ assertTemplateUsed(response, "table.html")
+
+
+class TestSubmissionsView:
+ def test_submissions_view_requires_login(self, client):
+ response = client.get("/submissions/")
+
+ # 404 because we don't have a user login view yet - we can use admin
+ # for that
+ assertRedirects(
+ response, "/accounts/login/?next=/submissions/", target_status_code=404
+ )
+
+ def test_submissions_basic_sanity_check(self, admin_client):
+ """
+ This test won't work without data, because it's running group_by and
+ requires non-empty dataframe
+ """
+ PretalxData.objects.create(
+ resource=PretalxData.PretalxResources.submissions,
+ content=[
+ {
+ "code": "ABCDEF",
+ "slot": None,
+ "tags": [],
+ "image": None,
+ "notes": "",
+ "state": "submitted",
+ "title": "Title",
+ "track": {"en": "Machine Learning, NLP and CV"},
+ "created": "2025-01-14T01:24:36.328974+01:00",
+ "answers": [],
+ "tag_ids": [],
+ "abstract": "Abstract",
+ "duration": 30,
+ "speakers": [],
+ "submission_type": "Talk",
+ },
+ {
+ "code": "XYZF12",
+ "slot": None,
+ "tags": [],
+ "image": None,
+ "notes": "Notes",
+ "state": "withdrawn",
+ "title": "Title 2",
+ "track": {"en": "Track 2"},
+ "answers": [],
+ "created": "2025-01-16T11:44:26.328974+01:00",
+ "tag_ids": [],
+ "abstract": "Minimal Abstract",
+ "duration": 45,
+ "speakers": [],
+ "submission_type": {"en": "Talk (long session)"},
+ },
+ ],
+ )
+
+ response = admin_client.get("/submissions/")
+
+ assert response.status_code == 200
+ assertTemplateUsed(response, "submissions.html")
diff --git a/pyproject.toml b/pyproject.toml
index 03b0b74..4d60038 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -27,6 +27,9 @@ dependencies = [
"freezegun>=1.5.1",
"ipython>=9.1.0",
"polars>=1.27.1",
+ "plotly[express]>=6.0.1",
+ "kaleido==0.2.0",
+ "plotly-stubs>=0.0.5",
]
[tool.pytest.ini_options]
diff --git a/uv.lock b/uv.lock
index 4176551..8d6459f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -369,8 +369,11 @@ dependencies = [
{ name = "gunicorn" },
{ name = "httpx" },
{ name = "ipython" },
+ { name = "kaleido" },
{ name = "mypy" },
{ name = "pdbpp" },
+ { name = "plotly", extra = ["express"] },
+ { name = "plotly-stubs" },
{ name = "polars" },
{ name = "psycopg" },
{ name = "pydantic" },
@@ -395,8 +398,11 @@ requires-dist = [
{ name = "gunicorn", specifier = ">=23.0.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "ipython", specifier = ">=9.1.0" },
+ { name = "kaleido", specifier = "==0.2.0" },
{ name = "mypy", specifier = ">=1.14.1" },
{ name = "pdbpp", specifier = ">=0.10.3" },
+ { name = "plotly", extras = ["express"], specifier = ">=6.0.1" },
+ { name = "plotly-stubs", specifier = ">=0.0.5" },
{ name = "polars", specifier = ">=1.27.1" },
{ name = "psycopg", specifier = ">=3.2.3" },
{ name = "pydantic", specifier = ">=2.10.6" },
@@ -455,6 +461,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 },
]
+[[package]]
+name = "kaleido"
+version = "0.2.0"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0a/8e/06188d282d20584509be97fb42e8d162210bb8789e3c12cd76fde65441b7/kaleido-0.2.0-py2.py3-none-macosx_10_11_x86_64.whl", hash = "sha256:73f46490168f9af7aac3b01035ad8851457e5b0088bc61425ae3c56bf0968065", size = 85153678 },
+ { url = "https://files.pythonhosted.org/packages/d9/55/014a1a52866c3d62cdbe59c0fa1de2830ca30d4dc50695e8705d406bf232/kaleido-0.2.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:3af187bf6cc2b3af61f5737bf1b10f4ea75ba53391ae20e6df96784f6ca0a9a1", size = 85808191 },
+ { url = "https://files.pythonhosted.org/packages/a9/69/978291fd5da1075c4e4aca3e4a6909411609a669ef5f94332fc4f9925b0d/kaleido-0.2.0-py2.py3-none-manylinux1_x86_64.whl", hash = "sha256:9db17625f5c6ae4600762b97c1d8296d67be20f34a8854ebe5fb264acb5eed97", size = 79902474 },
+ { url = "https://files.pythonhosted.org/packages/c0/93/a8be41b9c0e0eab39ecc0424ecb5fd6e97767e3933c7e026b82f746eb231/kaleido-0.2.0-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:f264b876c7ea6622425fe25db8731e8dff4308a9e5a8753bdc78cdb4ffa1aadd", size = 83711743 },
+ { url = "https://files.pythonhosted.org/packages/6b/62/d9cc7b2c750114fad8bfaccb6bc3cca7160232edf8ba3f69a18ac330b9f3/kaleido-0.2.0-py2.py3-none-win32.whl", hash = "sha256:54f41f426d18ff3cecf42549ff122fa841f15ab75c8fa8e19b40c3b282d19e8b", size = 62260634 },
+ { url = "https://files.pythonhosted.org/packages/dc/53/173caf3d8e474d1cdcd89859f0acd4b26940ef39c5431d16276c135e7870/kaleido-0.2.0-py2.py3-none-win_amd64.whl", hash = "sha256:e083250f8a7b950fcb3978655960662aed74926629f2a278b4ad83787ce22020", size = 65882326 },
+]
+
[[package]]
name = "matplotlib-inline"
version = "0.1.7"
@@ -519,6 +538,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 },
]
+[[package]]
+name = "narwhals"
+version = "1.36.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/92/503f99e2244a271aacd6c2588e0af1b59232292b217708748cdb30214dc3/narwhals-1.36.0.tar.gz", hash = "sha256:7cd860e7e066609bd8a042bb5b8e4193275532114448210a91cbd5c622b6e5eb", size = 270385 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/42/bf/fbcbd9f8676e06ed43d644a4ddbf31478a44056487578ce67f191da430cb/narwhals-1.36.0-py3-none-any.whl", hash = "sha256:e3c50dd1d769bc145f57ae17c1f0f0da6c3d397d62cdd0bb167e9b618e95c9d6", size = 331018 },
+]
+
+[[package]]
+name = "numpy"
+version = "2.2.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/dc/b2/ce4b867d8cd9c0ee84938ae1e6a6f7926ebf928c9090d036fc3c6a04f946/numpy-2.2.5.tar.gz", hash = "sha256:a9c0d994680cd991b1cb772e8b297340085466a6fe964bc9d4e80f5e2f43c291", size = 20273920 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e2/f7/1fd4ff108cd9d7ef929b8882692e23665dc9c23feecafbb9c6b80f4ec583/numpy-2.2.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ee461a4eaab4f165b68780a6a1af95fb23a29932be7569b9fab666c407969051", size = 20948633 },
+ { url = "https://files.pythonhosted.org/packages/12/03/d443c278348371b20d830af155ff2079acad6a9e60279fac2b41dbbb73d8/numpy-2.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ec31367fd6a255dc8de4772bd1658c3e926d8e860a0b6e922b615e532d320ddc", size = 14176123 },
+ { url = "https://files.pythonhosted.org/packages/2b/0b/5ca264641d0e7b14393313304da48b225d15d471250376f3fbdb1a2be603/numpy-2.2.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:47834cde750d3c9f4e52c6ca28a7361859fcaf52695c7dc3cc1a720b8922683e", size = 5163817 },
+ { url = "https://files.pythonhosted.org/packages/04/b3/d522672b9e3d28e26e1613de7675b441bbd1eaca75db95680635dd158c67/numpy-2.2.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2c1a1c6ccce4022383583a6ded7bbcda22fc635eb4eb1e0a053336425ed36dfa", size = 6698066 },
+ { url = "https://files.pythonhosted.org/packages/a0/93/0f7a75c1ff02d4b76df35079676b3b2719fcdfb39abdf44c8b33f43ef37d/numpy-2.2.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d75f338f5f79ee23548b03d801d28a505198297534f62416391857ea0479571", size = 14087277 },
+ { url = "https://files.pythonhosted.org/packages/b0/d9/7c338b923c53d431bc837b5b787052fef9ae68a56fe91e325aac0d48226e/numpy-2.2.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a801fef99668f309b88640e28d261991bfad9617c27beda4a3aec4f217ea073", size = 16135742 },
+ { url = "https://files.pythonhosted.org/packages/2d/10/4dec9184a5d74ba9867c6f7d1e9f2e0fb5fe96ff2bf50bb6f342d64f2003/numpy-2.2.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:abe38cd8381245a7f49967a6010e77dbf3680bd3627c0fe4362dd693b404c7f8", size = 15581825 },
+ { url = "https://files.pythonhosted.org/packages/80/1f/2b6fcd636e848053f5b57712a7d1880b1565eec35a637fdfd0a30d5e738d/numpy-2.2.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5a0ac90e46fdb5649ab6369d1ab6104bfe5854ab19b645bf5cda0127a13034ae", size = 17899600 },
+ { url = "https://files.pythonhosted.org/packages/ec/87/36801f4dc2623d76a0a3835975524a84bd2b18fe0f8835d45c8eae2f9ff2/numpy-2.2.5-cp312-cp312-win32.whl", hash = "sha256:0cd48122a6b7eab8f06404805b1bd5856200e3ed6f8a1b9a194f9d9054631beb", size = 6312626 },
+ { url = "https://files.pythonhosted.org/packages/8b/09/4ffb4d6cfe7ca6707336187951992bd8a8b9142cf345d87ab858d2d7636a/numpy-2.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:ced69262a8278547e63409b2653b372bf4baff0870c57efa76c5703fd6543282", size = 12645715 },
+]
+
[[package]]
name = "packaging"
version = "24.2"
@@ -563,6 +609,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 },
]
+[[package]]
+name = "plotly"
+version = "6.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "narwhals" },
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c7/cc/e41b5f697ae403f0b50e47b7af2e36642a193085f553bf7cc1169362873a/plotly-6.0.1.tar.gz", hash = "sha256:dd8400229872b6e3c964b099be699f8d00c489a974f2cfccfad5e8240873366b", size = 8094643 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/65/ad2bc85f7377f5cfba5d4466d5474423a3fb7f6a97fd807c06f92dd3e721/plotly-6.0.1-py3-none-any.whl", hash = "sha256:4714db20fea57a435692c548a4eb4fae454f7daddf15f8d8ba7e1045681d7768", size = 14805757 },
+]
+
+[package.optional-dependencies]
+express = [
+ { name = "numpy" },
+]
+
+[[package]]
+name = "plotly-stubs"
+version = "0.0.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/15/c1/3448098574d0bd352f157081137aa75e595466875361b69ad32b3e9f49e2/plotly_stubs-0.0.5.tar.gz", hash = "sha256:1f373acfd137a09b45a6e68adec0406c62347b5114b5f8f5529a27c0d3e418c5", size = 45908 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/31/02/f66f264c7cfc11eacddd5304b89fdf21f298260024f976e9232836bba163/plotly_stubs-0.0.5-py3-none-any.whl", hash = "sha256:2db5c784835e48aa7cd70165eb6ce1880d5fd149a32064182e3afe5d28e3f1f3", size = 92780 },
+]
+
[[package]]
name = "pluggy"
version = "1.5.0"