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 %} + + {% endfor %} + + + {% for row in rows %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} +
{{ column }}
{{ cell }}
+ +
+ + 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"