From ff01b1cd418cd5828900fad2c94fe2a857f78b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Mon, 9 Sep 2024 16:18:39 +0200 Subject: [PATCH 01/24] feat: add Webhooks base feature (#5453) # Description This PR adds the base implementation for Webhooks with the following changes: * Adding a new API to create/update/delete Webhooks. * Add a new endpoint to execute a testing a ping event for a specific Webhook. * No background jobs integration. This is gonna be done in a different PR. **Type of change** - New feature (non-breaking change which adds functionality) **How Has This Been Tested** - [x] Manually tested locally and in HF spaces. **Checklist** - I added relevant documentation - I followed the style guidelines of this project - I did a self-review of my code - I made corresponding changes to the documentation - I confirm My changes generate no new warnings - I have added tests that prove my fix is effective or that my feature works - I have added relevant notes to the CHANGELOG.md file (See https://keepachangelog.com/) --------- Co-authored-by: Paco Aranda --- argilla-server/CHANGELOG.md | 1 + argilla-server/pdm.lock | 111 ++++- argilla-server/pyproject.toml | 4 + .../6ed1b8bf8e08_create_webhooks_table.py | 50 +++ .../api/handlers/v1/webhooks.py | 99 +++++ .../api/policies/v1/__init__.py | 2 + .../api/policies/v1/webhook_policy.py | 38 ++ .../src/argilla_server/api/routes.py | 2 + .../argilla_server/api/schemas/v1/webhooks.py | 70 +++ .../argilla_server/api/webhooks/__init__.py | 14 + .../api/webhooks/v1/__init__.py | 14 + .../argilla_server/api/webhooks/v1/commons.py | 64 +++ .../argilla_server/api/webhooks/v1/enums.py | 20 + .../argilla_server/api/webhooks/v1/ping.py | 34 ++ .../api/webhooks/v1/responses.py | 34 ++ .../src/argilla_server/contexts/datasets.py | 2 +- .../src/argilla_server/contexts/webhooks.py | 41 ++ .../src/argilla_server/models/database.py | 25 ++ .../src/argilla_server/validators/webhooks.py | 38 ++ argilla-server/tests/factories.py | 19 +- .../unit/api/handlers/v1/webhooks/__init__.py | 14 + .../v1/webhooks/test_create_webhook.py | 235 ++++++++++ .../v1/webhooks/test_delete_webhook.py | 93 ++++ .../v1/webhooks/test_list_webhooks.py | 90 ++++ .../handlers/v1/webhooks/test_ping_webhook.py | 98 +++++ .../v1/webhooks/test_update_webhook.py | 414 ++++++++++++++++++ .../tests/unit/api/webhooks/__init__.py | 14 + .../tests/unit/api/webhooks/v1/__init__.py | 14 + .../api/webhooks/v1/test_notify_ping_event.py | 51 +++ .../v1/test_notify_response_created_event.py | 56 +++ argilla-server/tests/unit/models/__init__.py | 14 + .../tests/unit/models/test_webhook.py | 30 ++ 32 files changed, 1799 insertions(+), 6 deletions(-) create mode 100644 argilla-server/src/argilla_server/alembic/versions/6ed1b8bf8e08_create_webhooks_table.py create mode 100644 argilla-server/src/argilla_server/api/handlers/v1/webhooks.py create mode 100644 argilla-server/src/argilla_server/api/policies/v1/webhook_policy.py create mode 100644 argilla-server/src/argilla_server/api/schemas/v1/webhooks.py create mode 100644 argilla-server/src/argilla_server/api/webhooks/__init__.py create mode 100644 argilla-server/src/argilla_server/api/webhooks/v1/__init__.py create mode 100644 argilla-server/src/argilla_server/api/webhooks/v1/commons.py create mode 100644 argilla-server/src/argilla_server/api/webhooks/v1/enums.py create mode 100644 argilla-server/src/argilla_server/api/webhooks/v1/ping.py create mode 100644 argilla-server/src/argilla_server/api/webhooks/v1/responses.py create mode 100644 argilla-server/src/argilla_server/contexts/webhooks.py create mode 100644 argilla-server/src/argilla_server/validators/webhooks.py create mode 100644 argilla-server/tests/unit/api/handlers/v1/webhooks/__init__.py create mode 100644 argilla-server/tests/unit/api/handlers/v1/webhooks/test_create_webhook.py create mode 100644 argilla-server/tests/unit/api/handlers/v1/webhooks/test_delete_webhook.py create mode 100644 argilla-server/tests/unit/api/handlers/v1/webhooks/test_list_webhooks.py create mode 100644 argilla-server/tests/unit/api/handlers/v1/webhooks/test_ping_webhook.py create mode 100644 argilla-server/tests/unit/api/handlers/v1/webhooks/test_update_webhook.py create mode 100644 argilla-server/tests/unit/api/webhooks/__init__.py create mode 100644 argilla-server/tests/unit/api/webhooks/v1/__init__.py create mode 100644 argilla-server/tests/unit/api/webhooks/v1/test_notify_ping_event.py create mode 100644 argilla-server/tests/unit/api/webhooks/v1/test_notify_response_created_event.py create mode 100644 argilla-server/tests/unit/models/__init__.py create mode 100644 argilla-server/tests/unit/models/test_webhook.py diff --git a/argilla-server/CHANGELOG.md b/argilla-server/CHANGELOG.md index bf82d59c68..16b415c530 100644 --- a/argilla-server/CHANGELOG.md +++ b/argilla-server/CHANGELOG.md @@ -20,6 +20,7 @@ These are the section headers that we use: - Added [`rq`](https://python-rq.org) library to process background jobs using [Redis](https://redis.io) as a dependency. ([#5432](https://github.com/argilla-io/argilla/pull/5432)) - Added a new background job to update records status when a dataset distribution strategy is updated. ([#5432](https://github.com/argilla-io/argilla/pull/5432)) +- Added new endpoints to create, update, ping and delete webhooks. ([#5453](https://github.com/argilla-io/argilla/pull/5453)) ## [2.1.0](https://github.com/argilla-io/argilla/compare/v2.0.0...v2.1.0) diff --git a/argilla-server/pdm.lock b/argilla-server/pdm.lock index 6a6e00562c..8f3fe420ec 100644 --- a/argilla-server/pdm.lock +++ b/argilla-server/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "postgresql", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:c333424e19e30dc22ae7475a8f8cec7c965c90d6d551b7efef2a724fd7354245" +content_hash = "sha256:7ee2638ed0e27039b9050033be6d253697b13b988b9b75700260dbdca847d820" [[metadata.targets]] requires_python = ">=3.8,<3.11" @@ -700,6 +700,20 @@ files = [ {file = "defusedxml-0.8.0rc2.tar.gz", hash = "sha256:138c7d540a78775182206c7c97fe65b246a2f40b29471e1a2f1b0da76e7a3942"}, ] +[[package]] +name = "deprecated" +version = "1.2.14" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +summary = "Python @deprecated decorator to deprecate old python classes, functions or methods." +groups = ["default"] +dependencies = [ + "wrapt<2,>=1.10", +] +files = [ + {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, + {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, +] + [[package]] name = "dill" version = "0.3.7" @@ -2012,6 +2026,20 @@ files = [ {file = "requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5"}, ] +[[package]] +name = "respx" +version = "0.21.1" +requires_python = ">=3.7" +summary = "A utility for mocking out the Python HTTPX and HTTP Core libraries." +groups = ["test"] +dependencies = [ + "httpx>=0.21.0", +] +files = [ + {file = "respx-0.21.1-py2.py3-none-any.whl", hash = "sha256:05f45de23f0c785862a2c92a3e173916e8ca88e4caad715dd5f68584d6053c20"}, + {file = "respx-0.21.1.tar.gz", hash = "sha256:0bd7fe21bfaa52106caa1223ce61224cf30786985f17c63c5d71eff0307ee8af"}, +] + [[package]] name = "rich" version = "13.7.0" @@ -2335,6 +2363,24 @@ files = [ {file = "srsly-2.4.8.tar.gz", hash = "sha256:b24d95a65009c2447e0b49cda043ac53fecf4f09e358d87a57446458f91b8a91"}, ] +[[package]] +name = "standardwebhooks" +version = "1.0.0" +requires_python = ">=3.6" +summary = "Standard Webhooks" +groups = ["default"] +dependencies = [ + "Deprecated", + "attrs>=21.3.0", + "httpx>=0.23.0", + "python-dateutil", + "types-Deprecated", + "types-python-dateutil", +] +files = [ + {file = "standardwebhooks-1.0.0.tar.gz", hash = "sha256:d94b99c0dcea84156e03adad94f8dba32d5454cc68e12ec2c824051b55bb67ff"}, +] + [[package]] name = "starlette" version = "0.35.1" @@ -2445,6 +2491,28 @@ files = [ {file = "typer-0.9.0.tar.gz", hash = "sha256:50922fd79aea2f4751a8e0408ff10d2662bd0c8bbfa84755a699f3bada2978b2"}, ] +[[package]] +name = "types-deprecated" +version = "1.2.9.20240311" +requires_python = ">=3.8" +summary = "Typing stubs for Deprecated" +groups = ["default"] +files = [ + {file = "types-Deprecated-1.2.9.20240311.tar.gz", hash = "sha256:0680e89989a8142707de8103f15d182445a533c1047fd9b7e8c5459101e9b90a"}, + {file = "types_Deprecated-1.2.9.20240311-py3-none-any.whl", hash = "sha256:d7793aaf32ff8f7e49a8ac781de4872248e0694c4b75a7a8a186c51167463f9d"}, +] + +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20240821" +requires_python = ">=3.8" +summary = "Typing stubs for python-dateutil" +groups = ["default"] +files = [ + {file = "types-python-dateutil-2.9.0.20240821.tar.gz", hash = "sha256:9649d1dcb6fef1046fb18bebe9ea2aa0028b160918518c34589a46045f6ebd98"}, + {file = "types_python_dateutil-2.9.0.20240821-py3-none-any.whl", hash = "sha256:f5889fcb4e63ed4aaa379b44f93c32593d50b9a94c9a60a0c854d8cc3511cd57"}, +] + [[package]] name = "typing-extensions" version = "4.9.0" @@ -2680,6 +2748,47 @@ files = [ {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, ] +[[package]] +name = "wrapt" +version = "1.16.0" +requires_python = ">=3.6" +summary = "Module for decorators, wrappers and monkey patching." +groups = ["default"] +files = [ + {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, + {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, + {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, + {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, + {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, + {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, + {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, + {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, + {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, + {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, +] + [[package]] name = "xxhash" version = "3.4.1" diff --git a/argilla-server/pyproject.toml b/argilla-server/pyproject.toml index 2df84995bd..3060772b0d 100644 --- a/argilla-server/pyproject.toml +++ b/argilla-server/pyproject.toml @@ -59,6 +59,8 @@ dependencies = [ "typer >= 0.6.0, < 0.10.0", # spaCy only supports typer<0.10.0 "packaging>=23.2", "psycopg2-binary>=2.9.9", + # For Webhooks + "standardwebhooks>=1.0.0", # For Telemetry "huggingface_hub>=0.13,<1", ] @@ -103,6 +105,8 @@ test = [ "datasets > 1.17.0,!= 2.3.2", "spacy>=3.5.0,<3.7.0", "pytest-randomly>=3.15.0", + # For mocking httpx requests and responses + "respx>=0.21.1", ] [tool.pytest.ini_options] diff --git a/argilla-server/src/argilla_server/alembic/versions/6ed1b8bf8e08_create_webhooks_table.py b/argilla-server/src/argilla_server/alembic/versions/6ed1b8bf8e08_create_webhooks_table.py new file mode 100644 index 0000000000..1f88c37233 --- /dev/null +++ b/argilla-server/src/argilla_server/alembic/versions/6ed1b8bf8e08_create_webhooks_table.py @@ -0,0 +1,50 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""create webhooks table + +Revision ID: 6ed1b8bf8e08 +Revises: 237f7c674d74 +Create Date: 2024-09-02 11:41:57.561655 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "6ed1b8bf8e08" +down_revision = "237f7c674d74" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "webhooks", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("url", sa.Text(), nullable=False), + sa.Column("secret", sa.Text(), nullable=False), + sa.Column("events", sa.JSON(), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("inserted_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("webhooks") diff --git a/argilla-server/src/argilla_server/api/handlers/v1/webhooks.py b/argilla-server/src/argilla_server/api/handlers/v1/webhooks.py new file mode 100644 index 0000000000..49c99d1963 --- /dev/null +++ b/argilla-server/src/argilla_server/api/handlers/v1/webhooks.py @@ -0,0 +1,99 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from uuid import UUID +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import APIRouter, Depends, Security, status + +from argilla_server.database import get_async_db +from argilla_server.api.policies.v1 import WebhookPolicy, authorize +from argilla_server.api.webhooks.v1.ping import notify_ping_event +from argilla_server.security import auth +from argilla_server.models import User +from argilla_server.api.schemas.v1.webhooks import ( + WebhookUpdate as WebhookUpdateSchema, + WebhookCreate as WebhookCreateSchema, + Webhooks as WebhooksSchema, + Webhook as WebhookSchema, +) +from argilla_server.contexts import webhooks +from argilla_server.models import Webhook + +router = APIRouter(tags=["webhooks"]) + + +@router.get("/webhooks", response_model=WebhooksSchema) +async def list_webhooks( + *, + db: AsyncSession = Depends(get_async_db), + current_user: User = Security(auth.get_current_user), +): + await authorize(current_user, WebhookPolicy.list) + + return WebhooksSchema(items=await webhooks.list_webhooks(db)) + + +@router.post("/webhooks", status_code=status.HTTP_201_CREATED, response_model=WebhookSchema) +async def create_webhook( + *, + db: AsyncSession = Depends(get_async_db), + current_user: User = Security(auth.get_current_user), + webhook_create: WebhookCreateSchema, +): + await authorize(current_user, WebhookPolicy.create) + + return await webhooks.create_webhook(db, webhook_create.dict()) + + +@router.patch("/webhooks/{webhook_id}", response_model=WebhookSchema) +async def update_webhook( + *, + db: AsyncSession = Depends(get_async_db), + current_user: User = Security(auth.get_current_user), + webhook_id: UUID, + webhook_update: WebhookUpdateSchema, +): + webhook = await Webhook.get_or_raise(db, webhook_id) + + await authorize(current_user, WebhookPolicy.update) + + return await webhooks.update_webhook(db, webhook, webhook_update.dict(exclude_unset=True)) + + +@router.delete("/webhooks/{webhook_id}", response_model=WebhookSchema) +async def delete_webhook( + *, + db: AsyncSession = Depends(get_async_db), + current_user: User = Security(auth.get_current_user), + webhook_id: UUID, +): + webhook = await Webhook.get_or_raise(db, webhook_id) + + await authorize(current_user, WebhookPolicy.delete) + + return await webhooks.delete_webhook(db, webhook) + + +@router.post("/webhooks/{webhook_id}/ping", status_code=status.HTTP_204_NO_CONTENT) +async def ping_webhook( + *, + db: AsyncSession = Depends(get_async_db), + current_user: User = Security(auth.get_current_user), + webhook_id: UUID, +): + webhook = await Webhook.get_or_raise(db, webhook_id) + + await authorize(current_user, WebhookPolicy.ping) + + notify_ping_event(webhook) diff --git a/argilla-server/src/argilla_server/api/policies/v1/__init__.py b/argilla-server/src/argilla_server/api/policies/v1/__init__.py index 706b196d28..ae2e6ecc3f 100644 --- a/argilla-server/src/argilla_server/api/policies/v1/__init__.py +++ b/argilla-server/src/argilla_server/api/policies/v1/__init__.py @@ -24,6 +24,7 @@ from argilla_server.api.policies.v1.vector_settings_policy import VectorSettingsPolicy from argilla_server.api.policies.v1.workspace_policy import WorkspacePolicy from argilla_server.api.policies.v1.workspace_user_policy import WorkspaceUserPolicy +from argilla_server.api.policies.v1.webhook_policy import WebhookPolicy __all__ = [ "DatasetPolicy", @@ -37,6 +38,7 @@ "VectorSettingsPolicy", "WorkspacePolicy", "WorkspaceUserPolicy", + "WebhookPolicy", "authorize", "is_authorized", ] diff --git a/argilla-server/src/argilla_server/api/policies/v1/webhook_policy.py b/argilla-server/src/argilla_server/api/policies/v1/webhook_policy.py new file mode 100644 index 0000000000..ed7587a3e3 --- /dev/null +++ b/argilla-server/src/argilla_server/api/policies/v1/webhook_policy.py @@ -0,0 +1,38 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from argilla_server.api.policies.v1.commons import PolicyAction +from argilla_server.models import User + + +class WebhookPolicy: + @classmethod + async def list(cls, actor: User) -> bool: + return actor.is_owner + + @classmethod + async def create(cls, actor: User) -> bool: + return actor.is_owner + + @classmethod + async def update(cls, actor: User) -> bool: + return actor.is_owner + + @classmethod + async def delete(cls, actor: User) -> bool: + return actor.is_owner + + @classmethod + async def ping(cls, actor: User) -> bool: + return actor.is_owner diff --git a/argilla-server/src/argilla_server/api/routes.py b/argilla-server/src/argilla_server/api/routes.py index 456b918c3f..de12565be0 100644 --- a/argilla-server/src/argilla_server/api/routes.py +++ b/argilla-server/src/argilla_server/api/routes.py @@ -62,6 +62,7 @@ from argilla_server.api.handlers.v1 import ( workspaces as workspaces_v1, ) +from argilla_server.api.handlers.v1 import webhooks as webhooks_v1 from argilla_server.errors.base_errors import __ALL__ from argilla_server.errors.error_handler import APIErrorHandler @@ -92,6 +93,7 @@ def create_api_v1(): users_v1.router, vectors_settings_v1.router, workspaces_v1.router, + webhooks_v1.router, oauth2_v1.router, settings_v1.router, ]: diff --git a/argilla-server/src/argilla_server/api/schemas/v1/webhooks.py b/argilla-server/src/argilla_server/api/schemas/v1/webhooks.py new file mode 100644 index 0000000000..ac75525b22 --- /dev/null +++ b/argilla-server/src/argilla_server/api/schemas/v1/webhooks.py @@ -0,0 +1,70 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime +from typing import List, Optional +from uuid import UUID + +from argilla_server.api.webhooks.v1.enums import WebhookEvent +from argilla_server.api.schemas.v1.commons import UpdateSchema +from argilla_server.pydantic_v1 import BaseModel, Field, HttpUrl + +WEBHOOK_EVENTS_MIN_ITEMS = 1 +WEBHOOK_DESCRIPTION_MIN_LENGTH = 1 +WEBHOOK_DESCRIPTION_MAX_LENGTH = 1000 + + +class Webhook(BaseModel): + id: UUID + url: str + secret: str + events: List[WebhookEvent] + enabled: bool + description: Optional[str] + inserted_at: datetime + updated_at: datetime + + class Config: + orm_mode = True + + +class Webhooks(BaseModel): + items: List[Webhook] + + +class WebhookCreate(BaseModel): + url: HttpUrl + events: List[WebhookEvent] = Field( + min_items=WEBHOOK_EVENTS_MIN_ITEMS, + unique_items=True, + ) + description: Optional[str] = Field( + min_length=WEBHOOK_DESCRIPTION_MIN_LENGTH, + max_length=WEBHOOK_DESCRIPTION_MAX_LENGTH, + ) + + +class WebhookUpdate(UpdateSchema): + url: Optional[HttpUrl] + events: Optional[List[WebhookEvent]] = Field( + min_items=WEBHOOK_EVENTS_MIN_ITEMS, + unique_items=True, + ) + enabled: Optional[bool] + description: Optional[str] = Field( + min_length=WEBHOOK_DESCRIPTION_MIN_LENGTH, + max_length=WEBHOOK_DESCRIPTION_MAX_LENGTH, + ) + + __non_nullable_fields__ = {"url", "events", "enabled"} diff --git a/argilla-server/src/argilla_server/api/webhooks/__init__.py b/argilla-server/src/argilla_server/api/webhooks/__init__.py new file mode 100644 index 0000000000..4b6cecae7f --- /dev/null +++ b/argilla-server/src/argilla_server/api/webhooks/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/__init__.py b/argilla-server/src/argilla_server/api/webhooks/v1/__init__.py new file mode 100644 index 0000000000..4b6cecae7f --- /dev/null +++ b/argilla-server/src/argilla_server/api/webhooks/v1/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/commons.py b/argilla-server/src/argilla_server/api/webhooks/v1/commons.py new file mode 100644 index 0000000000..eecbc598a1 --- /dev/null +++ b/argilla-server/src/argilla_server/api/webhooks/v1/commons.py @@ -0,0 +1,64 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import secrets +import httpx + +from math import floor +from typing_extensions import Dict +from datetime import datetime, timezone +from standardwebhooks.webhooks import Webhook + +from argilla_server.models import Webhook as WebhookModel + +MSG_ID_BYTES_LENGTH = 16 + +NOTIFY_EVENT_DEFAULT_TIMEOUT = httpx.Timeout(timeout=5.0) + + +# NOTE: We are using standard webhooks implementation. +# For more information take a look to https://www.standardwebhooks.com +def notify_event(webhook: WebhookModel, type: str, timestamp: datetime, data: Dict) -> httpx.Response: + msg_id = _generate_msg_id() + payload = json.dumps(_build_payload(type, timestamp, data)) + signature = Webhook(webhook.secret).sign(msg_id, timestamp, payload) + + return httpx.post( + webhook.url, + headers=_build_headers(msg_id, timestamp, signature), + content=payload, + timeout=NOTIFY_EVENT_DEFAULT_TIMEOUT, + ) + + +def _generate_msg_id() -> str: + return f"msg_{secrets.token_urlsafe(MSG_ID_BYTES_LENGTH)}" + + +def _build_headers(msg_id: str, timestamp: datetime, signature: str) -> Dict: + return { + "webhook-id": msg_id, + "webhook-timestamp": str(floor(timestamp.replace(tzinfo=timezone.utc).timestamp())), + "webhook-signature": signature, + "content-type": "application/json", + } + + +def _build_payload(type: str, timestamp: datetime, data: Dict) -> Dict: + return { + "type": type, + "timestamp": timestamp.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "data": data, + } diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/enums.py b/argilla-server/src/argilla_server/api/webhooks/v1/enums.py new file mode 100644 index 0000000000..cf3e8541f6 --- /dev/null +++ b/argilla-server/src/argilla_server/api/webhooks/v1/enums.py @@ -0,0 +1,20 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from enum import Enum + + +class WebhookEvent(str, Enum): + response_created = "response.created" + ping = "ping" diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/ping.py b/argilla-server/src/argilla_server/api/webhooks/v1/ping.py new file mode 100644 index 0000000000..48a54d0050 --- /dev/null +++ b/argilla-server/src/argilla_server/api/webhooks/v1/ping.py @@ -0,0 +1,34 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import httpx + +from datetime import datetime + +from argilla_server.models import Webhook +from argilla_server.contexts import info +from argilla_server.api.webhooks.v1.commons import notify_event +from argilla_server.api.webhooks.v1.enums import WebhookEvent + + +def notify_ping_event(webhook: Webhook) -> httpx.Response: + return notify_event( + webhook=webhook, + type=WebhookEvent.ping, + timestamp=datetime.utcnow(), + data={ + "agent": "argilla-server", + "version": info.argilla_version(), + }, + ) diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/responses.py b/argilla-server/src/argilla_server/api/webhooks/v1/responses.py new file mode 100644 index 0000000000..aa026fb010 --- /dev/null +++ b/argilla-server/src/argilla_server/api/webhooks/v1/responses.py @@ -0,0 +1,34 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import httpx + +from datetime import datetime + +from fastapi.encoders import jsonable_encoder + +from argilla_server.models import Response, Webhook +from argilla_server.api.schemas.v1.responses import Response as ResponseSchema +from argilla_server.api.webhooks.v1.commons import notify_event +from argilla_server.api.webhooks.v1.enums import WebhookEvent + + +def notify_response_created_event(webhook: Webhook, response: Response) -> httpx.Response: + return notify_event( + webhook=webhook, + type=WebhookEvent.response_created, + timestamp=datetime.utcnow(), + data=jsonable_encoder(ResponseSchema.from_orm(response)), + ) diff --git a/argilla-server/src/argilla_server/contexts/datasets.py b/argilla-server/src/argilla_server/contexts/datasets.py index 06668930d4..330a457ef8 100644 --- a/argilla-server/src/argilla_server/contexts/datasets.py +++ b/argilla-server/src/argilla_server/contexts/datasets.py @@ -118,7 +118,7 @@ async def list_datasets_by_workspace_id(db: AsyncSession, workspace_id: UUID) -> return result.scalars().all() -async def create_dataset(db: AsyncSession, dataset_attrs: dict): +async def create_dataset(db: AsyncSession, dataset_attrs: dict) -> Dataset: dataset = Dataset( name=dataset_attrs["name"], guidelines=dataset_attrs["guidelines"], diff --git a/argilla-server/src/argilla_server/contexts/webhooks.py b/argilla-server/src/argilla_server/contexts/webhooks.py new file mode 100644 index 0000000000..5baade7ea7 --- /dev/null +++ b/argilla-server/src/argilla_server/contexts/webhooks.py @@ -0,0 +1,41 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from argilla_server.models import Webhook +from argilla_server.validators.webhooks import WebhookCreateValidator + + +async def list_webhooks(db: AsyncSession) -> Sequence[Webhook]: + return (await db.execute(select(Webhook).order_by(Webhook.inserted_at.asc()))).scalars().all() + + +async def create_webhook(db: AsyncSession, webhook_attrs: dict) -> Webhook: + webhook = Webhook(**webhook_attrs) + + await WebhookCreateValidator.validate(db, webhook) + + return await webhook.save(db) + + +async def update_webhook(db: AsyncSession, webhook: Webhook, webhook_attrs: dict) -> Webhook: + return await webhook.update(db, **webhook_attrs) + + +async def delete_webhook(db: AsyncSession, webhook: Webhook) -> Webhook: + return await webhook.delete(db) diff --git a/argilla-server/src/argilla_server/models/database.py b/argilla-server/src/argilla_server/models/database.py index dfd00b02f5..11e2a17cc6 100644 --- a/argilla-server/src/argilla_server/models/database.py +++ b/argilla-server/src/argilla_server/models/database.py @@ -13,6 +13,7 @@ # limitations under the License. import secrets +import base64 from datetime import datetime from typing import Any, List, Optional, Union from uuid import UUID @@ -56,9 +57,11 @@ "MetadataProperty", "Vector", "VectorSettings", + "Webhook", ] _USER_API_KEY_BYTES_LENGTH = 80 +_WEBHOOK_SECRET_BYTES_LENGTH = 64 class Field(DatabaseModel): @@ -509,3 +512,25 @@ def __repr__(self): f"username={self.username!r}, role={self.role.value!r}, " f"inserted_at={str(self.inserted_at)!r}, updated_at={str(self.updated_at)!r})" ) + + +def generate_webhook_secret() -> str: + # NOTE: https://www.standardwebhooks.com implementation requires a base64 encoded secret + return base64.b64encode(secrets.token_bytes(_WEBHOOK_SECRET_BYTES_LENGTH)).decode("utf-8") + + +class Webhook(DatabaseModel): + __tablename__ = "webhooks" + + url: Mapped[str] = mapped_column(Text) + secret: Mapped[str] = mapped_column(Text, default=generate_webhook_secret) + events: Mapped[List[str]] = mapped_column(JSON) + enabled: Mapped[bool] = mapped_column(default=True, server_default=sql.true()) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + def __repr__(self): + return ( + f"Webhook(id={str(self.id)!r}, url={self.url!r}, events={self.events!r}, " + f"enabled={self.enabled!r}, description={self.description!r}, " + f"inserted_at={str(self.inserted_at)!r}, updated_at={str(self.updated_at)!r})" + ) diff --git a/argilla-server/src/argilla_server/validators/webhooks.py b/argilla-server/src/argilla_server/validators/webhooks.py new file mode 100644 index 0000000000..064427fbb4 --- /dev/null +++ b/argilla-server/src/argilla_server/validators/webhooks.py @@ -0,0 +1,38 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from argilla_server.models import Webhook +from argilla_server.errors.future import UnprocessableEntityError + +MAXIMUM_NUMBER_OF_WEBHOOKS = 10 + + +class WebhookCreateValidator: + @classmethod + async def validate(cls, db: AsyncSession, webhook: Webhook) -> None: + await cls._validate_maximum_number_of_webhooks(db) + + @classmethod + async def _validate_maximum_number_of_webhooks(cls, db: AsyncSession) -> None: + if await cls._count_webhooks(db) >= MAXIMUM_NUMBER_OF_WEBHOOKS: + raise UnprocessableEntityError( + f"You can't create more than {MAXIMUM_NUMBER_OF_WEBHOOKS} webhooks. Please delete some of them first" + ) + + @classmethod + async def _count_webhooks(cls, db: AsyncSession) -> int: + return (await db.execute(select(func.count(Webhook.id)))).scalar_one() diff --git a/argilla-server/tests/factories.py b/argilla-server/tests/factories.py index 781a8eaf54..1d189d1ce4 100644 --- a/argilla-server/tests/factories.py +++ b/argilla-server/tests/factories.py @@ -14,9 +14,14 @@ import inspect import random - import factory + +from factory.alchemy import SESSION_PERSISTENCE_COMMIT, SESSION_PERSISTENCE_FLUSH +from factory.builder import BuildStep, StepBuilder, parse_declarations +from sqlalchemy.ext.asyncio import async_object_session + from argilla_server.enums import DatasetDistributionStrategy, FieldType, MetadataPropertyType, OptionsOrder +from argilla_server.api.webhooks.v1.enums import WebhookEvent from argilla_server.models import ( Dataset, Field, @@ -32,11 +37,9 @@ VectorSettings, Workspace, WorkspaceUser, + Webhook, ) from argilla_server.models.base import DatabaseModel -from factory.alchemy import SESSION_PERSISTENCE_COMMIT, SESSION_PERSISTENCE_FLUSH -from factory.builder import BuildStep, StepBuilder, parse_declarations -from sqlalchemy.ext.asyncio import async_object_session from tests.database import SyncTestSession, TestSession @@ -401,3 +404,11 @@ class Meta: record = factory.SubFactory(RecordFactory) question = factory.SubFactory(QuestionFactory) value = "negative" + + +class WebhookFactory(BaseFactory): + class Meta: + model = Webhook + + url = factory.Sequence(lambda n: f"https://example-{n}.com") + events = [WebhookEvent.response_created] diff --git a/argilla-server/tests/unit/api/handlers/v1/webhooks/__init__.py b/argilla-server/tests/unit/api/handlers/v1/webhooks/__init__.py new file mode 100644 index 0000000000..4b6cecae7f --- /dev/null +++ b/argilla-server/tests/unit/api/handlers/v1/webhooks/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_create_webhook.py b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_create_webhook.py new file mode 100644 index 0000000000..9125c0df66 --- /dev/null +++ b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_create_webhook.py @@ -0,0 +1,235 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from typing import Any +from httpx import AsyncClient +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from argilla_server.api.webhooks.v1.enums import WebhookEvent +from argilla_server.models import Webhook +from argilla_server.constants import API_KEY_HEADER_NAME + +from tests.factories import AdminFactory, AnnotatorFactory, WebhookFactory + + +@pytest.mark.asyncio +class TestCreateWebhook: + def url(self) -> str: + return "/api/v1/webhooks" + + async def test_create_webhook(self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "url": "https://example.com/webhook", + "events": [WebhookEvent.response_created], + "description": "Test webhook", + }, + ) + + assert response.status_code == 201 + + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 1 + webhook = (await db.execute(select(Webhook))).scalar_one() + + assert response.json() == { + "id": str(webhook.id), + "url": "https://example.com/webhook", + "secret": webhook.secret, + "events": [WebhookEvent.response_created], + "enabled": True, + "description": "Test webhook", + "inserted_at": webhook.inserted_at.isoformat(), + "updated_at": webhook.updated_at.isoformat(), + } + + async def test_create_webhook_as_admin(self, db: AsyncSession, async_client: AsyncClient): + admin = await AdminFactory.create() + + response = await async_client.post( + self.url(), + headers={API_KEY_HEADER_NAME: admin.api_key}, + json={ + "url": "https://example.com/webhook", + "events": [WebhookEvent.response_created], + }, + ) + + assert response.status_code == 403 + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 0 + + async def test_create_webhook_as_annotator(self, db: AsyncSession, async_client: AsyncClient): + annotator = await AnnotatorFactory.create() + + response = await async_client.post( + self.url(), + headers={API_KEY_HEADER_NAME: annotator.api_key}, + json={ + "url": "https://example.com/webhook", + "events": [WebhookEvent.response_created], + }, + ) + + assert response.status_code == 403 + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 0 + + async def test_create_webhook_without_authentication(self, db: AsyncSession, async_client: AsyncClient): + response = await async_client.post( + self.url(), + json={ + "url": "https://example.com/webhook", + "events": [WebhookEvent.response_created], + }, + ) + + assert response.status_code == 401 + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 0 + + @pytest.mark.parametrize("invalid_url", ["", "example.com", "http:example.com", "https:example.com"]) + async def test_create_webhook_with_invalid_url( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict, invalid_url: str + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "url": invalid_url, + "events": [WebhookEvent.response_created], + }, + ) + + assert response.status_code == 422 + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 0 + + @pytest.mark.parametrize( + "invalid_events", [[], ["invalid-event"], [WebhookEvent.response_created, "invalid-event"]] + ) + async def test_create_webhook_with_invalid_events( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict, invalid_events: list + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "url": "https://example.com/webhook", + "events": invalid_events, + }, + ) + + assert response.status_code == 422 + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 0 + + async def test_create_webhook_with_duplicated_events( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "url": "https://example.com/webhook", + "events": [WebhookEvent.response_created, WebhookEvent.response_created], + }, + ) + + assert response.status_code == 422 + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 0 + + @pytest.mark.parametrize("invalid_description", ["", "d" * 1001]) + async def test_create_webhook_with_invalid_description( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict, invalid_description: str + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "url": "https://example.com/webhook", + "events": [WebhookEvent.response_created], + "description": invalid_description, + }, + ) + + assert response.status_code == 422 + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 0 + + async def test_create_webhook_with_description_as_none( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "url": "https://example.com/webhook", + "events": [WebhookEvent.response_created], + "description": None, + }, + ) + + assert response.status_code == 201 + assert response.json()["description"] == None + + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 1 + webhook = (await db.execute(select(Webhook))).scalar_one() + assert webhook.description == None + + async def test_create_webhook_without_url( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "events": [WebhookEvent.response_created], + }, + ) + + assert response.status_code == 422 + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 0 + + async def test_create_webhook_without_events( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "url": "https://example.com/webhook", + }, + ) + + assert response.status_code == 422 + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 0 + + async def test_create_webhook_reaching_maximum_number_of_webhooks( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + await WebhookFactory.create_batch(10) + + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "url": "https://example.com/webhook", + "events": [WebhookEvent.response_created], + "description": "Test webhook", + }, + ) + + assert response.status_code == 422 + assert response.json() == {"detail": "You can't create more than 10 webhooks. Please delete some of them first"} + + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 10 diff --git a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_delete_webhook.py b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_delete_webhook.py new file mode 100644 index 0000000000..995583c155 --- /dev/null +++ b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_delete_webhook.py @@ -0,0 +1,93 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from httpx import AsyncClient +from uuid import UUID, uuid4 +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from argilla_server.api.webhooks.v1.enums import WebhookEvent +from argilla_server.models import Webhook +from argilla_server.constants import API_KEY_HEADER_NAME + +from tests.factories import AdminFactory, AnnotatorFactory, WebhookFactory + + +@pytest.mark.asyncio +class TestDeleteWebhook: + def url(self, webhook_id: UUID) -> str: + return f"/api/v1/webhooks/{webhook_id}" + + async def test_delete_webhook(self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict): + webhook = await WebhookFactory.create() + + response = await async_client.delete(self.url(webhook.id), headers=owner_auth_header) + + assert response.status_code == 200 + assert response.json() == { + "id": str(webhook.id), + "url": webhook.url, + "secret": webhook.secret, + "events": [WebhookEvent.response_created], + "enabled": True, + "description": None, + "inserted_at": webhook.inserted_at.isoformat(), + "updated_at": webhook.updated_at.isoformat(), + } + + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 0 + + async def test_delete_webhook_as_admin(self, db: AsyncSession, async_client: AsyncClient): + admin = await AdminFactory.create() + + webhook = await WebhookFactory.create() + + response = await async_client.delete( + self.url(webhook.id), + headers={API_KEY_HEADER_NAME: admin.api_key}, + ) + + assert response.status_code == 403 + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 1 + + async def test_delete_webhook_as_annotator(self, db: AsyncSession, async_client: AsyncClient): + annotator = await AnnotatorFactory.create() + + webhook = await WebhookFactory.create() + + response = await async_client.delete( + self.url(webhook.id), + headers={API_KEY_HEADER_NAME: annotator.api_key}, + ) + + assert response.status_code == 403 + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 1 + + async def test_delete_webhook_without_authentication(self, db: AsyncSession, async_client: AsyncClient): + webhook = await WebhookFactory.create() + + response = await async_client.delete(self.url(webhook.id)) + + assert response.status_code == 401 + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 1 + + async def test_delete_webhook_with_nonexistent_webhook_id(self, async_client: AsyncClient, owner_auth_header: dict): + webhook_id = uuid4() + + response = await async_client.delete(self.url(webhook_id), headers=owner_auth_header) + + assert response.status_code == 404 + assert response.json() == {"detail": f"Webhook with id `{webhook_id}` not found"} diff --git a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_list_webhooks.py b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_list_webhooks.py new file mode 100644 index 0000000000..e7c0dc1216 --- /dev/null +++ b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_list_webhooks.py @@ -0,0 +1,90 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from httpx import AsyncClient + +from argilla_server.api.webhooks.v1.enums import WebhookEvent +from argilla_server.constants import API_KEY_HEADER_NAME + +from tests.factories import AdminFactory, AnnotatorFactory, WebhookFactory + + +@pytest.mark.asyncio +class TestListWebhooks: + def url(self) -> str: + return "/api/v1/webhooks" + + async def test_list_webhooks(self, async_client: AsyncClient, owner_auth_header: dict): + webhooks = await WebhookFactory.create_batch(2) + + response = await async_client.get(self.url(), headers=owner_auth_header) + + assert response.status_code == 200 + assert response.json() == { + "items": [ + { + "id": str(webhooks[0].id), + "url": webhooks[0].url, + "secret": webhooks[0].secret, + "events": [WebhookEvent.response_created], + "enabled": True, + "description": None, + "inserted_at": webhooks[0].inserted_at.isoformat(), + "updated_at": webhooks[0].updated_at.isoformat(), + }, + { + "id": str(webhooks[1].id), + "url": webhooks[1].url, + "secret": webhooks[1].secret, + "events": [WebhookEvent.response_created], + "enabled": True, + "description": None, + "inserted_at": webhooks[1].inserted_at.isoformat(), + "updated_at": webhooks[1].updated_at.isoformat(), + }, + ], + } + + async def test_list_webhooks_without_webhooks(self, async_client: AsyncClient, owner_auth_header: dict): + response = await async_client.get(self.url(), headers=owner_auth_header) + + assert response.status_code == 200 + assert response.json() == {"items": []} + + async def test_list_webhooks_as_admin(self, async_client: AsyncClient): + admin = await AdminFactory.create() + + response = await async_client.get( + self.url(), + headers={API_KEY_HEADER_NAME: admin.api_key}, + ) + + assert response.status_code == 403 + + async def test_list_webhooks_as_annotator(self, async_client: AsyncClient): + annotator = await AnnotatorFactory.create() + + response = await async_client.get( + self.url(), + headers={API_KEY_HEADER_NAME: annotator.api_key}, + ) + + assert response.status_code == 403 + + async def test_list_webhooks_without_authentication(self, async_client: AsyncClient): + response = await async_client.get(self.url()) + + assert response.status_code == 401 diff --git a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_ping_webhook.py b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_ping_webhook.py new file mode 100644 index 0000000000..d8cd633199 --- /dev/null +++ b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_ping_webhook.py @@ -0,0 +1,98 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +import respx +import json + +from uuid import UUID, uuid4 +from httpx import AsyncClient, Response +from standardwebhooks.webhooks import Webhook + +from argilla_server.contexts import info +from argilla_server.constants import API_KEY_HEADER_NAME +from argilla_server.api.webhooks.v1.enums import WebhookEvent + +from tests.factories import AdminFactory, AnnotatorFactory, WebhookFactory + + +@pytest.mark.asyncio +class TestPingWebhook: + def url(self, webhook_id: UUID) -> str: + return f"/api/v1/webhooks/{webhook_id}/ping" + + async def test_ping_webhook(self, async_client: AsyncClient, owner_auth_header: dict, respx_mock): + webhook = await WebhookFactory.create() + + respx_mock.post(webhook.url).mock(return_value=Response(200)) + response = await async_client.post( + self.url(webhook.id), + headers=owner_auth_header, + ) + + assert response.status_code == 204 + + request, _ = respx.calls.last + timestamp = json.loads(request.content)["timestamp"] + + wh = Webhook(webhook.secret) + assert wh.verify(headers=request.headers, data=request.content) == { + "type": WebhookEvent.ping, + "timestamp": timestamp, + "data": { + "agent": "argilla-server", + "version": info.argilla_version(), + }, + } + + async def test_ping_webhook_as_admin(self, async_client: AsyncClient, respx_mock): + admin = await AdminFactory.create() + webhook = await WebhookFactory.create() + + respx_mock.post(webhook.url).mock(return_value=Response(200)) + response = await async_client.post( + self.url(webhook.id), + headers={API_KEY_HEADER_NAME: admin.api_key}, + ) + + assert response.status_code == 403 + + async def test_ping_webhook_as_annotator(self, async_client: AsyncClient): + annotator = await AnnotatorFactory.create() + webhook = await WebhookFactory.create() + + response = await async_client.post( + self.url(webhook.id), + headers={API_KEY_HEADER_NAME: annotator.api_key}, + ) + + assert response.status_code == 403 + + async def test_ping_webhook_without_authentication(self, async_client: AsyncClient): + webhook = await WebhookFactory.create() + + response = await async_client.post(self.url(webhook.id)) + + assert response.status_code == 401 + + async def test_ping_webhook_with_nonexistent_webhook_id(self, async_client: AsyncClient, owner_auth_header: dict): + webhook_id = uuid4() + + response = await async_client.post( + self.url(webhook_id), + headers=owner_auth_header, + ) + + assert response.status_code == 404 + assert response.json() == {"detail": f"Webhook with id `{webhook_id}` not found"} diff --git a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_update_webhook.py b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_update_webhook.py new file mode 100644 index 0000000000..25dd0d72ad --- /dev/null +++ b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_update_webhook.py @@ -0,0 +1,414 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from uuid import UUID, uuid4 +from httpx import AsyncClient +from typing import Any + +from argilla_server.api.webhooks.v1.enums import WebhookEvent +from argilla_server.constants import API_KEY_HEADER_NAME + +from tests.factories import AdminFactory, AnnotatorFactory, WebhookFactory + + +@pytest.mark.asyncio +class TestUpdateWebhook: + def url(self, webhook_id: UUID) -> str: + return f"/api/v1/webhooks/{webhook_id}" + + async def test_update_webhook(self, async_client: AsyncClient, owner_auth_header: dict): + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers=owner_auth_header, + json={ + "url": "https://example.com/webhook", + "events": [WebhookEvent.ping], + "enabled": False, + "description": "Test webhook", + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(webhook.id), + "url": "https://example.com/webhook", + "secret": webhook.secret, + "events": [WebhookEvent.ping], + "enabled": False, + "description": "Test webhook", + "inserted_at": webhook.inserted_at.isoformat(), + "updated_at": webhook.updated_at.isoformat(), + } + + assert webhook.url == "https://example.com/webhook" + assert webhook.events == [WebhookEvent.ping] + + async def test_update_webhook_with_url(self, async_client: AsyncClient, owner_auth_header: dict): + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers=owner_auth_header, + json={ + "url": "https://example.com/webhook", + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(webhook.id), + "url": "https://example.com/webhook", + "secret": webhook.secret, + "events": webhook.events, + "enabled": True, + "description": None, + "inserted_at": webhook.inserted_at.isoformat(), + "updated_at": webhook.updated_at.isoformat(), + } + + assert webhook.url == "https://example.com/webhook" + + async def test_update_webhook_with_events(self, async_client: AsyncClient, owner_auth_header: dict): + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers=owner_auth_header, + json={ + "events": [WebhookEvent.ping], + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(webhook.id), + "url": webhook.url, + "secret": webhook.secret, + "events": [WebhookEvent.ping], + "enabled": True, + "description": None, + "inserted_at": webhook.inserted_at.isoformat(), + "updated_at": webhook.updated_at.isoformat(), + } + + assert webhook.events == [WebhookEvent.ping] + + async def test_update_webhook_with_enabled(self, async_client: AsyncClient, owner_auth_header: dict): + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers=owner_auth_header, + json={ + "enabled": False, + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(webhook.id), + "url": webhook.url, + "secret": webhook.secret, + "events": webhook.events, + "enabled": False, + "description": None, + "inserted_at": webhook.inserted_at.isoformat(), + "updated_at": webhook.updated_at.isoformat(), + } + + assert webhook.enabled == False + + async def test_update_webhook_with_description(self, async_client: AsyncClient, owner_auth_header: dict): + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers=owner_auth_header, + json={ + "description": "Test webhook", + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(webhook.id), + "url": webhook.url, + "secret": webhook.secret, + "events": webhook.events, + "enabled": True, + "description": "Test webhook", + "inserted_at": webhook.inserted_at.isoformat(), + "updated_at": webhook.updated_at.isoformat(), + } + + assert webhook.description == "Test webhook" + + async def test_update_webhook_without_changes(self, async_client: AsyncClient, owner_auth_header: dict): + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers=owner_auth_header, + json={}, + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(webhook.id), + "url": webhook.url, + "secret": webhook.secret, + "events": webhook.events, + "enabled": True, + "description": None, + "inserted_at": webhook.inserted_at.isoformat(), + "updated_at": webhook.updated_at.isoformat(), + } + + async def test_update_webhook_as_admin(self, async_client: AsyncClient): + admin = await AdminFactory.create() + + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers={API_KEY_HEADER_NAME: admin.api_key}, + json={ + "url": "https://example.com/webhook", + "events": [WebhookEvent.ping], + }, + ) + + assert response.status_code == 403 + + assert webhook.url != "https://example.com/webhook" + assert webhook.events != [WebhookEvent.ping] + + async def test_update_webhook_as_annotator(self, async_client: AsyncClient): + annotator = await AnnotatorFactory.create() + + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers={API_KEY_HEADER_NAME: annotator.api_key}, + json={ + "url": "https://example.com/webhook", + "events": [WebhookEvent.ping], + }, + ) + + assert response.status_code == 403 + + assert webhook.url != "https://example.com/webhook" + assert webhook.events != [WebhookEvent.ping] + + async def test_update_webhook_without_authentication(self, async_client: AsyncClient): + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + json={ + "url": "https://example.com/webhook", + "events": [WebhookEvent.ping], + }, + ) + + assert response.status_code == 401 + + assert webhook.url != "https://example.com/webhook" + assert webhook.events != [WebhookEvent.ping] + + @pytest.mark.parametrize("invalid_url", ["", "example.com", "http:example.com", "https:example.com"]) + async def test_update_webhook_with_invalid_url( + self, async_client: AsyncClient, owner_auth_header: dict, invalid_url: str + ): + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers=owner_auth_header, + json={ + "url": invalid_url, + "events": [WebhookEvent.ping], + }, + ) + + assert response.status_code == 422 + + assert webhook.url != invalid_url + assert webhook.events != [WebhookEvent.ping] + + @pytest.mark.parametrize("invalid_events", [[], ["invalid_event"], [WebhookEvent.ping, "invalid_event"]]) + async def test_update_webhook_with_invalid_events( + self, async_client: AsyncClient, owner_auth_header: dict, invalid_events: list + ): + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers=owner_auth_header, + json={ + "url": "https://example.com/webhook", + "events": invalid_events, + }, + ) + + assert response.status_code == 422 + + assert webhook.url != "https://example.com/webhook" + assert webhook.events != invalid_events + + async def test_update_webhook_with_duplicated_events(self, async_client: AsyncClient, owner_auth_header: dict): + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers=owner_auth_header, + json={ + "events": [WebhookEvent.ping, WebhookEvent.ping], + }, + ) + + assert response.status_code == 422 + assert webhook.events != [WebhookEvent.ping, WebhookEvent.ping] + + @pytest.mark.parametrize("invalid_enabled", ["", "invalid", 123]) + async def test_update_webhook_with_invalid_enabled( + self, async_client: AsyncClient, owner_auth_header: dict, invalid_enabled: Any + ): + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers=owner_auth_header, + json={ + "enabled": invalid_enabled, + }, + ) + + assert response.status_code == 422 + assert webhook.enabled != invalid_enabled + + @pytest.mark.parametrize("invalid_description", ["", "d" * 1001]) + async def test_update_webhook_with_invalid_description( + self, async_client: AsyncClient, owner_auth_header: dict, invalid_description: str + ): + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers=owner_auth_header, + json={ + "description": invalid_description, + }, + ) + + assert response.status_code == 422 + assert webhook.description != invalid_description + + async def test_update_webhook_with_url_as_none(self, async_client: AsyncClient, owner_auth_header: dict): + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers=owner_auth_header, + json={ + "url": None, + "events": [WebhookEvent.ping], + }, + ) + + assert response.status_code == 422 + + assert webhook.url != None + assert webhook.events != [WebhookEvent.ping] + + async def test_update_webhook_with_enabled_as_none(self, async_client: AsyncClient, owner_auth_header: dict): + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers=owner_auth_header, + json={ + "enabled": None, + }, + ) + + assert response.status_code == 422 + assert webhook.enabled != None + + async def test_update_webhook_with_events_as_none(self, async_client: AsyncClient, owner_auth_header: dict): + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers=owner_auth_header, + json={ + "url": "https://example.com/webhook", + "events": None, + }, + ) + + assert response.status_code == 422 + + assert webhook.url != "https://example.com/webhook" + assert webhook.events != None + + async def test_update_webhook_with_description_as_none(self, async_client: AsyncClient, owner_auth_header: dict): + webhook = await WebhookFactory.create(description="Test webhook") + + response = await async_client.patch( + self.url(webhook.id), + headers=owner_auth_header, + json={ + "url": "https://example.com/webhook", + "events": [WebhookEvent.ping], + "description": None, + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(webhook.id), + "url": "https://example.com/webhook", + "secret": webhook.secret, + "events": [WebhookEvent.ping], + "enabled": True, + "description": None, + "inserted_at": webhook.inserted_at.isoformat(), + "updated_at": webhook.updated_at.isoformat(), + } + + assert webhook.url == "https://example.com/webhook" + assert webhook.events == [WebhookEvent.ping] + assert webhook.description == None + + async def test_update_webhook_with_nonexistent_webhook_id(self, async_client: AsyncClient, owner_auth_header: dict): + webhook_id = uuid4() + + response = await async_client.patch( + self.url(webhook_id), + headers=owner_auth_header, + json={ + "url": "https://example.com/webhook", + "events": [WebhookEvent.ping], + }, + ) + + assert response.status_code == 404 + assert response.json() == {"detail": f"Webhook with id `{webhook_id}` not found"} diff --git a/argilla-server/tests/unit/api/webhooks/__init__.py b/argilla-server/tests/unit/api/webhooks/__init__.py new file mode 100644 index 0000000000..4b6cecae7f --- /dev/null +++ b/argilla-server/tests/unit/api/webhooks/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/argilla-server/tests/unit/api/webhooks/v1/__init__.py b/argilla-server/tests/unit/api/webhooks/v1/__init__.py new file mode 100644 index 0000000000..4b6cecae7f --- /dev/null +++ b/argilla-server/tests/unit/api/webhooks/v1/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/argilla-server/tests/unit/api/webhooks/v1/test_notify_ping_event.py b/argilla-server/tests/unit/api/webhooks/v1/test_notify_ping_event.py new file mode 100644 index 0000000000..473ca42c24 --- /dev/null +++ b/argilla-server/tests/unit/api/webhooks/v1/test_notify_ping_event.py @@ -0,0 +1,51 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +import respx +import json + +from httpx import Response +from standardwebhooks.webhooks import Webhook + +from argilla_server.api.webhooks.v1.enums import WebhookEvent +from argilla_server.api.webhooks.v1.ping import notify_ping_event +from argilla_server.contexts import info + +from tests.factories import WebhookFactory + + +@pytest.mark.asyncio +class TestNotifyPingEvent: + async def test_notify_ping_event(self, respx_mock): + webhook = await WebhookFactory.create() + + respx_mock.post(webhook.url).mock(return_value=Response(200)) + response = notify_ping_event(webhook) + + assert response.status_code == 200 + + request, _ = respx.calls.last + timestamp = json.loads(request.content)["timestamp"] + + wh = Webhook(webhook.secret) + assert wh.verify(headers=request.headers, data=request.content) == { + "type": WebhookEvent.ping, + "timestamp": timestamp, + "data": { + "agent": "argilla-server", + "version": info.argilla_version(), + }, + } diff --git a/argilla-server/tests/unit/api/webhooks/v1/test_notify_response_created_event.py b/argilla-server/tests/unit/api/webhooks/v1/test_notify_response_created_event.py new file mode 100644 index 0000000000..1550f29432 --- /dev/null +++ b/argilla-server/tests/unit/api/webhooks/v1/test_notify_response_created_event.py @@ -0,0 +1,56 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +import respx +import json + +from httpx import Response +from standardwebhooks.webhooks import Webhook + +from argilla_server.enums import ResponseStatus +from argilla_server.api.webhooks.v1.enums import WebhookEvent +from argilla_server.api.webhooks.v1.responses import notify_response_created_event + +from tests.factories import ResponseFactory, WebhookFactory + + +@pytest.mark.asyncio +class TestNotifyResponseCreatedEvent: + async def test_notify_response_created_event(self, respx_mock): + webhook = await WebhookFactory.create() + response = await ResponseFactory.create() + + respx_mock.post(webhook.url).mock(return_value=Response(200)) + resp = notify_response_created_event(webhook, response) + + assert resp.status_code == 200 + + request, _ = respx.calls.last + timestamp = json.loads(request.content)["timestamp"] + + wh = Webhook(webhook.secret) + assert wh.verify(headers=request.headers, data=request.content) == { + "type": WebhookEvent.response_created, + "timestamp": timestamp, + "data": { + "id": str(response.id), + "values": None, + "status": ResponseStatus.submitted, + "record_id": str(response.record_id), + "user_id": str(response.user_id), + "inserted_at": response.inserted_at.isoformat(), + "updated_at": response.updated_at.isoformat(), + }, + } diff --git a/argilla-server/tests/unit/models/__init__.py b/argilla-server/tests/unit/models/__init__.py new file mode 100644 index 0000000000..4b6cecae7f --- /dev/null +++ b/argilla-server/tests/unit/models/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/argilla-server/tests/unit/models/test_webhook.py b/argilla-server/tests/unit/models/test_webhook.py new file mode 100644 index 0000000000..a33e99a0c0 --- /dev/null +++ b/argilla-server/tests/unit/models/test_webhook.py @@ -0,0 +1,30 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from tests.factories import WebhookFactory + + +@pytest.mark.asyncio +class TestWebhook: + async def test_secret_is_generated_by_default(self): + webhook = await WebhookFactory.create() + + assert webhook.secret + + async def test_secret_is_generated_by_default_individually(self): + webhooks = await WebhookFactory.create_batch(2) + + assert webhooks[0].secret != webhooks[1].secret From 70a9b2eb7aed1e458e724a828490ad87dd363064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Wed, 11 Sep 2024 13:45:05 +0200 Subject: [PATCH 02/24] feat: add Webhooks notify events using background jobs (#5468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description This PR adds the following changes: * Add first real Webhooks notifications for responses using some background jobs for them. * Add a new RQ `high` queue where we will enqueue the Webhooks notification jobs. * Add webhook events: * `response.created` * `response.updated` * `response.deleted` * `response.upserted` * `dataset.created` * `dataset.updated` * `dataset.deleted` * `dataset.published` **Type of change** - New feature (non-breaking change which adds functionality) **How Has This Been Tested** - [x] Manually tested locally and in HF spaces. **Checklist** - I added relevant documentation - I followed the style guidelines of this project - I did a self-review of my code - I made corresponding changes to the documentation - I confirm My changes generate no new warnings - I have added tests that prove my fix is effective or that my feature works - I have added relevant notes to the CHANGELOG.md file (See https://keepachangelog.com/) --------- Co-authored-by: Damián Pumar Co-authored-by: Paco Aranda --- argilla-server/CHANGELOG.md | 2 + .../docker/argilla-hf-spaces/Dockerfile | 1 - .../docker/argilla-hf-spaces/Procfile | 3 +- .../argilla_server/api/webhooks/v1/commons.py | 5 +- .../api/webhooks/v1/datasets.py | 33 +++++++++++ .../argilla_server/api/webhooks/v1/enums.py | 32 ++++++++++- .../argilla_server/api/webhooks/v1/ping.py | 2 +- .../api/webhooks/v1/responses.py | 23 ++++---- .../src/argilla_server/cli/worker.py | 4 +- .../src/argilla_server/contexts/datasets.py | 19 ++++++- .../src/argilla_server/contexts/webhooks.py | 10 +++- .../src/argilla_server/jobs/dataset_jobs.py | 6 +- .../src/argilla_server/jobs/queues.py | 3 +- .../src/argilla_server/jobs/webhook_jobs.py | 55 ++++++++++++++++++ .../v1/webhooks/test_create_webhook.py | 12 +++- .../handlers/v1/webhooks/test_ping_webhook.py | 4 +- .../v1/webhooks/test_update_webhook.py | 57 +++++++++++-------- .../api/webhooks/v1/test_notify_ping_event.py | 3 +- .../v1/test_notify_response_created_event.py | 56 ------------------ 19 files changed, 221 insertions(+), 109 deletions(-) create mode 100644 argilla-server/src/argilla_server/api/webhooks/v1/datasets.py create mode 100644 argilla-server/src/argilla_server/jobs/webhook_jobs.py delete mode 100644 argilla-server/tests/unit/api/webhooks/v1/test_notify_response_created_event.py diff --git a/argilla-server/CHANGELOG.md b/argilla-server/CHANGELOG.md index 16b415c530..a02c4d7c15 100644 --- a/argilla-server/CHANGELOG.md +++ b/argilla-server/CHANGELOG.md @@ -21,6 +21,8 @@ These are the section headers that we use: - Added [`rq`](https://python-rq.org) library to process background jobs using [Redis](https://redis.io) as a dependency. ([#5432](https://github.com/argilla-io/argilla/pull/5432)) - Added a new background job to update records status when a dataset distribution strategy is updated. ([#5432](https://github.com/argilla-io/argilla/pull/5432)) - Added new endpoints to create, update, ping and delete webhooks. ([#5453](https://github.com/argilla-io/argilla/pull/5453)) +- Added new webhook events when responses are created, updated, deleted or upserted. ([#5468](https://github.com/argilla-io/argilla/pull/5468)) +- Added new webhook events when datasets are created, updated, deleted or published. ([#5468](https://github.com/argilla-io/argilla/pull/5468)) ## [2.1.0](https://github.com/argilla-io/argilla/compare/v2.0.0...v2.1.0) diff --git a/argilla-server/docker/argilla-hf-spaces/Dockerfile b/argilla-server/docker/argilla-hf-spaces/Dockerfile index 1fb2419e81..6796c8c781 100644 --- a/argilla-server/docker/argilla-hf-spaces/Dockerfile +++ b/argilla-server/docker/argilla-hf-spaces/Dockerfile @@ -58,7 +58,6 @@ ENV ELASTIC_CONTAINER=true ENV ES_JAVA_OPTS="-Xms1g -Xmx1g" ENV ARGILLA_HOME_PATH=/data/argilla -ENV BACKGROUND_NUM_WORKERS=2 ENV REINDEX_DATASETS=1 CMD ["/bin/bash", "start.sh"] diff --git a/argilla-server/docker/argilla-hf-spaces/Procfile b/argilla-server/docker/argilla-hf-spaces/Procfile index 751d36e4b4..42d2e496e0 100644 --- a/argilla-server/docker/argilla-hf-spaces/Procfile +++ b/argilla-server/docker/argilla-hf-spaces/Procfile @@ -1,4 +1,5 @@ elastic: /usr/share/elasticsearch/bin/elasticsearch redis: /usr/bin/redis-server -worker: sleep 30; rq worker-pool --num-workers ${BACKGROUND_NUM_WORKERS} +worker_high: sleep 30; rq worker-pool --num-workers 2 high +worker_low: sleep 30; rq worker-pool --num-workers 1 low argilla: sleep 30; /bin/bash start_argilla_server.sh diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/commons.py b/argilla-server/src/argilla_server/api/webhooks/v1/commons.py index eecbc598a1..032462ad37 100644 --- a/argilla-server/src/argilla_server/api/webhooks/v1/commons.py +++ b/argilla-server/src/argilla_server/api/webhooks/v1/commons.py @@ -30,9 +30,9 @@ # NOTE: We are using standard webhooks implementation. # For more information take a look to https://www.standardwebhooks.com -def notify_event(webhook: WebhookModel, type: str, timestamp: datetime, data: Dict) -> httpx.Response: +def notify_event(webhook: WebhookModel, event: str, timestamp: datetime, data: Dict) -> httpx.Response: msg_id = _generate_msg_id() - payload = json.dumps(_build_payload(type, timestamp, data)) + payload = json.dumps(_build_payload(event, timestamp, data)) signature = Webhook(webhook.secret).sign(msg_id, timestamp, payload) return httpx.post( @@ -59,6 +59,7 @@ def _build_headers(msg_id: str, timestamp: datetime, signature: str) -> Dict: def _build_payload(type: str, timestamp: datetime, data: Dict) -> Dict: return { "type": type, + "version": 1, "timestamp": timestamp.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), "data": data, } diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/datasets.py b/argilla-server/src/argilla_server/api/webhooks/v1/datasets.py new file mode 100644 index 0000000000..fcd316e422 --- /dev/null +++ b/argilla-server/src/argilla_server/api/webhooks/v1/datasets.py @@ -0,0 +1,33 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import List +from datetime import datetime + +from rq.job import Job +from sqlalchemy.ext.asyncio import AsyncSession + +from argilla_server.models import Dataset +from argilla_server.jobs.webhook_jobs import enqueue_notify_events +from argilla_server.api.schemas.v1.datasets import Dataset as DatasetSchema +from argilla_server.api.webhooks.v1.enums import DatasetEvent + + +async def notify_dataset_event(db: AsyncSession, dataset_event: DatasetEvent, dataset: Dataset) -> List[Job]: + return await enqueue_notify_events( + db, + event=dataset_event, + timestamp=datetime.utcnow(), + data=DatasetSchema.from_orm(dataset).dict(), + ) diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/enums.py b/argilla-server/src/argilla_server/api/webhooks/v1/enums.py index cf3e8541f6..76f9be4edd 100644 --- a/argilla-server/src/argilla_server/api/webhooks/v1/enums.py +++ b/argilla-server/src/argilla_server/api/webhooks/v1/enums.py @@ -16,5 +16,35 @@ class WebhookEvent(str, Enum): + dataset_created = "dataset.created" + dataset_updated = "dataset.updated" + dataset_deleted = "dataset.deleted" + dataset_published = "dataset.published" + response_created = "response.created" - ping = "ping" + response_updated = "response.updated" + response_deleted = "response.deleted" + response_upserted = "response.upserted" + + def __str__(self): + return str(self.value) + + +class DatasetEvent(str, Enum): + created = WebhookEvent.dataset_created.value + updated = WebhookEvent.dataset_updated.value + deleted = WebhookEvent.dataset_deleted.value + published = WebhookEvent.dataset_published.value + + def __str__(self): + return str(self.value) + + +class ResponseEvent(str, Enum): + created = WebhookEvent.response_created.value + updated = WebhookEvent.response_updated.value + deleted = WebhookEvent.response_deleted.value + upserted = WebhookEvent.response_upserted.value + + def __str__(self): + return str(self.value) diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/ping.py b/argilla-server/src/argilla_server/api/webhooks/v1/ping.py index 48a54d0050..f4b2a0bf50 100644 --- a/argilla-server/src/argilla_server/api/webhooks/v1/ping.py +++ b/argilla-server/src/argilla_server/api/webhooks/v1/ping.py @@ -25,7 +25,7 @@ def notify_ping_event(webhook: Webhook) -> httpx.Response: return notify_event( webhook=webhook, - type=WebhookEvent.ping, + event="ping", timestamp=datetime.utcnow(), data={ "agent": "argilla-server", diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/responses.py b/argilla-server/src/argilla_server/api/webhooks/v1/responses.py index aa026fb010..1d5792f895 100644 --- a/argilla-server/src/argilla_server/api/webhooks/v1/responses.py +++ b/argilla-server/src/argilla_server/api/webhooks/v1/responses.py @@ -12,23 +12,22 @@ # See the License for the specific language governing permissions and # limitations under the License. -import json -import httpx - +from typing import List from datetime import datetime -from fastapi.encoders import jsonable_encoder +from rq.job import Job +from sqlalchemy.ext.asyncio import AsyncSession -from argilla_server.models import Response, Webhook +from argilla_server.models import Response +from argilla_server.jobs.webhook_jobs import enqueue_notify_events from argilla_server.api.schemas.v1.responses import Response as ResponseSchema -from argilla_server.api.webhooks.v1.commons import notify_event -from argilla_server.api.webhooks.v1.enums import WebhookEvent +from argilla_server.api.webhooks.v1.enums import ResponseEvent -def notify_response_created_event(webhook: Webhook, response: Response) -> httpx.Response: - return notify_event( - webhook=webhook, - type=WebhookEvent.response_created, +async def notify_response_event(db: AsyncSession, response_event: ResponseEvent, response: Response) -> List[Job]: + return await enqueue_notify_events( + db, + event=response_event, timestamp=datetime.utcnow(), - data=jsonable_encoder(ResponseSchema.from_orm(response)), + data=ResponseSchema.from_orm(response).dict(), ) diff --git a/argilla-server/src/argilla_server/cli/worker.py b/argilla-server/src/argilla_server/cli/worker.py index 710f35422a..befbc88c66 100644 --- a/argilla-server/src/argilla_server/cli/worker.py +++ b/argilla-server/src/argilla_server/cli/worker.py @@ -16,13 +16,13 @@ from typing import List -from argilla_server.jobs.queues import DEFAULT_QUEUE +from argilla_server.jobs.queues import LOW_QUEUE, HIGH_QUEUE DEFAULT_NUM_WORKERS = 2 def worker( - queues: List[str] = typer.Option([DEFAULT_QUEUE.name], help="Name of queues to listen"), + queues: List[str] = typer.Option([LOW_QUEUE.name, HIGH_QUEUE.name], help="Name of queues to listen"), num_workers: int = typer.Option(DEFAULT_NUM_WORKERS, help="Number of workers to start"), ) -> None: from rq.worker_pool import WorkerPool diff --git a/argilla-server/src/argilla_server/contexts/datasets.py b/argilla-server/src/argilla_server/contexts/datasets.py index 330a457ef8..e53cbd5a73 100644 --- a/argilla-server/src/argilla_server/contexts/datasets.py +++ b/argilla-server/src/argilla_server/contexts/datasets.py @@ -60,6 +60,9 @@ VectorSettingsCreate, ) from argilla_server.api.schemas.v1.vectors import Vector as VectorSchema +from argilla_server.api.webhooks.v1.enums import DatasetEvent, ResponseEvent +from argilla_server.api.webhooks.v1.responses import notify_response_event as notify_response_event_v1 +from argilla_server.api.webhooks.v1.datasets import notify_dataset_event as notify_dataset_event_v1 from argilla_server.contexts import accounts, distribution from argilla_server.database import get_async_db from argilla_server.enums import DatasetStatus, UserRole, RecordStatus @@ -129,7 +132,11 @@ async def create_dataset(db: AsyncSession, dataset_attrs: dict) -> Dataset: await DatasetCreateValidator.validate(db, dataset) - return await dataset.save(db) + await dataset.save(db) + + await notify_dataset_event_v1(db, DatasetEvent.created, dataset) + + return dataset async def _count_required_fields_by_dataset_id(db: AsyncSession, dataset_id: UUID) -> int: @@ -165,6 +172,8 @@ async def publish_dataset(db: AsyncSession, search_engine: SearchEngine, dataset await db.commit() + await notify_dataset_event_v1(db, DatasetEvent.published, dataset) + return dataset @@ -175,6 +184,8 @@ async def update_dataset(db: AsyncSession, dataset: Dataset, dataset_attrs: dict dataset_jobs.update_dataset_records_status_job.delay(dataset.id) + await notify_dataset_event_v1(db, DatasetEvent.updated, dataset) + return dataset @@ -185,6 +196,8 @@ async def delete_dataset(db: AsyncSession, search_engine: SearchEngine, dataset: await db.commit() + await notify_dataset_event_v1(db, DatasetEvent.deleted, dataset) + return dataset @@ -864,6 +877,7 @@ async def create_response( await db.commit() await distribution.update_record_status(search_engine, record.id) + await notify_response_event_v1(db, ResponseEvent.created, response) return response @@ -888,6 +902,7 @@ async def update_response( await db.commit() await distribution.update_record_status(search_engine, response.record_id) + await notify_response_event_v1(db, ResponseEvent.updated, response) return response @@ -916,6 +931,7 @@ async def upsert_response( await db.commit() await distribution.update_record_status(search_engine, record.id) + await notify_response_event_v1(db, ResponseEvent.upserted, response) return response @@ -930,6 +946,7 @@ async def delete_response(db: AsyncSession, search_engine: SearchEngine, respons await db.commit() await distribution.update_record_status(search_engine, response.record_id) + await notify_response_event_v1(db, ResponseEvent.deleted, response) return response diff --git a/argilla-server/src/argilla_server/contexts/webhooks.py b/argilla-server/src/argilla_server/contexts/webhooks.py index 5baade7ea7..08b29e3109 100644 --- a/argilla-server/src/argilla_server/contexts/webhooks.py +++ b/argilla-server/src/argilla_server/contexts/webhooks.py @@ -22,7 +22,15 @@ async def list_webhooks(db: AsyncSession) -> Sequence[Webhook]: - return (await db.execute(select(Webhook).order_by(Webhook.inserted_at.asc()))).scalars().all() + result = await db.execute(select(Webhook).order_by(Webhook.inserted_at.asc())) + + return result.scalars().all() + + +async def list_enabled_webhooks(db: AsyncSession) -> Sequence[Webhook]: + result = await db.execute(select(Webhook).where(Webhook.enabled == True).order_by(Webhook.inserted_at.asc())) + + return result.scalars().all() async def create_webhook(db: AsyncSession, webhook_attrs: dict) -> Webhook: diff --git a/argilla-server/src/argilla_server/jobs/dataset_jobs.py b/argilla-server/src/argilla_server/jobs/dataset_jobs.py index 2389a315e8..6a0045fd1d 100644 --- a/argilla-server/src/argilla_server/jobs/dataset_jobs.py +++ b/argilla-server/src/argilla_server/jobs/dataset_jobs.py @@ -21,7 +21,7 @@ from argilla_server.models import Record, Response from argilla_server.database import AsyncSessionLocal -from argilla_server.jobs.queues import DEFAULT_QUEUE +from argilla_server.jobs.queues import LOW_QUEUE from argilla_server.search_engine.base import SearchEngine from argilla_server.settings import settings from argilla_server.contexts import distribution @@ -30,8 +30,8 @@ JOB_RECORDS_YIELD_PER = 100 -@job(DEFAULT_QUEUE, timeout=JOB_TIMEOUT_DISABLED, retry=Retry(max=3)) -async def update_dataset_records_status_job(dataset_id: UUID): +@job(LOW_QUEUE, timeout=JOB_TIMEOUT_DISABLED, retry=Retry(max=3)) +async def update_dataset_records_status_job(dataset_id: UUID) -> None: """This Job updates the status of all the records in the dataset when the distribution strategy changes.""" record_ids = [] diff --git a/argilla-server/src/argilla_server/jobs/queues.py b/argilla-server/src/argilla_server/jobs/queues.py index 0f17a63bd6..2ba5ead309 100644 --- a/argilla-server/src/argilla_server/jobs/queues.py +++ b/argilla-server/src/argilla_server/jobs/queues.py @@ -21,4 +21,5 @@ REDIS_CONNECTION = redis.from_url(settings.redis_url) -DEFAULT_QUEUE = Queue("default", connection=REDIS_CONNECTION) +LOW_QUEUE = Queue("low", connection=REDIS_CONNECTION) +HIGH_QUEUE = Queue("high", connection=REDIS_CONNECTION) diff --git a/argilla-server/src/argilla_server/jobs/webhook_jobs.py b/argilla-server/src/argilla_server/jobs/webhook_jobs.py new file mode 100644 index 0000000000..a8c054cad4 --- /dev/null +++ b/argilla-server/src/argilla_server/jobs/webhook_jobs.py @@ -0,0 +1,55 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import httpx + +from typing import List + +from uuid import UUID +from datetime import datetime + +from rq.job import Retry, Job +from rq.decorators import job +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi.encoders import jsonable_encoder + +from argilla_server.api.webhooks.v1.commons import notify_event +from argilla_server.database import AsyncSessionLocal +from argilla_server.jobs.queues import HIGH_QUEUE +from argilla_server.contexts import webhooks +from argilla_server.models import Webhook + + +async def enqueue_notify_events(db: AsyncSession, event: str, timestamp: datetime, data: dict) -> List[Job]: + enabled_webhooks = await webhooks.list_enabled_webhooks(db) + if len(enabled_webhooks) == 0: + return [] + + enqueued_jobs = [] + jsonable_data = jsonable_encoder(data) + for enabled_webhook in enabled_webhooks: + if event in enabled_webhook.events: + enqueue_job = notify_event_job.delay(enabled_webhook.id, event, timestamp, jsonable_data) + enqueued_jobs.append(enqueue_job) + + return enqueued_jobs + + +@job(HIGH_QUEUE, retry=Retry(max=3, interval=[10, 60, 180])) +async def notify_event_job(webhook_id: UUID, event: str, timestamp: datetime, data: dict) -> None: + async with AsyncSessionLocal() as db: + webhook = await Webhook.get_or_raise(db, webhook_id) + + response = notify_event(webhook, event, timestamp, data) + response.raise_for_status() diff --git a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_create_webhook.py b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_create_webhook.py index 9125c0df66..4d168e8847 100644 --- a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_create_webhook.py +++ b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_create_webhook.py @@ -100,7 +100,17 @@ async def test_create_webhook_without_authentication(self, db: AsyncSession, asy assert response.status_code == 401 assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 0 - @pytest.mark.parametrize("invalid_url", ["", "example.com", "http:example.com", "https:example.com"]) + @pytest.mark.parametrize( + "invalid_url", + [ + "", + "example.com", + "http:example.com", + "https:example.com", + "http://localhost/webhooks", + "http://localhost:3000/webhooks", + ], + ) async def test_create_webhook_with_invalid_url( self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict, invalid_url: str ): diff --git a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_ping_webhook.py b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_ping_webhook.py index d8cd633199..949c91e5e7 100644 --- a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_ping_webhook.py +++ b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_ping_webhook.py @@ -22,7 +22,6 @@ from argilla_server.contexts import info from argilla_server.constants import API_KEY_HEADER_NAME -from argilla_server.api.webhooks.v1.enums import WebhookEvent from tests.factories import AdminFactory, AnnotatorFactory, WebhookFactory @@ -48,7 +47,8 @@ async def test_ping_webhook(self, async_client: AsyncClient, owner_auth_header: wh = Webhook(webhook.secret) assert wh.verify(headers=request.headers, data=request.content) == { - "type": WebhookEvent.ping, + "type": "ping", + "version": 1, "timestamp": timestamp, "data": { "agent": "argilla-server", diff --git a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_update_webhook.py b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_update_webhook.py index 25dd0d72ad..e4df0142c4 100644 --- a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_update_webhook.py +++ b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_update_webhook.py @@ -37,7 +37,10 @@ async def test_update_webhook(self, async_client: AsyncClient, owner_auth_header headers=owner_auth_header, json={ "url": "https://example.com/webhook", - "events": [WebhookEvent.ping], + "events": [ + WebhookEvent.response_created, + WebhookEvent.response_updated, + ], "enabled": False, "description": "Test webhook", }, @@ -48,7 +51,10 @@ async def test_update_webhook(self, async_client: AsyncClient, owner_auth_header "id": str(webhook.id), "url": "https://example.com/webhook", "secret": webhook.secret, - "events": [WebhookEvent.ping], + "events": [ + WebhookEvent.response_created, + WebhookEvent.response_updated, + ], "enabled": False, "description": "Test webhook", "inserted_at": webhook.inserted_at.isoformat(), @@ -56,7 +62,10 @@ async def test_update_webhook(self, async_client: AsyncClient, owner_auth_header } assert webhook.url == "https://example.com/webhook" - assert webhook.events == [WebhookEvent.ping] + assert webhook.events == [ + WebhookEvent.response_created, + WebhookEvent.response_updated, + ] async def test_update_webhook_with_url(self, async_client: AsyncClient, owner_auth_header: dict): webhook = await WebhookFactory.create() @@ -90,7 +99,7 @@ async def test_update_webhook_with_events(self, async_client: AsyncClient, owner self.url(webhook.id), headers=owner_auth_header, json={ - "events": [WebhookEvent.ping], + "events": [WebhookEvent.response_updated], }, ) @@ -99,14 +108,14 @@ async def test_update_webhook_with_events(self, async_client: AsyncClient, owner "id": str(webhook.id), "url": webhook.url, "secret": webhook.secret, - "events": [WebhookEvent.ping], + "events": [WebhookEvent.response_updated], "enabled": True, "description": None, "inserted_at": webhook.inserted_at.isoformat(), "updated_at": webhook.updated_at.isoformat(), } - assert webhook.events == [WebhookEvent.ping] + assert webhook.events == [WebhookEvent.response_updated] async def test_update_webhook_with_enabled(self, async_client: AsyncClient, owner_auth_header: dict): webhook = await WebhookFactory.create() @@ -189,14 +198,14 @@ async def test_update_webhook_as_admin(self, async_client: AsyncClient): headers={API_KEY_HEADER_NAME: admin.api_key}, json={ "url": "https://example.com/webhook", - "events": [WebhookEvent.ping], + "events": [WebhookEvent.response_updated], }, ) assert response.status_code == 403 assert webhook.url != "https://example.com/webhook" - assert webhook.events != [WebhookEvent.ping] + assert webhook.events != [WebhookEvent.response_updated] async def test_update_webhook_as_annotator(self, async_client: AsyncClient): annotator = await AnnotatorFactory.create() @@ -208,14 +217,14 @@ async def test_update_webhook_as_annotator(self, async_client: AsyncClient): headers={API_KEY_HEADER_NAME: annotator.api_key}, json={ "url": "https://example.com/webhook", - "events": [WebhookEvent.ping], + "events": [WebhookEvent.response_updated], }, ) assert response.status_code == 403 assert webhook.url != "https://example.com/webhook" - assert webhook.events != [WebhookEvent.ping] + assert webhook.events != [WebhookEvent.response_updated] async def test_update_webhook_without_authentication(self, async_client: AsyncClient): webhook = await WebhookFactory.create() @@ -224,14 +233,14 @@ async def test_update_webhook_without_authentication(self, async_client: AsyncCl self.url(webhook.id), json={ "url": "https://example.com/webhook", - "events": [WebhookEvent.ping], + "events": [WebhookEvent.response_updated], }, ) assert response.status_code == 401 assert webhook.url != "https://example.com/webhook" - assert webhook.events != [WebhookEvent.ping] + assert webhook.events != [WebhookEvent.response_updated] @pytest.mark.parametrize("invalid_url", ["", "example.com", "http:example.com", "https:example.com"]) async def test_update_webhook_with_invalid_url( @@ -244,16 +253,18 @@ async def test_update_webhook_with_invalid_url( headers=owner_auth_header, json={ "url": invalid_url, - "events": [WebhookEvent.ping], + "events": [WebhookEvent.response_updated], }, ) assert response.status_code == 422 assert webhook.url != invalid_url - assert webhook.events != [WebhookEvent.ping] + assert webhook.events != [WebhookEvent.response_updated] - @pytest.mark.parametrize("invalid_events", [[], ["invalid_event"], [WebhookEvent.ping, "invalid_event"]]) + @pytest.mark.parametrize( + "invalid_events", [[], ["invalid_event"], [WebhookEvent.response_updated, "invalid_event"]] + ) async def test_update_webhook_with_invalid_events( self, async_client: AsyncClient, owner_auth_header: dict, invalid_events: list ): @@ -280,12 +291,12 @@ async def test_update_webhook_with_duplicated_events(self, async_client: AsyncCl self.url(webhook.id), headers=owner_auth_header, json={ - "events": [WebhookEvent.ping, WebhookEvent.ping], + "events": [WebhookEvent.response_updated, WebhookEvent.response_updated], }, ) assert response.status_code == 422 - assert webhook.events != [WebhookEvent.ping, WebhookEvent.ping] + assert webhook.events != [WebhookEvent.response_updated, WebhookEvent.response_updated] @pytest.mark.parametrize("invalid_enabled", ["", "invalid", 123]) async def test_update_webhook_with_invalid_enabled( @@ -329,14 +340,14 @@ async def test_update_webhook_with_url_as_none(self, async_client: AsyncClient, headers=owner_auth_header, json={ "url": None, - "events": [WebhookEvent.ping], + "events": [WebhookEvent.response_updated], }, ) assert response.status_code == 422 assert webhook.url != None - assert webhook.events != [WebhookEvent.ping] + assert webhook.events != [WebhookEvent.response_updated] async def test_update_webhook_with_enabled_as_none(self, async_client: AsyncClient, owner_auth_header: dict): webhook = await WebhookFactory.create() @@ -377,7 +388,7 @@ async def test_update_webhook_with_description_as_none(self, async_client: Async headers=owner_auth_header, json={ "url": "https://example.com/webhook", - "events": [WebhookEvent.ping], + "events": [WebhookEvent.response_updated], "description": None, }, ) @@ -387,7 +398,7 @@ async def test_update_webhook_with_description_as_none(self, async_client: Async "id": str(webhook.id), "url": "https://example.com/webhook", "secret": webhook.secret, - "events": [WebhookEvent.ping], + "events": [WebhookEvent.response_updated], "enabled": True, "description": None, "inserted_at": webhook.inserted_at.isoformat(), @@ -395,7 +406,7 @@ async def test_update_webhook_with_description_as_none(self, async_client: Async } assert webhook.url == "https://example.com/webhook" - assert webhook.events == [WebhookEvent.ping] + assert webhook.events == [WebhookEvent.response_updated] assert webhook.description == None async def test_update_webhook_with_nonexistent_webhook_id(self, async_client: AsyncClient, owner_auth_header: dict): @@ -406,7 +417,7 @@ async def test_update_webhook_with_nonexistent_webhook_id(self, async_client: As headers=owner_auth_header, json={ "url": "https://example.com/webhook", - "events": [WebhookEvent.ping], + "events": [WebhookEvent.response_updated], }, ) diff --git a/argilla-server/tests/unit/api/webhooks/v1/test_notify_ping_event.py b/argilla-server/tests/unit/api/webhooks/v1/test_notify_ping_event.py index 473ca42c24..12c58cc208 100644 --- a/argilla-server/tests/unit/api/webhooks/v1/test_notify_ping_event.py +++ b/argilla-server/tests/unit/api/webhooks/v1/test_notify_ping_event.py @@ -42,7 +42,8 @@ async def test_notify_ping_event(self, respx_mock): wh = Webhook(webhook.secret) assert wh.verify(headers=request.headers, data=request.content) == { - "type": WebhookEvent.ping, + "type": "ping", + "version": 1, "timestamp": timestamp, "data": { "agent": "argilla-server", diff --git a/argilla-server/tests/unit/api/webhooks/v1/test_notify_response_created_event.py b/argilla-server/tests/unit/api/webhooks/v1/test_notify_response_created_event.py deleted file mode 100644 index 1550f29432..0000000000 --- a/argilla-server/tests/unit/api/webhooks/v1/test_notify_response_created_event.py +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright 2021-present, the Recognai S.L. team. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import pytest -import respx -import json - -from httpx import Response -from standardwebhooks.webhooks import Webhook - -from argilla_server.enums import ResponseStatus -from argilla_server.api.webhooks.v1.enums import WebhookEvent -from argilla_server.api.webhooks.v1.responses import notify_response_created_event - -from tests.factories import ResponseFactory, WebhookFactory - - -@pytest.mark.asyncio -class TestNotifyResponseCreatedEvent: - async def test_notify_response_created_event(self, respx_mock): - webhook = await WebhookFactory.create() - response = await ResponseFactory.create() - - respx_mock.post(webhook.url).mock(return_value=Response(200)) - resp = notify_response_created_event(webhook, response) - - assert resp.status_code == 200 - - request, _ = respx.calls.last - timestamp = json.loads(request.content)["timestamp"] - - wh = Webhook(webhook.secret) - assert wh.verify(headers=request.headers, data=request.content) == { - "type": WebhookEvent.response_created, - "timestamp": timestamp, - "data": { - "id": str(response.id), - "values": None, - "status": ResponseStatus.submitted, - "record_id": str(response.record_id), - "user_id": str(response.user_id), - "inserted_at": response.inserted_at.isoformat(), - "updated_at": response.updated_at.isoformat(), - }, - } From a55e884b26d6cbeabc57ba9bd38b618d3dd50313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Mon, 16 Sep 2024 09:51:32 +0200 Subject: [PATCH 03/24] feat: add Webhook expanded events (#5480) # Description This is a PR exploring the possibility of expanding associated resources on webhook event messages. The following changes has been added: * Add webhook event schemas for the following resources: * `Response` * `Record` * `Dataset` * `Workspace` * `User` * Webhook events associated to deletion of resources are sending now a simple `{"id": "id-of-the-deleted-resource"}` to avoid problems with missing resources deleted on cascade. * The model associations are loaded using `awaitable_attrs` to avoid replacing the original resource instance (and avoid a database refresh that could cause losing the state of the resource). This is an example of a `response.created` event with the changes: ```json { "type": "response.created", "version": 1, "timestamp": "2024-09-12T11:52:15.083612Z", "data": { "id": "053a845a-02fd-4951-9884-2317a4a2be19", "values": { "int_score": { "value": 3 } }, "status": "submitted", "record": { "id": "60eadd2c-76ed-4216-870e-9d361ac0a894", "status": "pending", "fields": { "text": "a combination of one or more elementary reaction steps which start with the appropriate reactants and end with the appropriate product(s)\na description of the path, or sequence of steps, by which a reaction occurs\na description of the path that a reaction takes\na detailed description of how a chemical reaction occurs\na detailed description of the way a reaction occurs and is based on the known experimental data about the reaction\na detailed (theoretical) description of how we think the chemical reaction proceeds\na series of elementary reactions or elementary steps that lead from reactants to products\na set of steps at the molecular level\na step by step description of the separate steps that occur during a chemical reaction\na stepwise description of the reaction path\nmechanism. A list of all elementary reactions that occur in the course of an overall chemical reaction.\nIn chemistry, a reaction mechanism is the step by step sequence of elementary reactions by which overall chemical change occurs." }, "metadata": { "dump": "CC-MAIN-2013-20", "url": "http://www.metaglossary.com/meanings/3337605/", "language": "en", "language_score": 0.9414262175559998, "token_count": 195, "score": 3.671875 }, "external_id": "", "dataset": { "id": "408239b6-d100-4eff-b5d9-afcc8e99b9f1", "name": "fineweb-edu-min-submitted-big", "guidelines": null, "allow_extra_metadata": false, "status": "ready", "distribution": { "strategy": "overlap", "min_submitted": 1 }, "workspace": { "id": "350bc020-2cd2-4a67-8b23-37a15c4d8139", "name": "argilla", "inserted_at": "2024-09-05T11:39:20.377192", "updated_at": "2024-09-05T11:39:20.377192" }, "questions": [ { "id": "f6453e3a-5ed2-4853-a8c4-913d03bc1dfb", "name": "int_score", "title": "Rate the quality of the text", "description": null, "required": true, "settings": { "type": "rating", "options": [ { "value": 0 }, { "value": 1 }, { "value": 2 }, { "value": 3 }, { "value": 4 }, { "value": 5 } ] }, "inserted_at": "2024-09-06T10:21:25.482006", "updated_at": "2024-09-06T10:21:25.482006" }, { "id": "4d0b53e6-9b96-42da-a863-2c17d483be01", "name": "comments", "title": "Comments:", "description": null, "required": false, "settings": { "type": "text", "use_markdown": false }, "inserted_at": "2024-09-06T10:21:25.493565", "updated_at": "2024-09-06T10:21:25.493565" } ], "fields": [ { "id": "02ef122a-d989-4422-89ad-e29096a1acfd", "name": "text", "title": "text", "required": true, "settings": { "type": "text", "use_markdown": false }, "inserted_at": "2024-09-06T10:21:25.468310", "updated_at": "2024-09-06T10:21:25.468310" } ], "metadata_properties": [ { "id": "c79d1808-7551-4fb8-b673-b5bb0bec0532", "name": "dump", "title": "dump", "settings": { "type": "terms", "values": null }, "visible_for_annotators": true, "inserted_at": "2024-09-06T10:21:25.506303", "updated_at": "2024-09-06T10:21:25.506303" }, { "id": "1195fc9b-0974-498f-8cff-6feddfd6e9a1", "name": "url", "title": "url", "settings": { "type": "terms", "values": null }, "visible_for_annotators": true, "inserted_at": "2024-09-06T10:21:25.516085", "updated_at": "2024-09-06T10:21:25.516085" }, { "id": "28cc925c-fed6-48bf-a1df-ef43e884b33b", "name": "language", "title": "language", "settings": { "type": "terms", "values": null }, "visible_for_annotators": true, "inserted_at": "2024-09-06T10:21:25.523871", "updated_at": "2024-09-06T10:21:25.523871" }, { "id": "9333a2c9-efaf-4b0e-88e7-b1ed24dfcbfa", "name": "language_score", "title": "language_score", "settings": { "min": null, "max": null, "type": "float" }, "visible_for_annotators": true, "inserted_at": "2024-09-06T10:21:25.532447", "updated_at": "2024-09-06T10:21:25.532447" }, { "id": "9d56b399-3c53-4e98-9d4a-ebfb360ec293", "name": "token_count", "title": "token_count", "settings": { "min": null, "max": null, "type": "integer" }, "visible_for_annotators": true, "inserted_at": "2024-09-06T10:21:25.539531", "updated_at": "2024-09-06T10:21:25.539531" }, { "id": "6c701f7b-de47-4224-b9b0-3629133282ed", "name": "score", "title": "score", "settings": { "min": null, "max": null, "type": "float" }, "visible_for_annotators": true, "inserted_at": "2024-09-06T10:21:25.546505", "updated_at": "2024-09-06T10:21:25.546505" } ], "vectors_settings": [], "last_activity_at": "2024-09-12T11:52:15.030317", "inserted_at": "2024-09-06T10:21:25.443578", "updated_at": "2024-09-12T11:52:04.923388" }, "inserted_at": "2024-09-06T10:21:27.790000", "updated_at": "2024-09-06T10:21:27.790000" }, "user": { "id": "df114042-958d-42c6-9f03-ab49bd451c6c", "first_name": "", "last_name": null, "username": "argilla", "role": "owner", "inserted_at": "2024-09-05T11:39:20.376463", "updated_at": "2024-09-05T11:39:20.376463" }, "inserted_at": "2024-09-12T11:52:15.029023", "updated_at": "2024-09-12T11:52:15.029023" } } ``` There are still missing changes and solutions to explore: - [ ] Review some associations that we can still add to the events. - [ ] Review if some missing endpoints are necessary. - [ ] Should we truncate record field values that are too large? - [ ] For example truncate image field values. Something like `afdsmk432mkfseiwer...(truncated)` - [ ] Update CHANGELOG if necessary. **Type of change** - New feature (non-breaking change which adds functionality) **How Has This Been Tested** - [ ] Manually test the new events on HF spaces. **Checklist** - I added relevant documentation - I followed the style guidelines of this project - I did a self-review of my code - I made corresponding changes to the documentation - I confirm My changes generate no new warnings - I have added tests that prove my fix is effective or that my feature works - I have added relevant notes to the CHANGELOG.md file (See https://keepachangelog.com/) --- argilla-server/.env.dev | 2 + argilla-server/.env.test | 1 + .../api/webhooks/v1/datasets.py | 33 +++- .../api/webhooks/v1/responses.py | 41 ++++- .../argilla_server/api/webhooks/v1/schemas.py | 162 ++++++++++++++++++ .../unit/api/handlers/v1/test_datasets.py | 6 +- 6 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 argilla-server/src/argilla_server/api/webhooks/v1/schemas.py diff --git a/argilla-server/.env.dev b/argilla-server/.env.dev index a542666ee6..76c10523d0 100644 --- a/argilla-server/.env.dev +++ b/argilla-server/.env.dev @@ -1,2 +1,4 @@ +OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES # Needed by RQ to work with forked processes on MacOS ALEMBIC_CONFIG=src/argilla_server/alembic.ini +ARGILLA_AUTH_SECRET_KEY=8VO7na5N/jQx+yP/N+HlE8q51vPdrxqlh6OzoebIyko= # With this we avoid using a different key every time the server is reloaded ARGILLA_DATABASE_URL=sqlite+aiosqlite:///${HOME}/.argilla/argilla.db?check_same_thread=False diff --git a/argilla-server/.env.test b/argilla-server/.env.test index c5d975e485..55d04fe762 100644 --- a/argilla-server/.env.test +++ b/argilla-server/.env.test @@ -1 +1,2 @@ ARGILLA_DATABASE_URL=sqlite+aiosqlite:///${HOME}/.argilla/argilla-test.db?check_same_thread=False +ARGILLA_REDIS_URL=redis://localhost:6379/1 # Using a different Redis database for testing diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/datasets.py b/argilla-server/src/argilla_server/api/webhooks/v1/datasets.py index fcd316e422..168e9f7a7f 100644 --- a/argilla-server/src/argilla_server/api/webhooks/v1/datasets.py +++ b/argilla-server/src/argilla_server/api/webhooks/v1/datasets.py @@ -16,18 +16,47 @@ from datetime import datetime from rq.job import Job +from sqlalchemy import select +from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession from argilla_server.models import Dataset from argilla_server.jobs.webhook_jobs import enqueue_notify_events -from argilla_server.api.schemas.v1.datasets import Dataset as DatasetSchema +from argilla_server.api.webhooks.v1.schemas import DatasetEventSchema from argilla_server.api.webhooks.v1.enums import DatasetEvent async def notify_dataset_event(db: AsyncSession, dataset_event: DatasetEvent, dataset: Dataset) -> List[Job]: + if dataset_event == DatasetEvent.deleted: + return await _notify_dataset_deleted_event(db, dataset) + + # NOTE: Force loading required association resources required by the event schema + ( + await db.execute( + select(Dataset) + .where(Dataset.id == dataset.id) + .options( + selectinload(Dataset.workspace), + selectinload(Dataset.questions), + selectinload(Dataset.fields), + selectinload(Dataset.metadata_properties), + selectinload(Dataset.vectors_settings), + ) + ) + ).scalar_one() + return await enqueue_notify_events( db, event=dataset_event, timestamp=datetime.utcnow(), - data=DatasetSchema.from_orm(dataset).dict(), + data=DatasetEventSchema.from_orm(dataset).dict(), + ) + + +async def _notify_dataset_deleted_event(db: AsyncSession, dataset: Dataset) -> List[Job]: + return await enqueue_notify_events( + db, + event=DatasetEvent.deleted, + timestamp=datetime.utcnow(), + data={"id": dataset.id}, ) diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/responses.py b/argilla-server/src/argilla_server/api/webhooks/v1/responses.py index 1d5792f895..97f451da11 100644 --- a/argilla-server/src/argilla_server/api/webhooks/v1/responses.py +++ b/argilla-server/src/argilla_server/api/webhooks/v1/responses.py @@ -15,19 +15,54 @@ from typing import List from datetime import datetime +from sqlalchemy import select +from sqlalchemy.orm import selectinload + from rq.job import Job from sqlalchemy.ext.asyncio import AsyncSession -from argilla_server.models import Response +from argilla_server.models import Response, Record, Dataset from argilla_server.jobs.webhook_jobs import enqueue_notify_events -from argilla_server.api.schemas.v1.responses import Response as ResponseSchema +from argilla_server.api.webhooks.v1.schemas import ResponseEventSchema from argilla_server.api.webhooks.v1.enums import ResponseEvent async def notify_response_event(db: AsyncSession, response_event: ResponseEvent, response: Response) -> List[Job]: + if response_event == ResponseEvent.deleted: + return await _notify_response_deleted_event(db, response) + + # NOTE: Force loading required association resources required by the event schema + ( + await db.execute( + select(Response) + .where(Response.id == response.id) + .options( + selectinload(Response.user), + selectinload(Response.record).options( + selectinload(Record.dataset).options( + selectinload(Dataset.workspace), + selectinload(Dataset.questions), + selectinload(Dataset.fields), + selectinload(Dataset.metadata_properties), + selectinload(Dataset.vectors_settings), + ), + ), + ), + ) + ).scalar_one() + return await enqueue_notify_events( db, event=response_event, timestamp=datetime.utcnow(), - data=ResponseSchema.from_orm(response).dict(), + data=ResponseEventSchema.from_orm(response).dict(), + ) + + +async def _notify_response_deleted_event(db: AsyncSession, response: Response) -> List[Job]: + return await enqueue_notify_events( + db, + event=ResponseEvent.deleted, + timestamp=datetime.utcnow(), + data={"id": response.id}, ) diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/schemas.py b/argilla-server/src/argilla_server/api/webhooks/v1/schemas.py new file mode 100644 index 0000000000..14df8a6d0d --- /dev/null +++ b/argilla-server/src/argilla_server/api/webhooks/v1/schemas.py @@ -0,0 +1,162 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from uuid import UUID +from typing import Optional, List +from datetime import datetime + +from argilla_server.pydantic_v1 import BaseModel, Field + + +class UserEventSchema(BaseModel): + id: UUID + first_name: str + last_name: Optional[str] + username: str + role: str + inserted_at: datetime + updated_at: datetime + + class Config: + orm_mode = True + + +class WorkspaceEventSchema(BaseModel): + id: UUID + name: str + inserted_at: datetime + updated_at: datetime + + class Config: + orm_mode = True + + +class DatasetQuestionEventSchema(BaseModel): + id: UUID + name: str + title: str + description: Optional[str] + required: bool + settings: dict + # dataset_id: UUID + inserted_at: datetime + updated_at: datetime + + class Config: + orm_mode = True + + +class DatasetFieldEventSchema(BaseModel): + id: UUID + name: str + title: str + required: bool + settings: dict + # dataset_id: UUID + inserted_at: datetime + updated_at: datetime + + class Config: + orm_mode = True + + +class DatasetMetadataPropertyEventSchema(BaseModel): + id: UUID + name: str + title: str + settings: dict + visible_for_annotators: bool + # dataset_id: UUID + inserted_at: datetime + updated_at: datetime + + class Config: + orm_mode = True + + +class DatasetVectorSettingsEventSchema(BaseModel): + id: UUID + name: str + title: str + dimensions: int + # dataset_id: UUID + inserted_at: datetime + updated_at: datetime + + class Config: + orm_mode = True + + +class DatasetEventSchema(BaseModel): + id: UUID + name: str + guidelines: Optional[str] + allow_extra_metadata: bool + status: str + distribution: dict + workspace: WorkspaceEventSchema + questions: List[DatasetQuestionEventSchema] + fields: List[DatasetFieldEventSchema] + metadata_properties: List[DatasetMetadataPropertyEventSchema] + vectors_settings: List[DatasetVectorSettingsEventSchema] + last_activity_at: datetime + inserted_at: datetime + updated_at: datetime + + class Config: + orm_mode = True + + +class RecordEventSchema(BaseModel): + id: UUID + status: str + # TODO: Truncate fields so we don't respond with big field values. + # Or find another possible solution. + fields: dict + metadata: Optional[dict] = Field(None, alias="metadata_") + external_id: Optional[str] + # responses: + # - Create a new `GET /api/v1/records/{record_id}/responses` endpoint. + # - Or use `/api/v1/records/{record_id}` endpoint. + # - Other possible alternative is to expand the responses here but using + # a RecordResponseEventSchema not including the record inside. + # suggestions: + # - Can use `GET /api/v1/records/{record_id}/suggestions` endpoint. + # - Or use `/api/v1/records/{record_id}` endpoint. + # - Other possible alternative is to expand the suggestions here but using + # a RecordSuggestionEventSchema not including the record inside. + # vectors: + # - Create a new `GET /api/v1/records/{record_id}/vectors` endpoint. + # - Or use `/api/v1/records/{record_id}` endpoint. + # - Other possible alternative is to expand the vectors here but using + # a RecordVectorEventSchema not including the record inside. + dataset: DatasetEventSchema + inserted_at: datetime + updated_at: datetime + + class Config: + orm_mode = True + + +class ResponseEventSchema(BaseModel): + id: UUID + values: Optional[dict] + status: str + record: RecordEventSchema + user: UserEventSchema + inserted_at: datetime + updated_at: datetime + + class Config: + orm_mode = True diff --git a/argilla-server/tests/unit/api/handlers/v1/test_datasets.py b/argilla-server/tests/unit/api/handlers/v1/test_datasets.py index 3e021472f5..37b2cf4ce0 100644 --- a/argilla-server/tests/unit/api/handlers/v1/test_datasets.py +++ b/argilla-server/tests/unit/api/handlers/v1/test_datasets.py @@ -4595,7 +4595,8 @@ async def test_publish_dataset_as_admin(self, async_client: "AsyncClient", db: " admin = await AdminFactory.create(workspaces=[dataset.workspace]) response = await async_client.put( - f"/api/v1/datasets/{dataset.id}/publish", headers={API_KEY_HEADER_NAME: admin.api_key} + f"/api/v1/datasets/{dataset.id}/publish", + headers={API_KEY_HEADER_NAME: admin.api_key}, ) assert response.status_code == 200 @@ -4895,7 +4896,8 @@ async def test_delete_dataset_as_admin(self, async_client: "AsyncClient", db: "A admin = await AdminFactory.create(workspaces=[dataset.workspace]) response = await async_client.delete( - f"/api/v1/datasets/{dataset.id}", headers={API_KEY_HEADER_NAME: admin.api_key} + f"/api/v1/datasets/{dataset.id}", + headers={API_KEY_HEADER_NAME: admin.api_key}, ) assert response.status_code == 200 From bea6eb03ed742b677f316713a82174eb1eeb2b25 Mon Sep 17 00:00:00 2001 From: Paco Aranda Date: Mon, 16 Sep 2024 12:23:10 +0200 Subject: [PATCH 04/24] [ENHANCEMENT] add record related webhook events (#5489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description This PR adds record-related webhook events: `created`, `updated`, `deleted` and `completed` **Type of change** - Improvement (change adding some improvement to an existing functionality) **How Has This Been Tested** **Checklist** - I added relevant documentation - I followed the style guidelines of this project - I did a self-review of my code - I made corresponding changes to the documentation - I confirm My changes generate no new warnings - I have added tests that prove my fix is effective or that my feature works - I have added relevant notes to the CHANGELOG.md file (See https://keepachangelog.com/) --------- Co-authored-by: José Francisco Calvo --- argilla-server/CHANGELOG.md | 1 + .../argilla_server/api/webhooks/v1/enums.py | 15 +++++ .../argilla_server/api/webhooks/v1/records.py | 61 +++++++++++++++++++ .../src/argilla_server/bulk/records_bulk.py | 14 +++++ .../src/argilla_server/contexts/datasets.py | 10 ++- .../argilla_server/contexts/distribution.py | 7 +++ .../src/argilla_server/models/database.py | 3 + 7 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 argilla-server/src/argilla_server/api/webhooks/v1/records.py diff --git a/argilla-server/CHANGELOG.md b/argilla-server/CHANGELOG.md index 4994606719..ba82c5eb7f 100644 --- a/argilla-server/CHANGELOG.md +++ b/argilla-server/CHANGELOG.md @@ -23,6 +23,7 @@ These are the section headers that we use: - Added new endpoints to create, update, ping and delete webhooks. ([#5453](https://github.com/argilla-io/argilla/pull/5453)) - Added new webhook events when responses are created, updated, deleted or upserted. ([#5468](https://github.com/argilla-io/argilla/pull/5468)) - Added new webhook events when datasets are created, updated, deleted or published. ([#5468](https://github.com/argilla-io/argilla/pull/5468)) +- Added new webhook events when records are created, updated, deleted or completed. ([#5489](https://github.com/argilla-io/argilla/pull/5489)) ### Fixed diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/enums.py b/argilla-server/src/argilla_server/api/webhooks/v1/enums.py index 76f9be4edd..b5c396be91 100644 --- a/argilla-server/src/argilla_server/api/webhooks/v1/enums.py +++ b/argilla-server/src/argilla_server/api/webhooks/v1/enums.py @@ -26,6 +26,11 @@ class WebhookEvent(str, Enum): response_deleted = "response.deleted" response_upserted = "response.upserted" + record_created = "record.created" + record_updated = "record.updated" + record_deleted = "record.deleted" + record_completed = "record.completed" + def __str__(self): return str(self.value) @@ -48,3 +53,13 @@ class ResponseEvent(str, Enum): def __str__(self): return str(self.value) + + +class RecordEvent(str, Enum): + created = WebhookEvent.record_created.value + updated = WebhookEvent.record_updated.value + deleted = WebhookEvent.record_deleted.value + completed = WebhookEvent.record_completed.value + + def __str__(self): + return str(self.value) diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/records.py b/argilla-server/src/argilla_server/api/webhooks/v1/records.py new file mode 100644 index 0000000000..34b4db4d18 --- /dev/null +++ b/argilla-server/src/argilla_server/api/webhooks/v1/records.py @@ -0,0 +1,61 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime +from typing import List + +from rq.job import Job +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from argilla_server.api.webhooks.v1.enums import RecordEvent +from argilla_server.api.webhooks.v1.schemas import RecordEventSchema +from argilla_server.jobs.webhook_jobs import enqueue_notify_events +from argilla_server.models import Record, Dataset + + +async def notify_record_event(db: AsyncSession, record_event: RecordEvent, record: Record) -> List[Job]: + if record_event == RecordEvent.deleted: + return await _notify_record_deleted_event(db, record) + + ( + await db.execute( + select(Dataset) + .where(Dataset.id == record.dataset_id) + .options( + selectinload(Dataset.workspace), + selectinload(Dataset.fields), + selectinload(Dataset.questions), + selectinload(Dataset.metadata_properties), + selectinload(Dataset.vectors_settings), + ) + ) + ).scalar_one() + + return await enqueue_notify_events( + db, + event=record_event, + timestamp=datetime.utcnow(), + data=RecordEventSchema.from_orm(record).dict(), + ) + + +async def _notify_record_deleted_event(db: AsyncSession, record: Record) -> List[Job]: + return await enqueue_notify_events( + db, + event=RecordEvent.deleted, + timestamp=datetime.utcnow(), + data={"id": record.id}, + ) diff --git a/argilla-server/src/argilla_server/bulk/records_bulk.py b/argilla-server/src/argilla_server/bulk/records_bulk.py index d1274b289e..e3e1155e72 100644 --- a/argilla-server/src/argilla_server/bulk/records_bulk.py +++ b/argilla-server/src/argilla_server/bulk/records_bulk.py @@ -29,6 +29,8 @@ ) from argilla_server.api.schemas.v1.responses import UserResponseCreate from argilla_server.api.schemas.v1.suggestions import SuggestionCreate +from argilla_server.api.webhooks.v1.enums import RecordEvent +from argilla_server.api.webhooks.v1.records import notify_record_event as notify_record_event_v1 from argilla_server.contexts import distribution from argilla_server.contexts.accounts import fetch_users_by_ids_as_dict from argilla_server.contexts.records import ( @@ -73,6 +75,9 @@ async def create_records_bulk(self, dataset: Dataset, bulk_create: RecordsBulkCr await self._db.commit() + for record in records: + await notify_record_event_v1(self._db, RecordEvent.created, record) + return RecordsBulk(items=records) async def _upsert_records_relationships(self, records: List[Record], records_create: List[RecordCreate]) -> None: @@ -214,6 +219,8 @@ async def upsert_records_bulk(self, dataset: Dataset, bulk_upsert: RecordsBulkUp await self._db.commit() + await self._notify_record_events(records) + return RecordsBulkWithUpdateInfo( items=records, updated_item_ids=[record.id for record in found_records.values()], @@ -233,6 +240,13 @@ async def _fetch_existing_dataset_records( return {**records_by_external_id, **records_by_id} + async def _notify_record_events(self, records: List[Record]) -> None: + for record in records: + if record.inserted_at == record.updated_at: + await notify_record_event_v1(self._db, RecordEvent.created, record) + else: + await notify_record_event_v1(self._db, RecordEvent.updated, record) + async def _preload_records_relationships_before_index(db: "AsyncSession", records: Sequence[Record]) -> None: await db.execute( diff --git a/argilla-server/src/argilla_server/contexts/datasets.py b/argilla-server/src/argilla_server/contexts/datasets.py index e53cbd5a73..690acc8c48 100644 --- a/argilla-server/src/argilla_server/contexts/datasets.py +++ b/argilla-server/src/argilla_server/contexts/datasets.py @@ -60,7 +60,8 @@ VectorSettingsCreate, ) from argilla_server.api.schemas.v1.vectors import Vector as VectorSchema -from argilla_server.api.webhooks.v1.enums import DatasetEvent, ResponseEvent +from argilla_server.api.webhooks.v1.enums import DatasetEvent, ResponseEvent, RecordEvent +from argilla_server.api.webhooks.v1.records import notify_record_event as notify_record_event_v1 from argilla_server.api.webhooks.v1.responses import notify_response_event as notify_response_event_v1 from argilla_server.api.webhooks.v1.datasets import notify_dataset_event as notify_dataset_event_v1 from argilla_server.contexts import accounts, distribution @@ -808,6 +809,9 @@ async def delete_records( await db.commit() + for record in records: + await notify_record_event_v1(db, RecordEvent.deleted, record) + async def update_record( db: AsyncSession, search_engine: "SearchEngine", record: Record, record_update: "RecordUpdate" @@ -837,6 +841,8 @@ async def update_record( await db.commit() + await notify_record_event_v1(db, RecordEvent.updated, record) + return record @@ -847,6 +853,8 @@ async def delete_record(db: AsyncSession, search_engine: "SearchEngine", record: await db.commit() + await notify_record_event_v1(db, RecordEvent.deleted, record) + return record diff --git a/argilla-server/src/argilla_server/contexts/distribution.py b/argilla-server/src/argilla_server/contexts/distribution.py index b4290b6482..410c375178 100644 --- a/argilla-server/src/argilla_server/contexts/distribution.py +++ b/argilla-server/src/argilla_server/contexts/distribution.py @@ -21,6 +21,8 @@ from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession +from argilla_server.api.webhooks.v1.enums import RecordEvent +from argilla_server.api.webhooks.v1.records import notify_record_event as notify_record_event_v1 from argilla_server.enums import DatasetDistributionStrategy, RecordStatus from argilla_server.models import Record from argilla_server.search_engine.base import SearchEngine @@ -51,6 +53,11 @@ async def update_record_status(search_engine: SearchEngine, record_id: UUID) -> await db.commit() + await notify_record_event_v1(db, RecordEvent.updated, record) + + if record.is_completed(): + await notify_record_event_v1(db, RecordEvent.completed, record) + return record diff --git a/argilla-server/src/argilla_server/models/database.py b/argilla-server/src/argilla_server/models/database.py index 11e2a17cc6..a69e6330ee 100644 --- a/argilla-server/src/argilla_server/models/database.py +++ b/argilla-server/src/argilla_server/models/database.py @@ -237,6 +237,9 @@ class Record(DatabaseModel): __table_args__ = (UniqueConstraint("external_id", "dataset_id", name="record_external_id_dataset_id_uq"),) + def is_completed(self) -> bool: + return self.status == RecordStatus.completed + def vector_value_by_vector_settings(self, vector_settings: "VectorSettings") -> Union[List[float], None]: for vector in self.vectors: if vector.vector_settings_id == vector_settings.id: From 48a0dfe39a8a24a27e3483572ec19342e44184d5 Mon Sep 17 00:00:00 2001 From: Paco Aranda Date: Mon, 16 Sep 2024 13:56:52 +0200 Subject: [PATCH 05/24] [ENHANCEMENT] `argilla`: expose webhooks API (#5490) # Description This PR exposes the new webhooks API through the low-level `client.api` component. Once the backend API and SDK flows are validated, we can work on exposing webhooks as client resource level **Type of change** - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) - Breaking change (fix or feature that would cause existing functionality to not work as expected) - Refactor (change restructuring the codebase without changing functionality) - Improvement (change adding some improvement to an existing functionality) - Documentation update **How Has This Been Tested** **Checklist** - I added relevant documentation - I followed the style guidelines of this project - I did a self-review of my code - I made corresponding changes to the documentation - I confirm My changes generate no new warnings - I have added tests that prove my fix is effective or that my feature works - I have added relevant notes to the CHANGELOG.md file (See https://keepachangelog.com/) --- argilla/src/argilla/_api/_client.py | 14 ++- argilla/src/argilla/_api/_webhooks.py | 137 ++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 argilla/src/argilla/_api/_webhooks.py diff --git a/argilla/src/argilla/_api/_client.py b/argilla/src/argilla/_api/_client.py index af53e157af..c8aed6f3ee 100644 --- a/argilla/src/argilla/_api/_client.py +++ b/argilla/src/argilla/_api/_client.py @@ -17,6 +17,8 @@ from typing import Optional import httpx + +from argilla._api._webhooks import WebhooksAPI from argilla._exceptions._api import UnauthorizedError from argilla._exceptions._client import ArgillaCredentialsError @@ -46,15 +48,19 @@ class ArgillaAPI: def __init__(self, http_client: httpx.Client): self.http_client = http_client + self.__users = UsersAPI(http_client=self.http_client) self.__workspaces = WorkspacesAPI(http_client=self.http_client) + self.__datasets = DatasetsAPI(http_client=self.http_client) - self.__users = UsersAPI(http_client=self.http_client) self.__fields = FieldsAPI(http_client=self.http_client) self.__questions = QuestionsAPI(http_client=self.http_client) - self.__records = RecordsAPI(http_client=self.http_client) self.__vectors = VectorsAPI(http_client=self.http_client) self.__metadata = MetadataAPI(http_client=self.http_client) + self.__records = RecordsAPI(http_client=self.http_client) + + self.__webhooks = WebhooksAPI(http_client=self.http_client) + @property def workspaces(self) -> "WorkspacesAPI": return self.__workspaces @@ -87,6 +93,10 @@ def vectors(self) -> "VectorsAPI": def metadata(self) -> "MetadataAPI": return self.__metadata + @property + def webhooks(self) -> "WebhooksAPI": + return self.__webhooks + class APIClient: """Initialize the SDK with the given API URL and API key. diff --git a/argilla/src/argilla/_api/_webhooks.py b/argilla/src/argilla/_api/_webhooks.py new file mode 100644 index 0000000000..f09ef800fe --- /dev/null +++ b/argilla/src/argilla/_api/_webhooks.py @@ -0,0 +1,137 @@ +# Copyright 2024-present, Argilla, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__all__ = ["WebhooksAPI"] + +from typing import List, Optional + +import httpx +from pydantic import ConfigDict, Field + +from argilla._api._base import ResourceAPI +from argilla._exceptions import api_error_handler +from argilla._models import ResourceModel + + +class WebhookModel(ResourceModel): + url: str + events: List[str] + enabled: bool = True + description: Optional[str] = None + + secret: Optional[str] = Field(None, description="Webhook secret. Read-only.") + + model_config = ConfigDict( + validate_assignment=True, + str_strip_whitespace=True, + ) + + +class WebhooksAPI(ResourceAPI[WebhookModel]): + http_client: httpx.Client + url_stub = "/api/v1/webhooks" + + @api_error_handler + def list(self) -> List[WebhookModel]: + """ + Get a list of all webhooks + + Returns: + List[WebhookModel]: List of webhooks + + """ + response = self.http_client.get(url=self.url_stub) + response.raise_for_status() + response_json = response.json() + webhooks = self._model_from_jsons(json_data=response_json["items"]) + self._log_message(message=f"Got {len(webhooks)} webhooks") + return webhooks + + @api_error_handler + def create(self, webhook: WebhookModel) -> WebhookModel: + """ + Create a webhook + + Args: + webhook (WebhookModel): Webhook to create + + Returns: + WebhookModel: Created webhook + + """ + response = self.http_client.post( + url=self.url_stub, + json={ + "url": webhook.url, + "events": webhook.events, + "description": webhook.description, + }, + ) + response.raise_for_status() + response_json = response.json() + webhook = self._model_from_json(json_data=response_json) + self._log_message(message=f"Created webhook with id {webhook.id}") + return webhook + + @api_error_handler + def delete(self, webhook_id: str) -> None: + """ + Delete a webhook + + Args: + webhook_id (str): ID of the webhook to delete + + """ + response = self.http_client.delete(url=f"{self.url_stub}/{webhook_id}") + response.raise_for_status() + self._log_message(message=f"Deleted webhook with id {webhook_id}") + + @api_error_handler + def update(self, webhook: WebhookModel) -> WebhookModel: + """ + Update a webhook + + Args: + webhook (WebhookModel): Webhook to update + + Returns: + WebhookModel: Updated webhook + + """ + response = self.http_client.patch(url=f"{self.url_stub}/{webhook.id}", json=webhook.model_dump()) + response.raise_for_status() + response_json = response.json() + webhook = self._model_from_json(json_data=response_json) + self._log_message(message=f"Updated webhook with id {webhook.id}") + return webhook + + @api_error_handler + def ping(self, webhook_id: str) -> None: + """ + Ping a webhook + + Args: + webhook_id (str): ID of the webhook to ping + + """ + response = self.http_client.post(url=f"{self.url_stub}/{webhook_id}/ping") + response.raise_for_status() + self._log_message(message=f"Pinged webhook with id {webhook_id}") + + @staticmethod + def _model_from_json(json_data: dict) -> WebhookModel: + return WebhookModel.model_validate(json_data) + + def _model_from_jsons(self, json_data: List[dict]) -> List[WebhookModel]: + return list(map(self._model_from_json, json_data)) From 3fb3d6663c7768c7c7a6c86b41eb758f82395177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Mon, 16 Sep 2024 16:14:34 +0200 Subject: [PATCH 06/24] chore: move webhooks folder outside api folder (#5493) # Description As discussed this PR is moving `webhooks` folder outside `api` folder. Now that we have specific schemas for webhooks it does not have much sense to have this code inside the `api` folder. Refs #1836 **Type of change** - Refactor (change restructuring the codebase without changing functionality) **How Has This Been Tested** - [x] Running test suite. **Checklist** - I added relevant documentation - I followed the style guidelines of this project - I did a self-review of my code - I made corresponding changes to the documentation - I confirm My changes generate no new warnings - I have added tests that prove my fix is effective or that my feature works - I have added relevant notes to the CHANGELOG.md file (See https://keepachangelog.com/) --- .../src/argilla_server/api/handlers/v1/webhooks.py | 2 +- .../src/argilla_server/api/schemas/v1/webhooks.py | 2 +- argilla-server/src/argilla_server/bulk/records_bulk.py | 4 ++-- argilla-server/src/argilla_server/contexts/datasets.py | 8 ++++---- .../src/argilla_server/contexts/distribution.py | 4 ++-- argilla-server/src/argilla_server/jobs/webhook_jobs.py | 2 +- .../src/argilla_server/{api => }/webhooks/__init__.py | 0 .../src/argilla_server/{api => }/webhooks/v1/__init__.py | 0 .../src/argilla_server/{api => }/webhooks/v1/commons.py | 0 .../src/argilla_server/{api => }/webhooks/v1/datasets.py | 4 ++-- .../src/argilla_server/{api => }/webhooks/v1/enums.py | 0 .../src/argilla_server/{api => }/webhooks/v1/ping.py | 4 ++-- .../src/argilla_server/{api => }/webhooks/v1/records.py | 4 ++-- .../src/argilla_server/{api => }/webhooks/v1/responses.py | 4 ++-- .../src/argilla_server/{api => }/webhooks/v1/schemas.py | 0 argilla-server/tests/factories.py | 2 +- .../unit/api/handlers/v1/webhooks/test_create_webhook.py | 2 +- .../unit/api/handlers/v1/webhooks/test_delete_webhook.py | 2 +- .../unit/api/handlers/v1/webhooks/test_list_webhooks.py | 2 +- .../unit/api/handlers/v1/webhooks/test_update_webhook.py | 2 +- argilla-server/tests/unit/{api => }/webhooks/__init__.py | 0 .../tests/unit/{api => }/webhooks/v1/__init__.py | 0 .../unit/{api => }/webhooks/v1/test_notify_ping_event.py | 4 ++-- 23 files changed, 26 insertions(+), 26 deletions(-) rename argilla-server/src/argilla_server/{api => }/webhooks/__init__.py (100%) rename argilla-server/src/argilla_server/{api => }/webhooks/v1/__init__.py (100%) rename argilla-server/src/argilla_server/{api => }/webhooks/v1/commons.py (100%) rename argilla-server/src/argilla_server/{api => }/webhooks/v1/datasets.py (94%) rename argilla-server/src/argilla_server/{api => }/webhooks/v1/enums.py (100%) rename argilla-server/src/argilla_server/{api => }/webhooks/v1/ping.py (89%) rename argilla-server/src/argilla_server/{api => }/webhooks/v1/records.py (93%) rename argilla-server/src/argilla_server/{api => }/webhooks/v1/responses.py (94%) rename argilla-server/src/argilla_server/{api => }/webhooks/v1/schemas.py (100%) rename argilla-server/tests/unit/{api => }/webhooks/__init__.py (100%) rename argilla-server/tests/unit/{api => }/webhooks/v1/__init__.py (100%) rename argilla-server/tests/unit/{api => }/webhooks/v1/test_notify_ping_event.py (92%) diff --git a/argilla-server/src/argilla_server/api/handlers/v1/webhooks.py b/argilla-server/src/argilla_server/api/handlers/v1/webhooks.py index 49c99d1963..54513dbc04 100644 --- a/argilla-server/src/argilla_server/api/handlers/v1/webhooks.py +++ b/argilla-server/src/argilla_server/api/handlers/v1/webhooks.py @@ -18,7 +18,7 @@ from argilla_server.database import get_async_db from argilla_server.api.policies.v1 import WebhookPolicy, authorize -from argilla_server.api.webhooks.v1.ping import notify_ping_event +from argilla_server.webhooks.v1.ping import notify_ping_event from argilla_server.security import auth from argilla_server.models import User from argilla_server.api.schemas.v1.webhooks import ( diff --git a/argilla-server/src/argilla_server/api/schemas/v1/webhooks.py b/argilla-server/src/argilla_server/api/schemas/v1/webhooks.py index ac75525b22..a093b7f26a 100644 --- a/argilla-server/src/argilla_server/api/schemas/v1/webhooks.py +++ b/argilla-server/src/argilla_server/api/schemas/v1/webhooks.py @@ -16,7 +16,7 @@ from typing import List, Optional from uuid import UUID -from argilla_server.api.webhooks.v1.enums import WebhookEvent +from argilla_server.webhooks.v1.enums import WebhookEvent from argilla_server.api.schemas.v1.commons import UpdateSchema from argilla_server.pydantic_v1 import BaseModel, Field, HttpUrl diff --git a/argilla-server/src/argilla_server/bulk/records_bulk.py b/argilla-server/src/argilla_server/bulk/records_bulk.py index e3e1155e72..6edbed7265 100644 --- a/argilla-server/src/argilla_server/bulk/records_bulk.py +++ b/argilla-server/src/argilla_server/bulk/records_bulk.py @@ -29,8 +29,8 @@ ) from argilla_server.api.schemas.v1.responses import UserResponseCreate from argilla_server.api.schemas.v1.suggestions import SuggestionCreate -from argilla_server.api.webhooks.v1.enums import RecordEvent -from argilla_server.api.webhooks.v1.records import notify_record_event as notify_record_event_v1 +from argilla_server.webhooks.v1.enums import RecordEvent +from argilla_server.webhooks.v1.records import notify_record_event as notify_record_event_v1 from argilla_server.contexts import distribution from argilla_server.contexts.accounts import fetch_users_by_ids_as_dict from argilla_server.contexts.records import ( diff --git a/argilla-server/src/argilla_server/contexts/datasets.py b/argilla-server/src/argilla_server/contexts/datasets.py index 690acc8c48..b49dd72477 100644 --- a/argilla-server/src/argilla_server/contexts/datasets.py +++ b/argilla-server/src/argilla_server/contexts/datasets.py @@ -60,10 +60,10 @@ VectorSettingsCreate, ) from argilla_server.api.schemas.v1.vectors import Vector as VectorSchema -from argilla_server.api.webhooks.v1.enums import DatasetEvent, ResponseEvent, RecordEvent -from argilla_server.api.webhooks.v1.records import notify_record_event as notify_record_event_v1 -from argilla_server.api.webhooks.v1.responses import notify_response_event as notify_response_event_v1 -from argilla_server.api.webhooks.v1.datasets import notify_dataset_event as notify_dataset_event_v1 +from argilla_server.webhooks.v1.enums import DatasetEvent, ResponseEvent, RecordEvent +from argilla_server.webhooks.v1.records import notify_record_event as notify_record_event_v1 +from argilla_server.webhooks.v1.responses import notify_response_event as notify_response_event_v1 +from argilla_server.webhooks.v1.datasets import notify_dataset_event as notify_dataset_event_v1 from argilla_server.contexts import accounts, distribution from argilla_server.database import get_async_db from argilla_server.enums import DatasetStatus, UserRole, RecordStatus diff --git a/argilla-server/src/argilla_server/contexts/distribution.py b/argilla-server/src/argilla_server/contexts/distribution.py index 410c375178..0073fe8160 100644 --- a/argilla-server/src/argilla_server/contexts/distribution.py +++ b/argilla-server/src/argilla_server/contexts/distribution.py @@ -21,8 +21,8 @@ from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession -from argilla_server.api.webhooks.v1.enums import RecordEvent -from argilla_server.api.webhooks.v1.records import notify_record_event as notify_record_event_v1 +from argilla_server.webhooks.v1.enums import RecordEvent +from argilla_server.webhooks.v1.records import notify_record_event as notify_record_event_v1 from argilla_server.enums import DatasetDistributionStrategy, RecordStatus from argilla_server.models import Record from argilla_server.search_engine.base import SearchEngine diff --git a/argilla-server/src/argilla_server/jobs/webhook_jobs.py b/argilla-server/src/argilla_server/jobs/webhook_jobs.py index a8c054cad4..6bc24a417d 100644 --- a/argilla-server/src/argilla_server/jobs/webhook_jobs.py +++ b/argilla-server/src/argilla_server/jobs/webhook_jobs.py @@ -24,7 +24,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from fastapi.encoders import jsonable_encoder -from argilla_server.api.webhooks.v1.commons import notify_event +from argilla_server.webhooks.v1.commons import notify_event from argilla_server.database import AsyncSessionLocal from argilla_server.jobs.queues import HIGH_QUEUE from argilla_server.contexts import webhooks diff --git a/argilla-server/src/argilla_server/api/webhooks/__init__.py b/argilla-server/src/argilla_server/webhooks/__init__.py similarity index 100% rename from argilla-server/src/argilla_server/api/webhooks/__init__.py rename to argilla-server/src/argilla_server/webhooks/__init__.py diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/__init__.py b/argilla-server/src/argilla_server/webhooks/v1/__init__.py similarity index 100% rename from argilla-server/src/argilla_server/api/webhooks/v1/__init__.py rename to argilla-server/src/argilla_server/webhooks/v1/__init__.py diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/commons.py b/argilla-server/src/argilla_server/webhooks/v1/commons.py similarity index 100% rename from argilla-server/src/argilla_server/api/webhooks/v1/commons.py rename to argilla-server/src/argilla_server/webhooks/v1/commons.py diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/datasets.py b/argilla-server/src/argilla_server/webhooks/v1/datasets.py similarity index 94% rename from argilla-server/src/argilla_server/api/webhooks/v1/datasets.py rename to argilla-server/src/argilla_server/webhooks/v1/datasets.py index 168e9f7a7f..7df7c257b4 100644 --- a/argilla-server/src/argilla_server/api/webhooks/v1/datasets.py +++ b/argilla-server/src/argilla_server/webhooks/v1/datasets.py @@ -22,8 +22,8 @@ from argilla_server.models import Dataset from argilla_server.jobs.webhook_jobs import enqueue_notify_events -from argilla_server.api.webhooks.v1.schemas import DatasetEventSchema -from argilla_server.api.webhooks.v1.enums import DatasetEvent +from argilla_server.webhooks.v1.schemas import DatasetEventSchema +from argilla_server.webhooks.v1.enums import DatasetEvent async def notify_dataset_event(db: AsyncSession, dataset_event: DatasetEvent, dataset: Dataset) -> List[Job]: diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/enums.py b/argilla-server/src/argilla_server/webhooks/v1/enums.py similarity index 100% rename from argilla-server/src/argilla_server/api/webhooks/v1/enums.py rename to argilla-server/src/argilla_server/webhooks/v1/enums.py diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/ping.py b/argilla-server/src/argilla_server/webhooks/v1/ping.py similarity index 89% rename from argilla-server/src/argilla_server/api/webhooks/v1/ping.py rename to argilla-server/src/argilla_server/webhooks/v1/ping.py index f4b2a0bf50..caea2bf287 100644 --- a/argilla-server/src/argilla_server/api/webhooks/v1/ping.py +++ b/argilla-server/src/argilla_server/webhooks/v1/ping.py @@ -18,8 +18,8 @@ from argilla_server.models import Webhook from argilla_server.contexts import info -from argilla_server.api.webhooks.v1.commons import notify_event -from argilla_server.api.webhooks.v1.enums import WebhookEvent +from argilla_server.webhooks.v1.commons import notify_event +from argilla_server.webhooks.v1.enums import WebhookEvent def notify_ping_event(webhook: Webhook) -> httpx.Response: diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/records.py b/argilla-server/src/argilla_server/webhooks/v1/records.py similarity index 93% rename from argilla-server/src/argilla_server/api/webhooks/v1/records.py rename to argilla-server/src/argilla_server/webhooks/v1/records.py index 34b4db4d18..b60d95d884 100644 --- a/argilla-server/src/argilla_server/api/webhooks/v1/records.py +++ b/argilla-server/src/argilla_server/webhooks/v1/records.py @@ -20,8 +20,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from argilla_server.api.webhooks.v1.enums import RecordEvent -from argilla_server.api.webhooks.v1.schemas import RecordEventSchema +from argilla_server.webhooks.v1.enums import RecordEvent +from argilla_server.webhooks.v1.schemas import RecordEventSchema from argilla_server.jobs.webhook_jobs import enqueue_notify_events from argilla_server.models import Record, Dataset diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/responses.py b/argilla-server/src/argilla_server/webhooks/v1/responses.py similarity index 94% rename from argilla-server/src/argilla_server/api/webhooks/v1/responses.py rename to argilla-server/src/argilla_server/webhooks/v1/responses.py index 97f451da11..a4048094c0 100644 --- a/argilla-server/src/argilla_server/api/webhooks/v1/responses.py +++ b/argilla-server/src/argilla_server/webhooks/v1/responses.py @@ -23,8 +23,8 @@ from argilla_server.models import Response, Record, Dataset from argilla_server.jobs.webhook_jobs import enqueue_notify_events -from argilla_server.api.webhooks.v1.schemas import ResponseEventSchema -from argilla_server.api.webhooks.v1.enums import ResponseEvent +from argilla_server.webhooks.v1.schemas import ResponseEventSchema +from argilla_server.webhooks.v1.enums import ResponseEvent async def notify_response_event(db: AsyncSession, response_event: ResponseEvent, response: Response) -> List[Job]: diff --git a/argilla-server/src/argilla_server/api/webhooks/v1/schemas.py b/argilla-server/src/argilla_server/webhooks/v1/schemas.py similarity index 100% rename from argilla-server/src/argilla_server/api/webhooks/v1/schemas.py rename to argilla-server/src/argilla_server/webhooks/v1/schemas.py diff --git a/argilla-server/tests/factories.py b/argilla-server/tests/factories.py index 1d189d1ce4..2d2666dbce 100644 --- a/argilla-server/tests/factories.py +++ b/argilla-server/tests/factories.py @@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import async_object_session from argilla_server.enums import DatasetDistributionStrategy, FieldType, MetadataPropertyType, OptionsOrder -from argilla_server.api.webhooks.v1.enums import WebhookEvent +from argilla_server.webhooks.v1.enums import WebhookEvent from argilla_server.models import ( Dataset, Field, diff --git a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_create_webhook.py b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_create_webhook.py index 4d168e8847..5b076b2d85 100644 --- a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_create_webhook.py +++ b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_create_webhook.py @@ -19,7 +19,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from argilla_server.api.webhooks.v1.enums import WebhookEvent +from argilla_server.webhooks.v1.enums import WebhookEvent from argilla_server.models import Webhook from argilla_server.constants import API_KEY_HEADER_NAME diff --git a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_delete_webhook.py b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_delete_webhook.py index 995583c155..f48b12dcb6 100644 --- a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_delete_webhook.py +++ b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_delete_webhook.py @@ -19,7 +19,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from argilla_server.api.webhooks.v1.enums import WebhookEvent +from argilla_server.webhooks.v1.enums import WebhookEvent from argilla_server.models import Webhook from argilla_server.constants import API_KEY_HEADER_NAME diff --git a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_list_webhooks.py b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_list_webhooks.py index e7c0dc1216..5738e23f5a 100644 --- a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_list_webhooks.py +++ b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_list_webhooks.py @@ -16,7 +16,7 @@ from httpx import AsyncClient -from argilla_server.api.webhooks.v1.enums import WebhookEvent +from argilla_server.webhooks.v1.enums import WebhookEvent from argilla_server.constants import API_KEY_HEADER_NAME from tests.factories import AdminFactory, AnnotatorFactory, WebhookFactory diff --git a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_update_webhook.py b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_update_webhook.py index e4df0142c4..c615cce9c9 100644 --- a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_update_webhook.py +++ b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_update_webhook.py @@ -18,7 +18,7 @@ from httpx import AsyncClient from typing import Any -from argilla_server.api.webhooks.v1.enums import WebhookEvent +from argilla_server.webhooks.v1.enums import WebhookEvent from argilla_server.constants import API_KEY_HEADER_NAME from tests.factories import AdminFactory, AnnotatorFactory, WebhookFactory diff --git a/argilla-server/tests/unit/api/webhooks/__init__.py b/argilla-server/tests/unit/webhooks/__init__.py similarity index 100% rename from argilla-server/tests/unit/api/webhooks/__init__.py rename to argilla-server/tests/unit/webhooks/__init__.py diff --git a/argilla-server/tests/unit/api/webhooks/v1/__init__.py b/argilla-server/tests/unit/webhooks/v1/__init__.py similarity index 100% rename from argilla-server/tests/unit/api/webhooks/v1/__init__.py rename to argilla-server/tests/unit/webhooks/v1/__init__.py diff --git a/argilla-server/tests/unit/api/webhooks/v1/test_notify_ping_event.py b/argilla-server/tests/unit/webhooks/v1/test_notify_ping_event.py similarity index 92% rename from argilla-server/tests/unit/api/webhooks/v1/test_notify_ping_event.py rename to argilla-server/tests/unit/webhooks/v1/test_notify_ping_event.py index 12c58cc208..1fc7f4ae26 100644 --- a/argilla-server/tests/unit/api/webhooks/v1/test_notify_ping_event.py +++ b/argilla-server/tests/unit/webhooks/v1/test_notify_ping_event.py @@ -20,8 +20,8 @@ from httpx import Response from standardwebhooks.webhooks import Webhook -from argilla_server.api.webhooks.v1.enums import WebhookEvent -from argilla_server.api.webhooks.v1.ping import notify_ping_event +from argilla_server.webhooks.v1.enums import WebhookEvent +from argilla_server.webhooks.v1.ping import notify_ping_event from argilla_server.contexts import info from tests.factories import WebhookFactory From cbf28c7502cf3c3b74b6387d7077c2b73fa646bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Mon, 16 Sep 2024 16:17:03 +0200 Subject: [PATCH 07/24] feat: remove `response.upserted` webhook event (#5494) # Description As discussed we want to stop using `response.upserted` webhook event and instead use `response.created` or `response.updated` when a response is upserted. Refs #1836 **Type of change** - New feature (non-breaking change which adds functionality) **How Has This Been Tested** - [x] Running test suite. **Checklist** - I added relevant documentation - I followed the style guidelines of this project - I did a self-review of my code - I made corresponding changes to the documentation - I confirm My changes generate no new warnings - I have added tests that prove my fix is effective or that my feature works - I have added relevant notes to the CHANGELOG.md file (See https://keepachangelog.com/) --- .../src/argilla_server/contexts/datasets.py | 6 +++- .../src/argilla_server/webhooks/v1/enums.py | 28 +++++++++---------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/argilla-server/src/argilla_server/contexts/datasets.py b/argilla-server/src/argilla_server/contexts/datasets.py index b49dd72477..3208ad9c7b 100644 --- a/argilla-server/src/argilla_server/contexts/datasets.py +++ b/argilla-server/src/argilla_server/contexts/datasets.py @@ -939,7 +939,11 @@ async def upsert_response( await db.commit() await distribution.update_record_status(search_engine, record.id) - await notify_response_event_v1(db, ResponseEvent.upserted, response) + + if response.inserted_at == response.updated_at: + await notify_response_event_v1(db, ResponseEvent.created, response) + else: + await notify_response_event_v1(db, ResponseEvent.updated, response) return response diff --git a/argilla-server/src/argilla_server/webhooks/v1/enums.py b/argilla-server/src/argilla_server/webhooks/v1/enums.py index b5c396be91..fce902e476 100644 --- a/argilla-server/src/argilla_server/webhooks/v1/enums.py +++ b/argilla-server/src/argilla_server/webhooks/v1/enums.py @@ -21,16 +21,15 @@ class WebhookEvent(str, Enum): dataset_deleted = "dataset.deleted" dataset_published = "dataset.published" - response_created = "response.created" - response_updated = "response.updated" - response_deleted = "response.deleted" - response_upserted = "response.upserted" - record_created = "record.created" record_updated = "record.updated" record_deleted = "record.deleted" record_completed = "record.completed" + response_created = "response.created" + response_updated = "response.updated" + response_deleted = "response.deleted" + def __str__(self): return str(self.value) @@ -45,16 +44,6 @@ def __str__(self): return str(self.value) -class ResponseEvent(str, Enum): - created = WebhookEvent.response_created.value - updated = WebhookEvent.response_updated.value - deleted = WebhookEvent.response_deleted.value - upserted = WebhookEvent.response_upserted.value - - def __str__(self): - return str(self.value) - - class RecordEvent(str, Enum): created = WebhookEvent.record_created.value updated = WebhookEvent.record_updated.value @@ -63,3 +52,12 @@ class RecordEvent(str, Enum): def __str__(self): return str(self.value) + + +class ResponseEvent(str, Enum): + created = WebhookEvent.response_created.value + updated = WebhookEvent.response_updated.value + deleted = WebhookEvent.response_deleted.value + + def __str__(self): + return str(self.value) From a26f0084d58661c609e17f8b0606ac062dbb066e Mon Sep 17 00:00:00 2001 From: Paco Aranda Date: Thu, 19 Sep 2024 12:09:23 +0200 Subject: [PATCH 08/24] tests: Add tests using IP address when creating or updating webhooks (#5511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Since URLs including IP addresses are allowed values when creating/updating webhooks, this PR only adds tests checking these scenarios. **Type of change** **How Has This Been Tested** **Checklist** - I added relevant documentation - I followed the style guidelines of this project - I did a self-review of my code - I made corresponding changes to the documentation - I confirm My changes generate no new warnings - I have added tests that prove my fix is effective or that my feature works - I have added relevant notes to the CHANGELOG.md file (See https://keepachangelog.com/) --------- Co-authored-by: José Francisco Calvo --- .../v1/webhooks/test_create_webhook.py | 20 +++++++++++++++++++ .../v1/webhooks/test_update_webhook.py | 14 +++++++++++++ 2 files changed, 34 insertions(+) diff --git a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_create_webhook.py b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_create_webhook.py index 5b076b2d85..6fb2b51717 100644 --- a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_create_webhook.py +++ b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_create_webhook.py @@ -58,6 +58,26 @@ async def test_create_webhook(self, db: AsyncSession, async_client: AsyncClient, "updated_at": webhook.updated_at.isoformat(), } + async def test_create_webhook_with_ip_address_url( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "url": "http://1.1.1.1/webhook", + "events": [WebhookEvent.response_created], + "description": "Test webhook", + }, + ) + + assert response.status_code == 201 + + assert (await db.execute(select(func.count(Webhook.id)))).scalar() == 1 + webhook = (await db.execute(select(Webhook))).scalar_one() + + assert response.json()["url"] == "http://1.1.1.1/webhook" + async def test_create_webhook_as_admin(self, db: AsyncSession, async_client: AsyncClient): admin = await AdminFactory.create() diff --git a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_update_webhook.py b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_update_webhook.py index c615cce9c9..ad2cc0bb30 100644 --- a/argilla-server/tests/unit/api/handlers/v1/webhooks/test_update_webhook.py +++ b/argilla-server/tests/unit/api/handlers/v1/webhooks/test_update_webhook.py @@ -92,6 +92,20 @@ async def test_update_webhook_with_url(self, async_client: AsyncClient, owner_au assert webhook.url == "https://example.com/webhook" + async def test_update_webhook_with_ip_address_url(self, async_client: AsyncClient, owner_auth_header: dict): + webhook = await WebhookFactory.create() + + response = await async_client.patch( + self.url(webhook.id), + headers=owner_auth_header, + json={ + "url": "https://1.1.1.1:9999/webhook", + }, + ) + + assert response.status_code == 200 + assert response.json()["url"] == "https://1.1.1.1:9999/webhook" + async def test_update_webhook_with_events(self, async_client: AsyncClient, owner_auth_header: dict): webhook = await WebhookFactory.create() From 6cd08feb27bd3c8ed8f6e0b68e3df4f70684dd21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Fri, 20 Sep 2024 16:38:15 +0200 Subject: [PATCH 09/24] [IMPROVEMENT] Add Webhooks delete events with expanded schemas (#5519) # Description Added some changes so we can generate Webhook events before deleting resources and notify those events only after the resource has been successfully deleted. Affected events: * Delete a dataset. * Delete a response. * Delete a record. * Delete many records. Refs #1836 **Type of change** - Improvement (change adding some improvement to an existing functionality) **How Has This Been Tested** - [x] Manually test that deleted resource events are correctly working. **Checklist** - I added relevant documentation - I followed the style guidelines of this project - I did a self-review of my code - I made corresponding changes to the documentation - I confirm My changes generate no new warnings - I have added tests that prove my fix is effective or that my feature works - I have added relevant notes to the CHANGELOG.md file (See https://keepachangelog.com/) --- .../src/argilla_server/contexts/datasets.py | 51 ++++++++++++++----- .../argilla_server/webhooks/v1/datasets.py | 21 +++----- .../src/argilla_server/webhooks/v1/event.py | 36 +++++++++++++ .../src/argilla_server/webhooks/v1/ping.py | 2 +- .../src/argilla_server/webhooks/v1/records.py | 26 ++++------ .../argilla_server/webhooks/v1/responses.py | 26 ++++------ 6 files changed, 100 insertions(+), 62 deletions(-) create mode 100644 argilla-server/src/argilla_server/webhooks/v1/event.py diff --git a/argilla-server/src/argilla_server/contexts/datasets.py b/argilla-server/src/argilla_server/contexts/datasets.py index 177a6c352b..dfdcad6ca7 100644 --- a/argilla-server/src/argilla_server/contexts/datasets.py +++ b/argilla-server/src/argilla_server/contexts/datasets.py @@ -61,9 +61,18 @@ ) from argilla_server.api.schemas.v1.vectors import Vector as VectorSchema from argilla_server.webhooks.v1.enums import DatasetEvent, ResponseEvent, RecordEvent -from argilla_server.webhooks.v1.records import notify_record_event as notify_record_event_v1 -from argilla_server.webhooks.v1.responses import notify_response_event as notify_response_event_v1 -from argilla_server.webhooks.v1.datasets import notify_dataset_event as notify_dataset_event_v1 +from argilla_server.webhooks.v1.records import ( + build_record_event as build_record_event_v1, + notify_record_event as notify_record_event_v1, +) +from argilla_server.webhooks.v1.responses import ( + build_response_event as build_response_event_v1, + notify_response_event as notify_response_event_v1, +) +from argilla_server.webhooks.v1.datasets import ( + build_dataset_event as build_dataset_event_v1, + notify_dataset_event as notify_dataset_event_v1, +) from argilla_server.contexts import accounts, distribution from argilla_server.database import get_async_db from argilla_server.enums import DatasetStatus, UserRole, RecordStatus @@ -204,13 +213,14 @@ async def update_dataset(db: AsyncSession, dataset: Dataset, dataset_attrs: dict async def delete_dataset(db: AsyncSession, search_engine: SearchEngine, dataset: Dataset) -> Dataset: + deleted_dataset_event_v1 = await build_dataset_event_v1(db, DatasetEvent.deleted, dataset) + async with db.begin_nested(): dataset = await dataset.delete(db, autocommit=False) await search_engine.delete_index(dataset) await db.commit() - - await notify_dataset_event_v1(db, DatasetEvent.deleted, dataset) + await deleted_dataset_event_v1.notify(db) return dataset @@ -815,15 +825,24 @@ async def preload_records_relationships_before_validate(db: AsyncSession, record async def delete_records( db: AsyncSession, search_engine: "SearchEngine", dataset: Dataset, records_ids: List[UUID] ) -> None: + params = [Record.id.in_(records_ids), Record.dataset_id == dataset.id] + + records = (await db.execute(select(Record).filter(*params))).scalars().all() + + deleted_record_events_v1 = [] + for record in records: + deleted_record_events_v1.append( + await build_record_event_v1(db, RecordEvent.deleted, record), + ) + async with db.begin_nested(): - params = [Record.id.in_(records_ids), Record.dataset_id == dataset.id] records = await Record.delete_many(db=db, params=params, autocommit=False) await search_engine.delete_records(dataset=dataset, records=records) await db.commit() - for record in records: - await notify_record_event_v1(db, RecordEvent.deleted, record) + for deleted_record_event_v1 in deleted_record_events_v1: + await deleted_record_event_v1.notify(db) async def update_record( @@ -860,13 +879,14 @@ async def update_record( async def delete_record(db: AsyncSession, search_engine: "SearchEngine", record: Record) -> Record: + deleted_record_event_v1 = await build_record_event_v1(db, RecordEvent.deleted, record) + async with db.begin_nested(): record = await record.delete(db=db, autocommit=False) await search_engine.delete_records(dataset=record.dataset, records=[record]) await db.commit() - - await notify_record_event_v1(db, RecordEvent.deleted, record) + await deleted_record_event_v1.notify(db) return record @@ -897,8 +917,8 @@ async def create_response( await search_engine.update_record_response(response) await db.commit() - await distribution.update_record_status(search_engine, record.id) await notify_response_event_v1(db, ResponseEvent.created, response) + await distribution.update_record_status(search_engine, record.id) return response @@ -922,8 +942,8 @@ async def update_response( await search_engine.update_record_response(response) await db.commit() - await distribution.update_record_status(search_engine, response.record_id) await notify_response_event_v1(db, ResponseEvent.updated, response) + await distribution.update_record_status(search_engine, response.record_id) return response @@ -951,17 +971,20 @@ async def upsert_response( await search_engine.update_record_response(response) await db.commit() - await distribution.update_record_status(search_engine, record.id) if response.inserted_at == response.updated_at: await notify_response_event_v1(db, ResponseEvent.created, response) else: await notify_response_event_v1(db, ResponseEvent.updated, response) + await distribution.update_record_status(search_engine, record.id) + return response async def delete_response(db: AsyncSession, search_engine: SearchEngine, response: Response) -> Response: + deleted_response_event_v1 = await build_response_event_v1(db, ResponseEvent.deleted, response) + async with db.begin_nested(): response = await response.delete(db, autocommit=False) @@ -970,8 +993,8 @@ async def delete_response(db: AsyncSession, search_engine: SearchEngine, respons await search_engine.delete_record_response(response) await db.commit() + await deleted_response_event_v1.notify(db) await distribution.update_record_status(search_engine, response.record_id) - await notify_response_event_v1(db, ResponseEvent.deleted, response) return response diff --git a/argilla-server/src/argilla_server/webhooks/v1/datasets.py b/argilla-server/src/argilla_server/webhooks/v1/datasets.py index 7df7c257b4..079d352bb1 100644 --- a/argilla-server/src/argilla_server/webhooks/v1/datasets.py +++ b/argilla-server/src/argilla_server/webhooks/v1/datasets.py @@ -21,15 +21,18 @@ from sqlalchemy.ext.asyncio import AsyncSession from argilla_server.models import Dataset -from argilla_server.jobs.webhook_jobs import enqueue_notify_events +from argilla_server.webhooks.v1.event import Event from argilla_server.webhooks.v1.schemas import DatasetEventSchema from argilla_server.webhooks.v1.enums import DatasetEvent async def notify_dataset_event(db: AsyncSession, dataset_event: DatasetEvent, dataset: Dataset) -> List[Job]: - if dataset_event == DatasetEvent.deleted: - return await _notify_dataset_deleted_event(db, dataset) + event = await build_dataset_event(db, dataset_event, dataset) + return await event.notify(db) + + +async def build_dataset_event(db: AsyncSession, dataset_event: DatasetEvent, dataset: Dataset) -> Event: # NOTE: Force loading required association resources required by the event schema ( await db.execute( @@ -45,18 +48,8 @@ async def notify_dataset_event(db: AsyncSession, dataset_event: DatasetEvent, da ) ).scalar_one() - return await enqueue_notify_events( - db, + return Event( event=dataset_event, timestamp=datetime.utcnow(), data=DatasetEventSchema.from_orm(dataset).dict(), ) - - -async def _notify_dataset_deleted_event(db: AsyncSession, dataset: Dataset) -> List[Job]: - return await enqueue_notify_events( - db, - event=DatasetEvent.deleted, - timestamp=datetime.utcnow(), - data={"id": dataset.id}, - ) diff --git a/argilla-server/src/argilla_server/webhooks/v1/event.py b/argilla-server/src/argilla_server/webhooks/v1/event.py new file mode 100644 index 0000000000..f5f3d9670b --- /dev/null +++ b/argilla-server/src/argilla_server/webhooks/v1/event.py @@ -0,0 +1,36 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import List +from datetime import datetime + +from rq.job import Job +from sqlalchemy.ext.asyncio import AsyncSession + +from argilla_server.jobs.webhook_jobs import enqueue_notify_events + + +class Event: + def __init__(self, event: str, timestamp: datetime, data: dict): + self.event = event + self.timestamp = timestamp + self.data = data + + async def notify(self, db: AsyncSession) -> List[Job]: + return await enqueue_notify_events( + db, + event=self.event, + timestamp=self.timestamp, + data=self.data, + ) diff --git a/argilla-server/src/argilla_server/webhooks/v1/ping.py b/argilla-server/src/argilla_server/webhooks/v1/ping.py index caea2bf287..fcf592e6bc 100644 --- a/argilla-server/src/argilla_server/webhooks/v1/ping.py +++ b/argilla-server/src/argilla_server/webhooks/v1/ping.py @@ -16,8 +16,8 @@ from datetime import datetime -from argilla_server.models import Webhook from argilla_server.contexts import info +from argilla_server.models import Webhook from argilla_server.webhooks.v1.commons import notify_event from argilla_server.webhooks.v1.enums import WebhookEvent diff --git a/argilla-server/src/argilla_server/webhooks/v1/records.py b/argilla-server/src/argilla_server/webhooks/v1/records.py index b60d95d884..f03172d473 100644 --- a/argilla-server/src/argilla_server/webhooks/v1/records.py +++ b/argilla-server/src/argilla_server/webhooks/v1/records.py @@ -17,19 +17,23 @@ from rq.job import Job from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from sqlalchemy.ext.asyncio import AsyncSession +from argilla_server.models import Record, Dataset +from argilla_server.webhooks.v1.event import Event from argilla_server.webhooks.v1.enums import RecordEvent from argilla_server.webhooks.v1.schemas import RecordEventSchema -from argilla_server.jobs.webhook_jobs import enqueue_notify_events -from argilla_server.models import Record, Dataset async def notify_record_event(db: AsyncSession, record_event: RecordEvent, record: Record) -> List[Job]: - if record_event == RecordEvent.deleted: - return await _notify_record_deleted_event(db, record) + event = await build_record_event(db, record_event, record) + + return await event.notify(db) + +async def build_record_event(db: AsyncSession, record_event: RecordEvent, record: Record) -> Event: + # NOTE: Force loading required association resources required by the event schema ( await db.execute( select(Dataset) @@ -44,18 +48,8 @@ async def notify_record_event(db: AsyncSession, record_event: RecordEvent, recor ) ).scalar_one() - return await enqueue_notify_events( - db, + return Event( event=record_event, timestamp=datetime.utcnow(), data=RecordEventSchema.from_orm(record).dict(), ) - - -async def _notify_record_deleted_event(db: AsyncSession, record: Record) -> List[Job]: - return await enqueue_notify_events( - db, - event=RecordEvent.deleted, - timestamp=datetime.utcnow(), - data={"id": record.id}, - ) diff --git a/argilla-server/src/argilla_server/webhooks/v1/responses.py b/argilla-server/src/argilla_server/webhooks/v1/responses.py index a4048094c0..122fae0fd9 100644 --- a/argilla-server/src/argilla_server/webhooks/v1/responses.py +++ b/argilla-server/src/argilla_server/webhooks/v1/responses.py @@ -15,22 +15,24 @@ from typing import List from datetime import datetime +from rq.job import Job from sqlalchemy import select from sqlalchemy.orm import selectinload - -from rq.job import Job from sqlalchemy.ext.asyncio import AsyncSession from argilla_server.models import Response, Record, Dataset -from argilla_server.jobs.webhook_jobs import enqueue_notify_events -from argilla_server.webhooks.v1.schemas import ResponseEventSchema +from argilla_server.webhooks.v1.event import Event from argilla_server.webhooks.v1.enums import ResponseEvent +from argilla_server.webhooks.v1.schemas import ResponseEventSchema async def notify_response_event(db: AsyncSession, response_event: ResponseEvent, response: Response) -> List[Job]: - if response_event == ResponseEvent.deleted: - return await _notify_response_deleted_event(db, response) + event = await build_response_event(db, response_event, response) + + return await event.notify(db) + +async def build_response_event(db: AsyncSession, response_event: ResponseEvent, response: Response) -> Event: # NOTE: Force loading required association resources required by the event schema ( await db.execute( @@ -51,18 +53,8 @@ async def notify_response_event(db: AsyncSession, response_event: ResponseEvent, ) ).scalar_one() - return await enqueue_notify_events( - db, + return Event( event=response_event, timestamp=datetime.utcnow(), data=ResponseEventSchema.from_orm(response).dict(), ) - - -async def _notify_response_deleted_event(db: AsyncSession, response: Response) -> List[Job]: - return await enqueue_notify_events( - db, - event=ResponseEvent.deleted, - timestamp=datetime.utcnow(), - data={"id": response.id}, - ) From d2cfbed39973357513e429f8bf4f0977f8e5de67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Mon, 23 Sep 2024 16:02:28 +0200 Subject: [PATCH 10/24] [FIX] `webhook-timestamp` header to use current seconds since epoch value instead of event timestamp (#5521) # Description Using the same timestamp value in the payload and in the `webhook-timestamp` (this one used for verification) was causing an "message too old" error when verifying the webhook message. Replacing it to be calculated in the moment of the webhook request should solve these associated problems. **Type of change** - Fix **How Has This Been Tested** - [x] Test suite passing. - [x] Tested manually with a listener verifying the webhook message. **Checklist** - I added relevant documentation - I followed the style guidelines of this project - I did a self-review of my code - I made corresponding changes to the documentation - I confirm My changes generate no new warnings - I have added tests that prove my fix is effective or that my feature works - I have added relevant notes to the CHANGELOG.md file (See https://keepachangelog.com/) --- argilla-server/src/argilla_server/webhooks/v1/commons.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/argilla-server/src/argilla_server/webhooks/v1/commons.py b/argilla-server/src/argilla_server/webhooks/v1/commons.py index 032462ad37..860a8f81ce 100644 --- a/argilla-server/src/argilla_server/webhooks/v1/commons.py +++ b/argilla-server/src/argilla_server/webhooks/v1/commons.py @@ -31,13 +31,15 @@ # NOTE: We are using standard webhooks implementation. # For more information take a look to https://www.standardwebhooks.com def notify_event(webhook: WebhookModel, event: str, timestamp: datetime, data: Dict) -> httpx.Response: + timestamp_attempt = datetime.utcnow() + msg_id = _generate_msg_id() payload = json.dumps(_build_payload(event, timestamp, data)) - signature = Webhook(webhook.secret).sign(msg_id, timestamp, payload) + signature = Webhook(webhook.secret).sign(msg_id, timestamp_attempt, payload) return httpx.post( webhook.url, - headers=_build_headers(msg_id, timestamp, signature), + headers=_build_headers(msg_id, timestamp_attempt, signature), content=payload, timeout=NOTIFY_EVENT_DEFAULT_TIMEOUT, ) From d8d2986b25a14a4a073d1a6b581e07832ed03c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Wed, 25 Sep 2024 17:50:17 +0200 Subject: [PATCH 11/24] feat: add missing tests for webhooks feature (#5537) # Description This PR adds some missing tests checking that webhook events are enqueued on background jobs when actions occurs inside Argilla app. Refs #1836 **Type of change** - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) - Breaking change (fix or feature that would cause existing functionality to not work as expected) - Refactor (change restructuring the codebase without changing functionality) - Improvement (change adding some improvement to an existing functionality) - Documentation update **How Has This Been Tested** - [x] All tests should be passing. **Checklist** - I added relevant documentation - I followed the style guidelines of this project - I did a self-review of my code - I made corresponding changes to the documentation - I confirm My changes generate no new warnings - I have added tests that prove my fix is effective or that my feature works - I have added relevant notes to the CHANGELOG.md file (See https://keepachangelog.com/) --- .../api/handlers/v1/datasets/records_bulk.py | 14 +- .../src/argilla_server/bulk/records_bulk.py | 4 +- .../src/argilla_server/contexts/datasets.py | 2 +- argilla-server/tests/conftest.py | 23 +- .../test_create_dataset_records_bulk.py | 59 ++++- .../test_upsert_dataset_records_bulk.py | 111 +++++++++- .../records/test_delete_dataset_records.py | 60 ++++++ .../v1/datasets/test_create_dataset.py | 37 +++- .../v1/datasets/test_delete_dataset.py | 52 +++++ .../v1/datasets/test_publish_dataset.py | 55 +++++ .../v1/datasets/test_update_dataset.py | 33 ++- .../v1/records/test_create_record_response.py | 129 ++++++++++- .../handlers/v1/records/test_delete_record.py | 52 +++++ .../handlers/v1/records/test_update_record.py | 53 +++++ ...test_create_current_user_responses_bulk.py | 202 +++++++++++++++++- .../v1/responses/test_delete_response.py | 64 +++++- .../v1/responses/test_update_response.py | 167 ++++++++++++++- argilla-server/tests/unit/jobs/__init__.py | 14 ++ .../tests/unit/jobs/webhook_jobs/__init__.py | 14 ++ .../test_enqueue_notify_events.py | 58 +++++ 20 files changed, 1150 insertions(+), 53 deletions(-) rename argilla-server/tests/unit/api/handlers/v1/{records => datasets/records/records_bulk}/test_upsert_dataset_records_bulk.py (58%) create mode 100644 argilla-server/tests/unit/api/handlers/v1/datasets/records/test_delete_dataset_records.py create mode 100644 argilla-server/tests/unit/api/handlers/v1/datasets/test_delete_dataset.py create mode 100644 argilla-server/tests/unit/api/handlers/v1/datasets/test_publish_dataset.py create mode 100644 argilla-server/tests/unit/api/handlers/v1/records/test_delete_record.py create mode 100644 argilla-server/tests/unit/api/handlers/v1/records/test_update_record.py create mode 100644 argilla-server/tests/unit/jobs/__init__.py create mode 100644 argilla-server/tests/unit/jobs/webhook_jobs/__init__.py create mode 100644 argilla-server/tests/unit/jobs/webhook_jobs/test_enqueue_notify_events.py diff --git a/argilla-server/src/argilla_server/api/handlers/v1/datasets/records_bulk.py b/argilla-server/src/argilla_server/api/handlers/v1/datasets/records_bulk.py index 69cc536a0f..9aef5ca0ac 100644 --- a/argilla-server/src/argilla_server/api/handlers/v1/datasets/records_bulk.py +++ b/argilla-server/src/argilla_server/api/handlers/v1/datasets/records_bulk.py @@ -26,7 +26,6 @@ from argilla_server.models import Dataset, User from argilla_server.search_engine import SearchEngine, get_search_engine from argilla_server.security import auth -from argilla_server.telemetry import TelemetryClient, get_telemetry_client router = APIRouter() @@ -43,7 +42,6 @@ async def create_dataset_records_bulk( db: AsyncSession = Depends(get_async_db), search_engine: SearchEngine = Depends(get_search_engine), current_user: User = Security(auth.get_current_user), - telemetry_client: TelemetryClient = Depends(get_telemetry_client), ): dataset = await Dataset.get_or_raise( db, @@ -58,9 +56,7 @@ async def create_dataset_records_bulk( await authorize(current_user, DatasetPolicy.create_records(dataset)) - records_bulk = await CreateRecordsBulk(db, search_engine).create_records_bulk(dataset, records_bulk_create) - - return records_bulk + return await CreateRecordsBulk(db, search_engine).create_records_bulk(dataset, records_bulk_create) @router.put("/datasets/{dataset_id}/records/bulk", response_model=RecordsBulk) @@ -71,7 +67,6 @@ async def upsert_dataset_records_bulk( db: AsyncSession = Depends(get_async_db), search_engine: SearchEngine = Depends(get_search_engine), current_user: User = Security(auth.get_current_user), - telemetry_client: TelemetryClient = Depends(get_telemetry_client), ): dataset = await Dataset.get_or_raise( db, @@ -86,9 +81,4 @@ async def upsert_dataset_records_bulk( await authorize(current_user, DatasetPolicy.upsert_records(dataset)) - records_bulk = await UpsertRecordsBulk(db, search_engine).upsert_records_bulk(dataset, records_bulk_create) - - updated = len(records_bulk.updated_item_ids) - created = len(records_bulk.items) - updated - - return records_bulk + return await UpsertRecordsBulk(db, search_engine).upsert_records_bulk(dataset, records_bulk_create) diff --git a/argilla-server/src/argilla_server/bulk/records_bulk.py b/argilla-server/src/argilla_server/bulk/records_bulk.py index babaa2b70c..7af00f3443 100644 --- a/argilla-server/src/argilla_server/bulk/records_bulk.py +++ b/argilla-server/src/argilla_server/bulk/records_bulk.py @@ -220,7 +220,7 @@ async def upsert_records_bulk(self, dataset: Dataset, bulk_upsert: RecordsBulkUp await self._db.commit() - await self._notify_record_events(records) + await self._notify_upsert_record_events(records) return RecordsBulkWithUpdateInfo( items=records, @@ -241,7 +241,7 @@ async def _fetch_existing_dataset_records( return {**records_by_external_id, **records_by_id} - async def _notify_record_events(self, records: List[Record]) -> None: + async def _notify_upsert_record_events(self, records: List[Record]) -> None: for record in records: if record.inserted_at == record.updated_at: await notify_record_event_v1(self._db, RecordEvent.created, record) diff --git a/argilla-server/src/argilla_server/contexts/datasets.py b/argilla-server/src/argilla_server/contexts/datasets.py index dfdcad6ca7..56f91804f6 100644 --- a/argilla-server/src/argilla_server/contexts/datasets.py +++ b/argilla-server/src/argilla_server/contexts/datasets.py @@ -827,7 +827,7 @@ async def delete_records( ) -> None: params = [Record.id.in_(records_ids), Record.dataset_id == dataset.id] - records = (await db.execute(select(Record).filter(*params))).scalars().all() + records = (await db.execute(select(Record).filter(*params).order_by(Record.inserted_at.asc()))).scalars().all() deleted_record_events_v1 = [] for record in records: diff --git a/argilla-server/tests/conftest.py b/argilla-server/tests/conftest.py index 67d704bf2c..55b4a53af5 100644 --- a/argilla-server/tests/conftest.py +++ b/argilla-server/tests/conftest.py @@ -12,17 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -import asyncio -from typing import TYPE_CHECKING, AsyncGenerator, Generator - import httpx +import asyncio import pytest import pytest_asyncio + +from rq import Queue +from typing import TYPE_CHECKING, AsyncGenerator, Generator +from sqlalchemy import NullPool, create_engine +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine + from argilla_server.cli.database.migrate import migrate_db from argilla_server.database import database_url_sync +from argilla_server.jobs.queues import REDIS_CONNECTION from argilla_server.settings import settings -from sqlalchemy import NullPool, create_engine -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from tests.database import SyncTestSession, TestSession, set_task @@ -97,6 +100,16 @@ def sync_db(sync_connection: "Connection") -> Generator["Session", None, None]: sync_connection.rollback() +@pytest.fixture(autouse=True) +def empty_job_queues(): + queues = Queue.all(connection=REDIS_CONNECTION) + + for queue in queues: + queue.empty() + + yield + + @pytest.fixture def async_db_proxy(mocker: "MockerFixture", sync_db: "Session") -> "AsyncSession": """Create a mocked `AsyncSession` that proxies to the sync session. This will allow us to execute the async CLI commands diff --git a/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_create_dataset_records_bulk.py b/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_create_dataset_records_bulk.py index 4a40ed48a1..21831a1c8c 100644 --- a/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_create_dataset_records_bulk.py +++ b/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_create_dataset_records_bulk.py @@ -12,15 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -from uuid import UUID - import pytest -from argilla_server.enums import DatasetStatus, QuestionType, ResponseStatus, SuggestionType -from argilla_server.models.database import Record, Response, Suggestion, User + +from uuid import UUID from httpx import AsyncClient +from fastapi.encoders import jsonable_encoder from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from argilla_server.jobs.queues import HIGH_QUEUE +from argilla_server.webhooks.v1.enums import RecordEvent +from argilla_server.webhooks.v1.records import build_record_event +from argilla_server.models.database import Record, Response, Suggestion, User +from argilla_server.enums import DatasetStatus, QuestionType, ResponseStatus, SuggestionType + from tests.factories import ( DatasetFactory, LabelSelectionQuestionFactory, @@ -32,6 +37,7 @@ ImageFieldFactory, TextQuestionFactory, ChatFieldFactory, + WebhookFactory, ) @@ -551,3 +557,48 @@ async def test_create_dataset_records_bulk_with_chat_field_without_content_key( } } assert (await db.execute(select(func.count(Record.id)))).scalar_one() == 0 + + async def test_create_dataset_records_bulk_enqueue_webhook_record_created_events( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + dataset = await DatasetFactory.create(status=DatasetStatus.ready) + await TextFieldFactory.create(name="prompt", dataset=dataset) + await TextQuestionFactory.create(name="text-question", dataset=dataset) + + webhook = await WebhookFactory.create(events=[RecordEvent.created]) + + response = await async_client.post( + self.url(dataset.id), + headers=owner_auth_header, + json={ + "items": [ + { + "fields": { + "prompt": "Does exercise help reduce stress?", + }, + }, + { + "fields": { + "prompt": "What is the best way to reduce stress?", + }, + }, + ], + }, + ) + + assert response.status_code == 201 + + records = (await db.execute(select(Record).order_by(Record.inserted_at.asc()))).scalars().all() + + event_a = await build_record_event(db, RecordEvent.created, records[0]) + event_b = await build_record_event(db, RecordEvent.created, records[1]) + + assert HIGH_QUEUE.count == 2 + + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == RecordEvent.created + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event_a.data) + + assert HIGH_QUEUE.jobs[1].args[0] == webhook.id + assert HIGH_QUEUE.jobs[1].args[1] == RecordEvent.created + assert HIGH_QUEUE.jobs[1].args[3] == jsonable_encoder(event_b.data) diff --git a/argilla-server/tests/unit/api/handlers/v1/records/test_upsert_dataset_records_bulk.py b/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_upsert_dataset_records_bulk.py similarity index 58% rename from argilla-server/tests/unit/api/handlers/v1/records/test_upsert_dataset_records_bulk.py rename to argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_upsert_dataset_records_bulk.py index 82b035a58a..73c37c5bb1 100644 --- a/argilla-server/tests/unit/api/handlers/v1/records/test_upsert_dataset_records_bulk.py +++ b/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_upsert_dataset_records_bulk.py @@ -16,11 +16,26 @@ from uuid import UUID from httpx import AsyncClient +from fastapi.encoders import jsonable_encoder +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession -from argilla_server.models import User + +from argilla_server.models import User, Record +from argilla_server.jobs.queues import HIGH_QUEUE from argilla_server.enums import DatasetDistributionStrategy, ResponseStatus, DatasetStatus, RecordStatus +from argilla_server.webhooks.v1.enums import RecordEvent +from argilla_server.webhooks.v1.records import build_record_event -from tests.factories import DatasetFactory, RecordFactory, TextQuestionFactory, ResponseFactory, AnnotatorFactory +from tests.factories import ( + DatasetFactory, + RecordFactory, + TextFieldFactory, + TextQuestionFactory, + AnnotatorFactory, + WebhookFactory, + ResponseFactory, +) @pytest.mark.asyncio @@ -151,3 +166,95 @@ async def test_upsert_dataset_records_bulk_updates_records_status( assert record_b.status == RecordStatus.pending assert record_c.status == RecordStatus.pending assert record_d.status == RecordStatus.pending + + async def test_upsert_dataset_records_bulk_enqueue_webhook_record_created_events( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + dataset = await DatasetFactory.create(status=DatasetStatus.ready) + await TextFieldFactory.create(name="prompt", dataset=dataset) + await TextQuestionFactory.create(name="text-question", dataset=dataset) + + webhook = await WebhookFactory.create(events=[RecordEvent.created, RecordEvent.updated]) + + response = await async_client.put( + self.url(dataset.id), + headers=owner_auth_header, + json={ + "items": [ + { + "fields": { + "prompt": "Does exercise help reduce stress?", + }, + }, + { + "fields": { + "prompt": "What is the best way to reduce stress?", + }, + }, + ], + }, + ) + + assert response.status_code == 200 + + records = (await db.execute(select(Record).order_by(Record.inserted_at.asc()))).scalars().all() + + event_a = await build_record_event(db, RecordEvent.created, records[0]) + event_b = await build_record_event(db, RecordEvent.created, records[1]) + + assert HIGH_QUEUE.count == 2 + + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == RecordEvent.created + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event_a.data) + + assert HIGH_QUEUE.jobs[1].args[0] == webhook.id + assert HIGH_QUEUE.jobs[1].args[1] == RecordEvent.created + assert HIGH_QUEUE.jobs[1].args[3] == jsonable_encoder(event_b.data) + + async def test_upsert_dataset_records_bulk_enqueue_webhook_record_updated_events( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + dataset = await DatasetFactory.create(status=DatasetStatus.ready) + await TextFieldFactory.create(name="prompt", dataset=dataset) + await TextQuestionFactory.create(name="text-question", dataset=dataset) + + records = await RecordFactory.create_batch(2, dataset=dataset) + + webhook = await WebhookFactory.create(events=[RecordEvent.created, RecordEvent.updated]) + + response = await async_client.put( + self.url(dataset.id), + headers=owner_auth_header, + json={ + "items": [ + { + "id": str(records[0].id), + "metadata": { + "metadata-key": "metadata-value", + }, + }, + { + "id": str(records[1].id), + "metadata": { + "metadata-key": "metadata-value", + }, + }, + ], + }, + ) + + assert response.status_code == 200 + + event_a = await build_record_event(db, RecordEvent.updated, records[0]) + event_b = await build_record_event(db, RecordEvent.updated, records[1]) + + assert HIGH_QUEUE.count == 2 + + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == RecordEvent.updated + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event_a.data) + + assert HIGH_QUEUE.jobs[1].args[0] == webhook.id + assert HIGH_QUEUE.jobs[1].args[1] == RecordEvent.updated + assert HIGH_QUEUE.jobs[1].args[3] == jsonable_encoder(event_b.data) diff --git a/argilla-server/tests/unit/api/handlers/v1/datasets/records/test_delete_dataset_records.py b/argilla-server/tests/unit/api/handlers/v1/datasets/records/test_delete_dataset_records.py new file mode 100644 index 0000000000..19773b3512 --- /dev/null +++ b/argilla-server/tests/unit/api/handlers/v1/datasets/records/test_delete_dataset_records.py @@ -0,0 +1,60 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from uuid import UUID +from httpx import AsyncClient +from fastapi.encoders import jsonable_encoder +from sqlalchemy.ext.asyncio import AsyncSession + +from argilla_server.jobs.queues import HIGH_QUEUE +from argilla_server.webhooks.v1.enums import RecordEvent +from argilla_server.webhooks.v1.records import build_record_event + +from tests.factories import DatasetFactory, RecordFactory, WebhookFactory + + +@pytest.mark.asyncio +class TestDeleteDatasetRecords: + def url(self, dataset_id: UUID) -> str: + return f"/api/v1/datasets/{dataset_id}/records" + + async def test_delete_dataset_records_enqueue_webhook_record_deleted_events( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + dataset = await DatasetFactory.create() + records = await RecordFactory.create_batch(2, dataset=dataset) + webhook = await WebhookFactory.create(events=[RecordEvent.deleted]) + + event_a = await build_record_event(db, RecordEvent.deleted, records[0]) + event_b = await build_record_event(db, RecordEvent.deleted, records[1]) + + response = await async_client.delete( + self.url(dataset.id), + headers=owner_auth_header, + params={"ids": f"{records[0].id},{records[1].id}"}, + ) + + assert response.status_code == 204 + + assert HIGH_QUEUE.count == 2 + + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == RecordEvent.deleted + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event_a.data) + + assert HIGH_QUEUE.jobs[1].args[0] == webhook.id + assert HIGH_QUEUE.jobs[1].args[1] == RecordEvent.deleted + assert HIGH_QUEUE.jobs[1].args[3] == jsonable_encoder(event_b.data) diff --git a/argilla-server/tests/unit/api/handlers/v1/datasets/test_create_dataset.py b/argilla-server/tests/unit/api/handlers/v1/datasets/test_create_dataset.py index 4261145d0c..567900a529 100644 --- a/argilla-server/tests/unit/api/handlers/v1/datasets/test_create_dataset.py +++ b/argilla-server/tests/unit/api/handlers/v1/datasets/test_create_dataset.py @@ -13,13 +13,19 @@ # limitations under the License. import pytest -from argilla_server.enums import DatasetDistributionStrategy, DatasetStatus -from argilla_server.models import Dataset + from httpx import AsyncClient from sqlalchemy import func, select +from fastapi.encoders import jsonable_encoder from sqlalchemy.ext.asyncio import AsyncSession -from tests.factories import WorkspaceFactory +from argilla_server.models import Dataset +from argilla_server.jobs.queues import HIGH_QUEUE +from argilla_server.enums import DatasetDistributionStrategy, DatasetStatus +from argilla_server.webhooks.v1.enums import DatasetEvent +from argilla_server.webhooks.v1.datasets import build_dataset_event + +from tests.factories import WebhookFactory, WorkspaceFactory @pytest.mark.asyncio @@ -137,3 +143,28 @@ async def test_create_dataset_with_invalid_distribution_strategy( assert response.status_code == 422 assert (await db.execute(select(func.count(Dataset.id)))).scalar_one() == 0 + + async def test_create_dataset_enqueue_webhook_dataset_created_event( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + workspace = await WorkspaceFactory.create() + webhook = await WebhookFactory.create(events=[DatasetEvent.created]) + + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "name": "Dataset Name", + "workspace_id": str(workspace.id), + }, + ) + + assert response.status_code == 201 + + dataset = (await db.execute(select(Dataset))).scalar_one() + event = await build_dataset_event(db, DatasetEvent.created, dataset) + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == DatasetEvent.created + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) diff --git a/argilla-server/tests/unit/api/handlers/v1/datasets/test_delete_dataset.py b/argilla-server/tests/unit/api/handlers/v1/datasets/test_delete_dataset.py new file mode 100644 index 0000000000..d01feabcbf --- /dev/null +++ b/argilla-server/tests/unit/api/handlers/v1/datasets/test_delete_dataset.py @@ -0,0 +1,52 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from uuid import UUID +from httpx import AsyncClient +from fastapi.encoders import jsonable_encoder +from sqlalchemy.ext.asyncio import AsyncSession + +from argilla_server.jobs.queues import HIGH_QUEUE +from argilla_server.webhooks.v1.enums import DatasetEvent +from argilla_server.webhooks.v1.datasets import build_dataset_event + +from tests.factories import DatasetFactory, WebhookFactory + + +@pytest.mark.asyncio +class TestDeleteDataset: + def url(self, dataset_id: UUID) -> str: + return f"/api/v1/datasets/{dataset_id}" + + async def test_delete_dataset_enqueue_webhook_dataset_deleted_event( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + dataset = await DatasetFactory.create() + webhook = await WebhookFactory.create(events=[DatasetEvent.deleted]) + + event = await build_dataset_event(db, DatasetEvent.deleted, dataset) + + response = await async_client.delete( + self.url(dataset.id), + headers=owner_auth_header, + ) + + assert response.status_code == 200 + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == DatasetEvent.deleted + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) diff --git a/argilla-server/tests/unit/api/handlers/v1/datasets/test_publish_dataset.py b/argilla-server/tests/unit/api/handlers/v1/datasets/test_publish_dataset.py new file mode 100644 index 0000000000..9fb3a11481 --- /dev/null +++ b/argilla-server/tests/unit/api/handlers/v1/datasets/test_publish_dataset.py @@ -0,0 +1,55 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from uuid import UUID +from httpx import AsyncClient +from fastapi.encoders import jsonable_encoder +from sqlalchemy.ext.asyncio import AsyncSession + +from argilla_server.jobs.queues import HIGH_QUEUE +from argilla_server.webhooks.v1.enums import DatasetEvent +from argilla_server.webhooks.v1.datasets import build_dataset_event + +from tests.factories import DatasetFactory, TextFieldFactory, RatingQuestionFactory, WebhookFactory + + +@pytest.mark.asyncio +class TestPublishDataset: + def url(self, dataset_id: UUID) -> str: + return f"/api/v1/datasets/{dataset_id}/publish" + + async def test_publish_dataset_enqueue_webhook_dataset_published_event( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + dataset = await DatasetFactory.create() + await TextFieldFactory.create(dataset=dataset, required=True) + await RatingQuestionFactory.create(dataset=dataset, required=True) + + webhook = await WebhookFactory.create(events=[DatasetEvent.published]) + + response = await async_client.put( + self.url(dataset.id), + headers=owner_auth_header, + ) + + assert response.status_code == 200 + + event = await build_dataset_event(db, DatasetEvent.published, dataset) + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == DatasetEvent.published + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) diff --git a/argilla-server/tests/unit/api/handlers/v1/datasets/test_update_dataset.py b/argilla-server/tests/unit/api/handlers/v1/datasets/test_update_dataset.py index 91c4f54f17..e3dbf842bb 100644 --- a/argilla-server/tests/unit/api/handlers/v1/datasets/test_update_dataset.py +++ b/argilla-server/tests/unit/api/handlers/v1/datasets/test_update_dataset.py @@ -12,13 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. -from uuid import UUID - import pytest + +from uuid import UUID from httpx import AsyncClient +from fastapi.encoders import jsonable_encoder +from sqlalchemy.ext.asyncio import AsyncSession +from argilla_server.jobs.queues import HIGH_QUEUE from argilla_server.enums import DatasetDistributionStrategy, DatasetStatus -from tests.factories import DatasetFactory, RecordFactory, ResponseFactory +from argilla_server.webhooks.v1.datasets import build_dataset_event +from argilla_server.webhooks.v1.enums import DatasetEvent + +from tests.factories import DatasetFactory, RecordFactory, ResponseFactory, WebhookFactory @pytest.mark.asyncio @@ -152,3 +158,24 @@ async def test_update_dataset_distribution_as_none(self, async_client: AsyncClie "strategy": DatasetDistributionStrategy.overlap, "min_submitted": 1, } + + async def test_update_dataset_enqueue_webhook_dataset_updated_event( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + dataset = await DatasetFactory.create() + webhook = await WebhookFactory.create(events=[DatasetEvent.updated]) + + response = await async_client.patch( + self.url(dataset.id), + headers=owner_auth_header, + json={"name": "Updated dataset"}, + ) + + assert response.status_code == 200 + + event = await build_dataset_event(db, DatasetEvent.updated, dataset) + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == DatasetEvent.updated + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) diff --git a/argilla-server/tests/unit/api/handlers/v1/records/test_create_record_response.py b/argilla-server/tests/unit/api/handlers/v1/records/test_create_record_response.py index ce433d036d..68fbd93685 100644 --- a/argilla-server/tests/unit/api/handlers/v1/records/test_create_record_response.py +++ b/argilla-server/tests/unit/api/handlers/v1/records/test_create_record_response.py @@ -12,19 +12,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -from datetime import datetime -from uuid import UUID - import pytest +from uuid import UUID +from datetime import datetime from httpx import AsyncClient from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from fastapi.encoders import jsonable_encoder -from argilla_server.enums import ResponseStatus, RecordStatus, DatasetDistributionStrategy from argilla_server.models import Response, User +from argilla_server.jobs.queues import HIGH_QUEUE +from argilla_server.webhooks.v1.enums import RecordEvent, ResponseEvent +from argilla_server.webhooks.v1.responses import build_response_event +from argilla_server.webhooks.v1.records import build_record_event +from argilla_server.enums import ResponseStatus, RecordStatus, DatasetDistributionStrategy -from tests.factories import DatasetFactory, RecordFactory, SpanQuestionFactory, TextQuestionFactory +from tests.factories import DatasetFactory, RecordFactory, SpanQuestionFactory, TextQuestionFactory, WebhookFactory @pytest.mark.asyncio @@ -516,3 +520,118 @@ async def test_create_record_response_does_not_updates_record_status_to_complete assert response.status_code == 201 assert record.status == RecordStatus.pending + + async def test_create_record_response_enqueue_webhook_response_created_event( + self, db: AsyncSession, async_client: AsyncClient, owner: User, owner_auth_header: dict + ): + dataset = await DatasetFactory.create( + distribution={ + "strategy": DatasetDistributionStrategy.overlap, + "min_submitted": 2, + } + ) + + await TextQuestionFactory.create(name="text-question", dataset=dataset) + + record = await RecordFactory.create(fields={"field-a": "Hello"}, dataset=dataset) + + webhook = await WebhookFactory.create(events=[ResponseEvent.created]) + + resp = await async_client.post( + self.url(record.id), + headers=owner_auth_header, + json={ + "values": { + "text-question": { + "value": "text question response", + }, + }, + "status": ResponseStatus.submitted, + }, + ) + + assert resp.status_code == 201 + + response = (await db.execute(select(Response))).scalar_one() + event = await build_response_event(db, ResponseEvent.created, response) + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == ResponseEvent.created + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) + + async def test_create_record_response_enqueue_webhook_record_updated_event( + self, db: AsyncSession, async_client: AsyncClient, owner: User, owner_auth_header: dict + ): + dataset = await DatasetFactory.create( + distribution={ + "strategy": DatasetDistributionStrategy.overlap, + "min_submitted": 1, + } + ) + + await TextQuestionFactory.create(name="text-question", dataset=dataset) + + record = await RecordFactory.create(fields={"field-a": "Hello"}, dataset=dataset) + + webhook = await WebhookFactory.create(events=[RecordEvent.updated]) + + response = await async_client.post( + self.url(record.id), + headers=owner_auth_header, + json={ + "values": { + "text-question": { + "value": "text question response", + }, + }, + "status": ResponseStatus.submitted, + }, + ) + + assert response.status_code == 201 + + event = await build_record_event(db, RecordEvent.updated, record) + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == RecordEvent.updated + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) + + async def test_create_record_response_enqueue_webhook_record_completed_event( + self, db: AsyncSession, async_client: AsyncClient, owner: User, owner_auth_header: dict + ): + dataset = await DatasetFactory.create( + distribution={ + "strategy": DatasetDistributionStrategy.overlap, + "min_submitted": 1, + } + ) + + await TextQuestionFactory.create(name="text-question", dataset=dataset) + + record = await RecordFactory.create(fields={"field-a": "Hello"}, dataset=dataset) + + webhook = await WebhookFactory.create(events=[RecordEvent.completed]) + + response = await async_client.post( + self.url(record.id), + headers=owner_auth_header, + json={ + "values": { + "text-question": { + "value": "text question response", + }, + }, + "status": ResponseStatus.submitted, + }, + ) + + assert response.status_code == 201 + + event = await build_record_event(db, RecordEvent.completed, record) + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == RecordEvent.completed + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) diff --git a/argilla-server/tests/unit/api/handlers/v1/records/test_delete_record.py b/argilla-server/tests/unit/api/handlers/v1/records/test_delete_record.py new file mode 100644 index 0000000000..ab017e50fa --- /dev/null +++ b/argilla-server/tests/unit/api/handlers/v1/records/test_delete_record.py @@ -0,0 +1,52 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from uuid import UUID +from httpx import AsyncClient +from fastapi.encoders import jsonable_encoder +from sqlalchemy.ext.asyncio import AsyncSession + +from argilla_server.jobs.queues import HIGH_QUEUE +from argilla_server.webhooks.v1.enums import RecordEvent +from argilla_server.webhooks.v1.records import build_record_event + +from tests.factories import RecordFactory, WebhookFactory + + +@pytest.mark.asyncio +class TestDeleteRecord: + def url(self, record_id: UUID) -> str: + return f"/api/v1/records/{record_id}" + + async def test_delete_record_enqueue_webhook_record_deleted_event( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + record = await RecordFactory.create() + webhook = await WebhookFactory.create(events=[RecordEvent.deleted]) + + event = await build_record_event(db, RecordEvent.deleted, record) + + response = await async_client.delete( + self.url(record.id), + headers=owner_auth_header, + ) + + assert response.status_code == 200 + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == RecordEvent.deleted + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) diff --git a/argilla-server/tests/unit/api/handlers/v1/records/test_update_record.py b/argilla-server/tests/unit/api/handlers/v1/records/test_update_record.py new file mode 100644 index 0000000000..d8eba32655 --- /dev/null +++ b/argilla-server/tests/unit/api/handlers/v1/records/test_update_record.py @@ -0,0 +1,53 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from uuid import UUID +from httpx import AsyncClient +from fastapi.encoders import jsonable_encoder +from sqlalchemy.ext.asyncio import AsyncSession + +from argilla_server.jobs.queues import HIGH_QUEUE +from argilla_server.webhooks.v1.enums import RecordEvent +from argilla_server.webhooks.v1.records import build_record_event + +from tests.factories import RecordFactory, WebhookFactory + + +@pytest.mark.asyncio +class TestUpdateRecord: + def url(self, record_id: UUID) -> str: + return f"/api/v1/records/{record_id}" + + async def test_update_record_enqueue_webhook_record_updated_event( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + record = await RecordFactory.create() + webhook = await WebhookFactory.create(events=[RecordEvent.updated]) + + response = await async_client.patch( + self.url(record.id), + headers=owner_auth_header, + json={}, + ) + + assert response.status_code == 200 + + event = await build_record_event(db, RecordEvent.updated, record) + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == RecordEvent.updated + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) diff --git a/argilla-server/tests/unit/api/handlers/v1/responses/test_create_current_user_responses_bulk.py b/argilla-server/tests/unit/api/handlers/v1/responses/test_create_current_user_responses_bulk.py index 07b4bf0199..3cfe3fb7a4 100644 --- a/argilla-server/tests/unit/api/handlers/v1/responses/test_create_current_user_responses_bulk.py +++ b/argilla-server/tests/unit/api/handlers/v1/responses/test_create_current_user_responses_bulk.py @@ -11,28 +11,36 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + import os +import pytest + +from uuid import UUID, uuid4 from datetime import datetime from unittest.mock import call -from uuid import UUID, uuid4 +from httpx import AsyncClient +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi.encoders import jsonable_encoder -import pytest from argilla_server.constants import API_KEY_HEADER_NAME -from argilla_server.enums import ResponseStatus, RecordStatus +from argilla_server.enums import DatasetDistributionStrategy, ResponseStatus, RecordStatus +from argilla_server.jobs.queues import HIGH_QUEUE from argilla_server.models import Response, User from argilla_server.search_engine import SearchEngine from argilla_server.use_cases.responses.upsert_responses_in_bulk import UpsertResponsesInBulkUseCase -from httpx import AsyncClient -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession - +from argilla_server.webhooks.v1.enums import RecordEvent, ResponseEvent +from argilla_server.webhooks.v1.responses import build_response_event +from argilla_server.webhooks.v1.records import build_record_event from tests.factories import ( AnnotatorFactory, DatasetFactory, RatingQuestionFactory, RecordFactory, ResponseFactory, + WebhookFactory, WorkspaceUserFactory, + TextQuestionFactory, ) @@ -447,3 +455,183 @@ async def refresh_records(records): await use_case.execute([bulk_item.item for bulk_item in bulk_items], user) profiler.open_in_browser() + + async def test_create_current_user_responses_bulk_enqueue_webhook_response_created_event( + self, db: AsyncSession, async_client: AsyncClient, owner: User, owner_auth_header: dict + ): + dataset = await DatasetFactory.create( + distribution={ + "strategy": DatasetDistributionStrategy.overlap, + "min_submitted": 2, + }, + ) + + await TextQuestionFactory.create(name="text-question", dataset=dataset) + + record = await RecordFactory.create(fields={"field-a": "Hello"}, dataset=dataset) + + webhook = await WebhookFactory.create(events=[ResponseEvent.created, ResponseEvent.updated]) + + resp = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "items": [ + { + "values": { + "text-question": { + "value": "Created value", + }, + }, + "status": ResponseStatus.submitted, + "record_id": str(record.id), + }, + ], + }, + ) + + assert resp.status_code == 200 + + response = (await db.execute(select(Response))).scalar_one() + event = await build_response_event(db, ResponseEvent.created, response) + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == ResponseEvent.created + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) + + async def test_create_current_user_responses_bulk_enqueue_webhook_response_updated_event( + self, db: AsyncSession, async_client: AsyncClient, owner: User, owner_auth_header: dict + ): + dataset = await DatasetFactory.create( + distribution={ + "strategy": DatasetDistributionStrategy.overlap, + "min_submitted": 2, + }, + ) + + await TextQuestionFactory.create(name="text-question", dataset=dataset) + + record = await RecordFactory.create(fields={"field-a": "Hello"}, dataset=dataset) + + response = await ResponseFactory.create( + values={"text-question": {"value": "Created value"}}, + status=ResponseStatus.submitted, + record=record, + user=owner, + ) + + webhook = await WebhookFactory.create(events=[ResponseEvent.created, ResponseEvent.updated]) + + resp = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "items": [ + { + "values": { + "text-question": { + "value": "Updated value", + }, + }, + "status": ResponseStatus.submitted, + "record_id": str(record.id), + }, + ], + }, + ) + + assert resp.status_code == 200 + + event = await build_response_event(db, ResponseEvent.updated, response) + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == ResponseEvent.updated + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) + + async def test_create_current_user_responses_bulk_enqueue_webhook_record_updated_event( + self, db: AsyncSession, async_client: AsyncClient, owner: User, owner_auth_header: dict + ): + dataset = await DatasetFactory.create( + distribution={ + "strategy": DatasetDistributionStrategy.overlap, + "min_submitted": 1, + }, + ) + + await TextQuestionFactory.create(name="text-question", dataset=dataset) + + record = await RecordFactory.create(fields={"field-a": "Hello"}, dataset=dataset) + + webhook = await WebhookFactory.create(events=[RecordEvent.updated]) + + resp = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "items": [ + { + "values": { + "text-question": { + "value": "Created value", + }, + }, + "status": ResponseStatus.submitted, + "record_id": str(record.id), + }, + ], + }, + ) + + assert resp.status_code == 200 + + event = await build_record_event(db, RecordEvent.updated, record) + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == RecordEvent.updated + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) + + async def test_create_current_user_responses_bulk_enqueue_webhook_record_completed_event( + self, db: AsyncSession, async_client: AsyncClient, owner: User, owner_auth_header: dict + ): + dataset = await DatasetFactory.create( + distribution={ + "strategy": DatasetDistributionStrategy.overlap, + "min_submitted": 1, + }, + ) + + await TextQuestionFactory.create(name="text-question", dataset=dataset) + + record = await RecordFactory.create(fields={"field-a": "Hello"}, dataset=dataset) + + webhook = await WebhookFactory.create(events=[RecordEvent.completed]) + + resp = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "items": [ + { + "values": { + "text-question": { + "value": "Created value", + }, + }, + "status": ResponseStatus.submitted, + "record_id": str(record.id), + }, + ], + }, + ) + + assert resp.status_code == 200 + + event = await build_record_event(db, RecordEvent.completed, record) + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == RecordEvent.completed + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) diff --git a/argilla-server/tests/unit/api/handlers/v1/responses/test_delete_response.py b/argilla-server/tests/unit/api/handlers/v1/responses/test_delete_response.py index 6b9d4ec749..af66f6d2ec 100644 --- a/argilla-server/tests/unit/api/handlers/v1/responses/test_delete_response.py +++ b/argilla-server/tests/unit/api/handlers/v1/responses/test_delete_response.py @@ -12,16 +12,21 @@ # See the License for the specific language governing permissions and # limitations under the License. -from uuid import UUID - import pytest +from uuid import UUID from httpx import AsyncClient +from fastapi.encoders import jsonable_encoder +from sqlalchemy.ext.asyncio import AsyncSession from argilla_server.models import User +from argilla_server.jobs.queues import HIGH_QUEUE +from argilla_server.webhooks.v1.enums import RecordEvent, ResponseEvent +from argilla_server.webhooks.v1.responses import build_response_event +from argilla_server.webhooks.v1.records import build_record_event from argilla_server.enums import DatasetDistributionStrategy, RecordStatus, ResponseStatus -from tests.factories import DatasetFactory, RecordFactory, ResponseFactory, TextQuestionFactory +from tests.factories import DatasetFactory, RecordFactory, ResponseFactory, TextQuestionFactory, WebhookFactory @pytest.mark.asyncio @@ -64,3 +69,56 @@ async def test_delete_response_does_not_updates_record_status_to_pending( assert resp.status_code == 200 assert record.status == RecordStatus.completed + + async def test_delete_response_enqueue_webhook_response_deleted_event( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + response = await ResponseFactory.create() + webhook = await WebhookFactory.create(events=[ResponseEvent.deleted]) + + event = await build_response_event(db, ResponseEvent.deleted, response) + + resp = await async_client.delete(self.url(response.id), headers=owner_auth_header) + + assert resp.status_code == 200 + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == ResponseEvent.deleted + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) + + async def test_delete_response_enqueue_webhook_record_updated_event( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + record = await RecordFactory.create() + responses = await ResponseFactory.create_batch(2, record=record) + webhook = await WebhookFactory.create(events=[RecordEvent.updated]) + + response = await async_client.delete(self.url(responses[0].id), headers=owner_auth_header) + + assert response.status_code == 200 + + event = await build_record_event(db, RecordEvent.updated, record) + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == RecordEvent.updated + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) + + async def test_delete_response_enqueue_webhook_record_completed_event( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + record = await RecordFactory.create() + responses = await ResponseFactory.create_batch(2, record=record) + webhook = await WebhookFactory.create(events=[RecordEvent.completed]) + + response = await async_client.delete(self.url(responses[0].id), headers=owner_auth_header) + + assert response.status_code == 200 + + event = await build_record_event(db, RecordEvent.completed, record) + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == RecordEvent.completed + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) diff --git a/argilla-server/tests/unit/api/handlers/v1/responses/test_update_response.py b/argilla-server/tests/unit/api/handlers/v1/responses/test_update_response.py index d5097f8c7b..4d5f8a4792 100644 --- a/argilla-server/tests/unit/api/handlers/v1/responses/test_update_response.py +++ b/argilla-server/tests/unit/api/handlers/v1/responses/test_update_response.py @@ -12,19 +12,30 @@ # See the License for the specific language governing permissions and # limitations under the License. -from datetime import datetime -from uuid import UUID - import pytest -from httpx import AsyncClient +from uuid import UUID +from datetime import datetime +from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio.session import AsyncSession +from fastapi.encoders import jsonable_encoder -from argilla_server.enums import ResponseStatus, DatasetDistributionStrategy, RecordStatus from argilla_server.models import Response, User +from argilla_server.jobs.queues import HIGH_QUEUE +from argilla_server.webhooks.v1.enums import RecordEvent, ResponseEvent +from argilla_server.webhooks.v1.responses import build_response_event +from argilla_server.webhooks.v1.records import build_record_event +from argilla_server.enums import ResponseStatus, DatasetDistributionStrategy, RecordStatus -from tests.factories import DatasetFactory, RecordFactory, ResponseFactory, SpanQuestionFactory, TextQuestionFactory +from tests.factories import ( + DatasetFactory, + RecordFactory, + ResponseFactory, + SpanQuestionFactory, + TextQuestionFactory, + WebhookFactory, +) @pytest.mark.asyncio @@ -625,3 +636,147 @@ async def test_update_response_updates_record_status_to_pending( assert resp.status_code == 200 assert record.status == RecordStatus.pending + + async def test_update_response_enqueue_webhook_response_updated_event( + self, db: AsyncSession, async_client: AsyncClient, owner: User, owner_auth_header: dict + ): + dataset = await DatasetFactory.create( + distribution={ + "strategy": DatasetDistributionStrategy.overlap, + "min_submitted": 2, + }, + ) + await TextQuestionFactory.create(name="text-question", dataset=dataset) + + record = await RecordFactory.create(fields={"field-a": "Hello"}, dataset=dataset) + + response = await ResponseFactory.create( + values={ + "text-question": { + "value": "Hello", + }, + }, + status=ResponseStatus.submitted, + user=owner, + record=record, + ) + + webhook = await WebhookFactory.create(events=[ResponseEvent.updated]) + + resp = await async_client.put( + self.url(response.id), + headers=owner_auth_header, + json={ + "values": { + "text-question": { + "value": "Update value", + }, + }, + "status": ResponseStatus.submitted, + }, + ) + + assert resp.status_code == 200 + + event = await build_response_event(db, ResponseEvent.updated, response) + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == ResponseEvent.updated + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) + + async def test_update_response_enqueue_webhook_record_updated_event( + self, db: AsyncSession, async_client: AsyncClient, owner: User, owner_auth_header: dict + ): + dataset = await DatasetFactory.create( + distribution={ + "strategy": DatasetDistributionStrategy.overlap, + "min_submitted": 1, + }, + ) + await TextQuestionFactory.create(name="text-question", dataset=dataset) + + record = await RecordFactory.create(fields={"field-a": "Hello"}, dataset=dataset) + + response = await ResponseFactory.create( + values={ + "text-question": { + "value": "Hello", + }, + }, + status=ResponseStatus.draft, + user=owner, + record=record, + ) + + webhook = await WebhookFactory.create(events=[RecordEvent.updated]) + + resp = await async_client.put( + self.url(response.id), + headers=owner_auth_header, + json={ + "values": { + "text-question": { + "value": "Update value", + }, + }, + "status": ResponseStatus.submitted, + }, + ) + + assert resp.status_code == 200 + + event = await build_record_event(db, RecordEvent.updated, record) + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == RecordEvent.updated + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) + + async def test_update_response_enqueue_webhook_record_completed_event( + self, db: AsyncSession, async_client: AsyncClient, owner: User, owner_auth_header: dict + ): + dataset = await DatasetFactory.create( + distribution={ + "strategy": DatasetDistributionStrategy.overlap, + "min_submitted": 1, + }, + ) + await TextQuestionFactory.create(name="text-question", dataset=dataset) + + record = await RecordFactory.create(fields={"field-a": "Hello"}, dataset=dataset) + + response = await ResponseFactory.create( + values={ + "text-question": { + "value": "Hello", + }, + }, + status=ResponseStatus.draft, + user=owner, + record=record, + ) + + webhook = await WebhookFactory.create(events=[RecordEvent.completed]) + + resp = await async_client.put( + self.url(response.id), + headers=owner_auth_header, + json={ + "values": { + "text-question": { + "value": "Update value", + }, + }, + "status": ResponseStatus.submitted, + }, + ) + + assert resp.status_code == 200 + + event = await build_record_event(db, RecordEvent.completed, record) + + assert HIGH_QUEUE.count == 1 + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == RecordEvent.completed + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event.data) diff --git a/argilla-server/tests/unit/jobs/__init__.py b/argilla-server/tests/unit/jobs/__init__.py new file mode 100644 index 0000000000..4b6cecae7f --- /dev/null +++ b/argilla-server/tests/unit/jobs/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/argilla-server/tests/unit/jobs/webhook_jobs/__init__.py b/argilla-server/tests/unit/jobs/webhook_jobs/__init__.py new file mode 100644 index 0000000000..4b6cecae7f --- /dev/null +++ b/argilla-server/tests/unit/jobs/webhook_jobs/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/argilla-server/tests/unit/jobs/webhook_jobs/test_enqueue_notify_events.py b/argilla-server/tests/unit/jobs/webhook_jobs/test_enqueue_notify_events.py new file mode 100644 index 0000000000..4f50f00ca2 --- /dev/null +++ b/argilla-server/tests/unit/jobs/webhook_jobs/test_enqueue_notify_events.py @@ -0,0 +1,58 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime +import pytest + +from fastapi.encoders import jsonable_encoder +from sqlalchemy.ext.asyncio import AsyncSession + +from argilla_server.jobs.queues import HIGH_QUEUE +from argilla_server.jobs.webhook_jobs import enqueue_notify_events +from argilla_server.webhooks.v1.enums import ResponseEvent +from argilla_server.webhooks.v1.responses import build_response_event + +from tests.factories import ResponseFactory, WebhookFactory + + +@pytest.mark.asyncio +class TestEnqueueNotifyEvents: + async def test_enqueue_notify_events(self, db: AsyncSession): + response = await ResponseFactory.create() + + webhooks = await WebhookFactory.create_batch(2, events=[ResponseEvent.created]) + webhooks_disabled = await WebhookFactory.create_batch(2, events=[ResponseEvent.created], enabled=False) + webhooks_with_other_events = await WebhookFactory.create_batch(2, events=[ResponseEvent.deleted]) + + event = await build_response_event(db, ResponseEvent.created, response) + jsonable_data = jsonable_encoder(event.data) + + await enqueue_notify_events( + db=db, + event=ResponseEvent.created, + timestamp=event.timestamp, + data=jsonable_data, + ) + + assert HIGH_QUEUE.count == 2 + + assert HIGH_QUEUE.jobs[0].args[0] == webhooks[0].id + assert HIGH_QUEUE.jobs[0].args[1] == ResponseEvent.created + assert HIGH_QUEUE.jobs[0].args[2] == event.timestamp + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_data + + assert HIGH_QUEUE.jobs[1].args[0] == webhooks[1].id + assert HIGH_QUEUE.jobs[1].args[1] == ResponseEvent.created + assert HIGH_QUEUE.jobs[1].args[2] == event.timestamp + assert HIGH_QUEUE.jobs[1].args[3] == jsonable_data From 78193c74f63866fc22393f300864edb3825f8b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Wed, 25 Sep 2024 17:52:40 +0200 Subject: [PATCH 12/24] chore: update CHANGELOG.md --- argilla-server/CHANGELOG.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/argilla-server/CHANGELOG.md b/argilla-server/CHANGELOG.md index c53b7e61de..41deed1ddb 100644 --- a/argilla-server/CHANGELOG.md +++ b/argilla-server/CHANGELOG.md @@ -16,17 +16,15 @@ These are the section headers that we use: ## [Unreleased]() -## [2.2.0](https://github.com/argilla-io/argilla/compare/v2.1.0...v2.2.0) - ### Added - Added filtering by `name`, and `status` support to endpoint `GET /api/v1/me/datasets`. ([#5374](https://github.com/argilla-io/argilla/pull/5374)) - Added new endpoints to create, update, ping and delete webhooks. ([#5453](https://github.com/argilla-io/argilla/pull/5453)) -- Added new webhook events when responses are created, updated, deleted or upserted. ([#5468](https://github.com/argilla-io/argilla/pull/5468)) +- Added new webhook events when responses are created, updated, deleted. ([#5468](https://github.com/argilla-io/argilla/pull/5468)) - Added new webhook events when datasets are created, updated, deleted or published. ([#5468](https://github.com/argilla-io/argilla/pull/5468)) - Added new webhook events when records are created, updated, deleted or completed. ([#5489](https://github.com/argilla-io/argilla/pull/5489)) -## [2.2.0]() +## [2.2.0](https://github.com/argilla-io/argilla/compare/v2.1.0...v2.2.0) ### Added From 922c59c233f9b873a10a5a82afcbbddd7d15488f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Thu, 26 Sep 2024 15:51:27 +0200 Subject: [PATCH 13/24] feat: increase webhook requests timeout from 5 to 20 seconds (#5544) # Description This PR increases the timeout for webhook requests from `5` seconds to `20` seconds so the webhook listeners have more time to generate a response. Refs #1836 **Type of change** - Improvement (change adding some improvement to an existing functionality) **How Has This Been Tested** - [x] Test suite should be passing. **Checklist** - I added relevant documentation - I followed the style guidelines of this project - I did a self-review of my code - I made corresponding changes to the documentation - I confirm My changes generate no new warnings - I have added tests that prove my fix is effective or that my feature works - I have added relevant notes to the CHANGELOG.md file (See https://keepachangelog.com/) --- argilla-server/src/argilla_server/webhooks/v1/commons.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argilla-server/src/argilla_server/webhooks/v1/commons.py b/argilla-server/src/argilla_server/webhooks/v1/commons.py index 860a8f81ce..bf1a94af7c 100644 --- a/argilla-server/src/argilla_server/webhooks/v1/commons.py +++ b/argilla-server/src/argilla_server/webhooks/v1/commons.py @@ -25,7 +25,7 @@ MSG_ID_BYTES_LENGTH = 16 -NOTIFY_EVENT_DEFAULT_TIMEOUT = httpx.Timeout(timeout=5.0) +NOTIFY_EVENT_DEFAULT_TIMEOUT = httpx.Timeout(timeout=20.0) # NOTE: We are using standard webhooks implementation. From 3bef0e0aa2d9e62b634fbc3472805d2ce17f4592 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Oct 2024 14:08:11 +0000 Subject: [PATCH 14/24] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../records/records_bulk/test_create_dataset_records_bulk.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_create_dataset_records_bulk.py b/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_create_dataset_records_bulk.py index 58884d5854..7e05619904 100644 --- a/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_create_dataset_records_bulk.py +++ b/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_create_dataset_records_bulk.py @@ -599,7 +599,6 @@ async def test_create_dataset_records_bulk_enqueue_webhook_record_created_events webhook = await WebhookFactory.create(events=[RecordEvent.created]) - async def test_create_dataset_records_bulk_with_custom_field_values( self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict ): @@ -617,7 +616,6 @@ async def test_create_dataset_records_bulk_with_custom_field_values( "fields": { "prompt": "Does exercise help reduce stress?", "custom": {"a": 1, "b": 2}, - }, }, { @@ -683,4 +681,3 @@ async def test_create_dataset_records_bulk_with_wrong_custom_field_value( assert response.status_code == 422 assert (await db.execute(select(func.count(Record.id)))).scalar_one() == 0 - From dc52185c9547fd9dab7baa9859803f1a11d22aba Mon Sep 17 00:00:00 2001 From: Paco Aranda Date: Wed, 2 Oct 2024 16:21:59 +0200 Subject: [PATCH 15/24] Apply suggestions from code review --- .../records/records_bulk/test_create_dataset_records_bulk.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_create_dataset_records_bulk.py b/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_create_dataset_records_bulk.py index 7e05619904..155b04641a 100644 --- a/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_create_dataset_records_bulk.py +++ b/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_create_dataset_records_bulk.py @@ -614,13 +614,11 @@ async def test_create_dataset_records_bulk_with_custom_field_values( "items": [ { "fields": { - "prompt": "Does exercise help reduce stress?", "custom": {"a": 1, "b": 2}, }, }, { "fields": { - "prompt": "What is the best way to reduce stress?", "custom": {"c": 1, "b": 2}, }, }, From 8a90dccd12f83a64d546d761098ee499d85021a5 Mon Sep 17 00:00:00 2001 From: Francisco Aranda Date: Wed, 2 Oct 2024 16:44:27 +0200 Subject: [PATCH 16/24] tests: Fixing tests after merge --- .../test_create_dataset_records_bulk.py | 205 +++++++++++++++--- .../test_create_dataset_records_bulk.py | 145 ------------- 2 files changed, 173 insertions(+), 177 deletions(-) delete mode 100644 argilla-server/tests/unit/api/handlers/v1/records/test_create_dataset_records_bulk.py diff --git a/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_create_dataset_records_bulk.py b/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_create_dataset_records_bulk.py index 155b04641a..64d99656e7 100644 --- a/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_create_dataset_records_bulk.py +++ b/argilla-server/tests/unit/api/handlers/v1/datasets/records/records_bulk/test_create_dataset_records_bulk.py @@ -12,20 +12,26 @@ # See the License for the specific language governing permissions and # limitations under the License. -import pytest - from uuid import UUID -from httpx import AsyncClient + +import pytest from fastapi.encoders import jsonable_encoder +from httpx import AsyncClient from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from argilla_server.enums import ( + DatasetStatus, + QuestionType, + ResponseStatus, + SuggestionType, + RecordStatus, + DatasetDistributionStrategy, +) from argilla_server.jobs.queues import HIGH_QUEUE +from argilla_server.models.database import Record, Response, Suggestion, User from argilla_server.webhooks.v1.enums import RecordEvent from argilla_server.webhooks.v1.records import build_record_event -from argilla_server.models.database import Record, Response, Suggestion, User -from argilla_server.enums import DatasetStatus, QuestionType, ResponseStatus, SuggestionType - from tests.factories import ( DatasetFactory, LabelSelectionQuestionFactory, @@ -39,6 +45,7 @@ ChatFieldFactory, CustomFieldFactory, WebhookFactory, + AnnotatorFactory, ) @@ -590,15 +597,6 @@ async def test_create_dataset_records_bulk_with_chat_field_without_content_key( } assert (await db.execute(select(func.count(Record.id)))).scalar_one() == 0 - async def test_create_dataset_records_bulk_enqueue_webhook_record_created_events( - self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict - ): - dataset = await DatasetFactory.create(status=DatasetStatus.ready) - await TextFieldFactory.create(name="prompt", dataset=dataset) - await TextQuestionFactory.create(name="text-question", dataset=dataset) - - webhook = await WebhookFactory.create(events=[RecordEvent.created]) - async def test_create_dataset_records_bulk_with_custom_field_values( self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict ): @@ -631,23 +629,6 @@ async def test_create_dataset_records_bulk_with_custom_field_values( }, ) - assert response.status_code == 201 - - records = (await db.execute(select(Record).order_by(Record.inserted_at.asc()))).scalars().all() - - event_a = await build_record_event(db, RecordEvent.created, records[0]) - event_b = await build_record_event(db, RecordEvent.created, records[1]) - - assert HIGH_QUEUE.count == 2 - - assert HIGH_QUEUE.jobs[0].args[0] == webhook.id - assert HIGH_QUEUE.jobs[0].args[1] == RecordEvent.created - assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event_a.data) - - assert HIGH_QUEUE.jobs[1].args[0] == webhook.id - assert HIGH_QUEUE.jobs[1].args[1] == RecordEvent.created - assert HIGH_QUEUE.jobs[1].args[3] == jsonable_encoder(event_b.data) - assert response.status_code == 201, response.json() records = (await db.execute(select(Record))).scalars().all() assert len(records) == 3 @@ -679,3 +660,163 @@ async def test_create_dataset_records_bulk_with_wrong_custom_field_value( assert response.status_code == 422 assert (await db.execute(select(func.count(Record.id)))).scalar_one() == 0 + + async def test_create_dataset_records_bulk_updates_records_status( + self, db: AsyncSession, async_client: AsyncClient, owner: User, owner_auth_header: dict + ): + dataset = await DatasetFactory.create( + status=DatasetStatus.ready, + distribution={ + "strategy": DatasetDistributionStrategy.overlap, + "min_submitted": 2, + }, + ) + + user = await AnnotatorFactory.create(workspaces=[dataset.workspace]) + + await TextFieldFactory.create(name="prompt", dataset=dataset) + await TextFieldFactory.create(name="response", dataset=dataset) + + await TextQuestionFactory.create(name="text-question", dataset=dataset) + + response = await async_client.post( + self.url(dataset.id), + headers=owner_auth_header, + json={ + "items": [ + { + "fields": { + "prompt": "Does exercise help reduce stress?", + "response": "Exercise can definitely help reduce stress.", + }, + "responses": [ + { + "user_id": str(owner.id), + "status": ResponseStatus.submitted, + "values": { + "text-question": { + "value": "text question response", + }, + }, + }, + { + "user_id": str(user.id), + "status": ResponseStatus.submitted, + "values": { + "text-question": { + "value": "text question response", + }, + }, + }, + ], + }, + { + "fields": { + "prompt": "Does exercise help reduce stress?", + "response": "Exercise can definitely help reduce stress.", + }, + "responses": [ + { + "user_id": str(owner.id), + "status": ResponseStatus.submitted, + "values": { + "text-question": { + "value": "text question response", + }, + }, + }, + ], + }, + { + "fields": { + "prompt": "Does exercise help reduce stress?", + "response": "Exercise can definitely help reduce stress.", + }, + "responses": [ + { + "user_id": str(owner.id), + "status": ResponseStatus.draft, + "values": { + "text-question": { + "value": "text question response", + }, + }, + }, + { + "user_id": str(user.id), + "status": ResponseStatus.draft, + "values": { + "text-question": { + "value": "text question response", + }, + }, + }, + ], + }, + { + "fields": { + "prompt": "Does exercise help reduce stress?", + "response": "Exercise can definitely help reduce stress.", + }, + }, + ], + }, + ) + + assert response.status_code == 201 + + response_items = response.json()["items"] + assert response_items[0]["status"] == RecordStatus.completed + assert response_items[1]["status"] == RecordStatus.pending + assert response_items[2]["status"] == RecordStatus.pending + assert response_items[3]["status"] == RecordStatus.pending + + assert (await Record.get(db, UUID(response_items[0]["id"]))).status == RecordStatus.completed + assert (await Record.get(db, UUID(response_items[1]["id"]))).status == RecordStatus.pending + assert (await Record.get(db, UUID(response_items[2]["id"]))).status == RecordStatus.pending + assert (await Record.get(db, UUID(response_items[3]["id"]))).status == RecordStatus.pending + + async def test_create_dataset_records_bulk_enqueue_webhook_record_created_events( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + dataset = await DatasetFactory.create(status=DatasetStatus.ready) + await TextFieldFactory.create(name="prompt", dataset=dataset) + await TextQuestionFactory.create(name="text-question", dataset=dataset) + + webhook = await WebhookFactory.create(events=[RecordEvent.created]) + + response = await async_client.post( + self.url(dataset.id), + headers=owner_auth_header, + json={ + "items": [ + { + "fields": { + "prompt": "You should exercise more.", + }, + }, + { + "fields": { + "prompt": "Do you like to exercise?", + }, + }, + ], + }, + ) + + assert response.status_code == 201, response.json() + + records = (await db.execute(select(Record).order_by(Record.inserted_at.asc()))).scalars().all() + + event_a = await build_record_event(db, RecordEvent.created, records[0]) + event_b = await build_record_event(db, RecordEvent.created, records[1]) + + assert HIGH_QUEUE.count == 2 + + assert HIGH_QUEUE.jobs[0].args[0] == webhook.id + assert HIGH_QUEUE.jobs[0].args[1] == RecordEvent.created + assert HIGH_QUEUE.jobs[0].args[3] == jsonable_encoder(event_a.data) + + assert HIGH_QUEUE.jobs[1].args[0] == webhook.id + assert HIGH_QUEUE.jobs[1].args[1] == RecordEvent.created + assert HIGH_QUEUE.jobs[1].args[3] == jsonable_encoder(event_b.data) diff --git a/argilla-server/tests/unit/api/handlers/v1/records/test_create_dataset_records_bulk.py b/argilla-server/tests/unit/api/handlers/v1/records/test_create_dataset_records_bulk.py deleted file mode 100644 index 1aae133535..0000000000 --- a/argilla-server/tests/unit/api/handlers/v1/records/test_create_dataset_records_bulk.py +++ /dev/null @@ -1,145 +0,0 @@ -# Copyright 2021-present, the Recognai S.L. team. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import pytest - -from uuid import UUID -from httpx import AsyncClient -from sqlalchemy.ext.asyncio import AsyncSession - -from argilla_server.models import User, Record -from argilla_server.enums import DatasetDistributionStrategy, RecordStatus, ResponseStatus, DatasetStatus - -from tests.factories import AnnotatorFactory, DatasetFactory, TextFieldFactory, TextQuestionFactory - - -@pytest.mark.asyncio -class TestCreateDatasetRecordsBulk: - def url(self, dataset_id: UUID) -> str: - return f"/api/v1/datasets/{dataset_id}/records/bulk" - - async def test_create_dataset_records_bulk_updates_records_status( - self, db: AsyncSession, async_client: AsyncClient, owner: User, owner_auth_header: dict - ): - dataset = await DatasetFactory.create( - status=DatasetStatus.ready, - distribution={ - "strategy": DatasetDistributionStrategy.overlap, - "min_submitted": 2, - }, - ) - - user = await AnnotatorFactory.create(workspaces=[dataset.workspace]) - - await TextFieldFactory.create(name="prompt", dataset=dataset) - await TextFieldFactory.create(name="response", dataset=dataset) - - await TextQuestionFactory.create(name="text-question", dataset=dataset) - - response = await async_client.post( - self.url(dataset.id), - headers=owner_auth_header, - json={ - "items": [ - { - "fields": { - "prompt": "Does exercise help reduce stress?", - "response": "Exercise can definitely help reduce stress.", - }, - "responses": [ - { - "user_id": str(owner.id), - "status": ResponseStatus.submitted, - "values": { - "text-question": { - "value": "text question response", - }, - }, - }, - { - "user_id": str(user.id), - "status": ResponseStatus.submitted, - "values": { - "text-question": { - "value": "text question response", - }, - }, - }, - ], - }, - { - "fields": { - "prompt": "Does exercise help reduce stress?", - "response": "Exercise can definitely help reduce stress.", - }, - "responses": [ - { - "user_id": str(owner.id), - "status": ResponseStatus.submitted, - "values": { - "text-question": { - "value": "text question response", - }, - }, - }, - ], - }, - { - "fields": { - "prompt": "Does exercise help reduce stress?", - "response": "Exercise can definitely help reduce stress.", - }, - "responses": [ - { - "user_id": str(owner.id), - "status": ResponseStatus.draft, - "values": { - "text-question": { - "value": "text question response", - }, - }, - }, - { - "user_id": str(user.id), - "status": ResponseStatus.draft, - "values": { - "text-question": { - "value": "text question response", - }, - }, - }, - ], - }, - { - "fields": { - "prompt": "Does exercise help reduce stress?", - "response": "Exercise can definitely help reduce stress.", - }, - }, - ], - }, - ) - - assert response.status_code == 201 - - response_items = response.json()["items"] - assert response_items[0]["status"] == RecordStatus.completed - assert response_items[1]["status"] == RecordStatus.pending - assert response_items[2]["status"] == RecordStatus.pending - assert response_items[3]["status"] == RecordStatus.pending - - assert (await Record.get(db, UUID(response_items[0]["id"]))).status == RecordStatus.completed - assert (await Record.get(db, UUID(response_items[1]["id"]))).status == RecordStatus.pending - assert (await Record.get(db, UUID(response_items[2]["id"]))).status == RecordStatus.pending - assert (await Record.get(db, UUID(response_items[3]["id"]))).status == RecordStatus.pending From 22dfb9225b4d54095a79d7ca67a5e4ebe747bb3a Mon Sep 17 00:00:00 2001 From: Paco Aranda Date: Tue, 8 Oct 2024 17:31:10 +0200 Subject: [PATCH 17/24] [FEAUTURE] `argilla`: working with webhooks (#5502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description This PR adds the `argilla.webhooks` module to work with webhooks from the SDK. With the changes on this PR, users can easily create webhook listeners using the python SDK. For example, listening changes can be defined as follows: ```python import argilla as rg @rg.webhook_listener(events="response.updated") async def on_response_updated(response: rg.UserResponse, **kwargs): ... # do your work @rg.webhook_listener(events=["dataset.created", "dataset.updated", "dataset.published"]) async def on_dataset_event( type: str, timestamp: datetime, dataset: rg.Dataset, **kwargs, ): print(f"Event type {type} at {timestamp}") print(dataset.settings) ``` You can find a fully basic example using webhooks [here](https://github.com/argilla-io/argilla/tree/feat/argilla/working-with-webhooks/examples/webhooks/basic-webhooks) This is still a draft PR and the final feature may change. Refs: https://github.com/argilla-io/argilla/issues/4658 **Type of change** - New feature (non-breaking change which adds functionality) **How Has This Been Tested** **Checklist** - I added relevant documentation - I followed the style guidelines of this project - I did a self-review of my code - I made corresponding changes to the documentation - I confirm My changes generate no new warnings - I have added tests that prove my fix is effective or that my feature works - I have added relevant notes to the CHANGELOG.md file (See https://keepachangelog.com/) --------- Co-authored-by: David Berenstein Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Sara Han <127759186+sdiazlor@users.noreply.github.com> Co-authored-by: José Francisco Calvo --- argilla/docs/how_to_guides/index.md | 18 +- argilla/docs/how_to_guides/webhooks.md | 160 ++ .../docs/how_to_guides/webhooks_internals.md | 1863 +++++++++++++++++ argilla/docs/reference/argilla/SUMMARY.md | 1 + argilla/docs/reference/argilla/webhooks.md | 61 + argilla/mkdocs.yml | 1 + argilla/pdm.lock | 48 +- argilla/pyproject.toml | 1 + argilla/src/argilla/__init__.py | 1 + argilla/src/argilla/_api/_webhooks.py | 19 +- .../src/argilla/_helpers/_resource_repr.py | 1 + argilla/src/argilla/_models/__init__.py | 1 + argilla/src/argilla/_models/_webhook.py | 72 + argilla/src/argilla/client.py | 71 +- argilla/src/argilla/responses.py | 10 + argilla/src/argilla/webhooks/__init__.py | 43 + argilla/src/argilla/webhooks/_event.py | 179 ++ argilla/src/argilla/webhooks/_handler.py | 78 + argilla/src/argilla/webhooks/_helpers.py | 202 ++ argilla/src/argilla/webhooks/_resource.py | 98 + examples/webhooks/basic-webhooks/README.md | 31 + examples/webhooks/basic-webhooks/main.py | 76 + .../webhooks/basic-webhooks/requirements.txt | 3 + 23 files changed, 3015 insertions(+), 23 deletions(-) create mode 100644 argilla/docs/how_to_guides/webhooks.md create mode 100644 argilla/docs/how_to_guides/webhooks_internals.md create mode 100644 argilla/docs/reference/argilla/webhooks.md create mode 100644 argilla/src/argilla/_models/_webhook.py create mode 100644 argilla/src/argilla/webhooks/__init__.py create mode 100644 argilla/src/argilla/webhooks/_event.py create mode 100644 argilla/src/argilla/webhooks/_handler.py create mode 100644 argilla/src/argilla/webhooks/_helpers.py create mode 100644 argilla/src/argilla/webhooks/_resource.py create mode 100644 examples/webhooks/basic-webhooks/README.md create mode 100644 examples/webhooks/basic-webhooks/main.py create mode 100644 examples/webhooks/basic-webhooks/requirements.txt diff --git a/argilla/docs/how_to_guides/index.md b/argilla/docs/how_to_guides/index.md index d1141d2efc..c590a730b0 100644 --- a/argilla/docs/how_to_guides/index.md +++ b/argilla/docs/how_to_guides/index.md @@ -82,6 +82,22 @@ These guides provide step-by-step instructions for common scenarios, including d
+- __Use webhooks to respond to server events__ + + --- + + Learn how to use Argilla webhooks to receive notifications about events in your Argilla Server. + + [:octicons-arrow-right-24: How-to guide](webhooks.md) + +- __Webhooks internals__ + + --- + + Learn how Argilla webhooks are implented under the hood and the structure of the different events. + + [:octicons-arrow-right-24: How-to guide](webhooks_internals.md) + - __Use Markdown to format rich content__ --- @@ -98,4 +114,4 @@ These guides provide step-by-step instructions for common scenarios, including d [:octicons-arrow-right-24: How-to guide](migrate_from_legacy_datasets.md) -
\ No newline at end of file + diff --git a/argilla/docs/how_to_guides/webhooks.md b/argilla/docs/how_to_guides/webhooks.md new file mode 100644 index 0000000000..0b0ea0f214 --- /dev/null +++ b/argilla/docs/how_to_guides/webhooks.md @@ -0,0 +1,160 @@ +--- +description: In this section, we will provide a step-by-step guide to create a webhook in Argilla. +--- + +# Use Argilla webhooks + +This guide provides an overview of how to create and use webhooks in Argilla. + +A **webhook** allows an application to submit real-time information to other applications whenever a specific event occurs. Unlike traditional APIs, you won’t need to poll for data very frequently in order to get it in real time. This makes webhooks much more efficient for both the provider and the consumer. + +## Creating a webhook listener in Argilla + +The python SDK provides a simple way to create a webhook in Argilla. It allows you to focus on the use case of the webhook and not on the implementation details. You only need to create your event handler function with the `webhook_listener` decorator. + +```python +import argilla as rg + +from datetime import datetime +from argilla import webhook_listener + +@webhook_listener(events="dataset.created") +async def my_webhook_handler(dataset: rg.Dataset, type: str, timestamp: datetime): + print(dataset, type, timestamp) +``` + +In the example above, we have created a webhook that listens to the `dataset.created` event. +> You can find the list of events in the [Events](#events) section. + +The python SDK will automatically create a webhook in Argilla and listen to the specified event. When the event is triggered, +the `my_webhook_handler` function will be called with the event data. The SDK will also parse the incoming webhook event into +a proper resource object (`rg.Dataset`, `rg.Record`, and `rg.Response`). The SDK will also take care of request authentication and error handling. + +## Running the webhook server + +Under the hood, the SDK uses the `FastAPI` framework to create the webhook server and the POST endpoint to receive the webhook events. + +To run the webhook, you need to define the webhook server in your code and start it using the `uvicorn` command. + +```python +# my_webhook.py file +from argilla import get_webhook_server + +server = get_webhook_server() +``` + +```bash +uvicorn my_webhook:server +``` + +You can explore the Swagger UI to explore your defined webhooks by visiting `http://localhost:8000/docs`. + + +The `uvicorn` command will start the webhook server on the default port `8000`. + +By default, the Python SDK will register the webhook using the server URL `http://127.0.0.1:8000/`. If you want to use a different server URL, you can set the `WEBHOOK_SERVER_URL` environment variable. + +```bash +export WEBHOOK_SERVER_URL=http://my-webhook-server.com +``` + +All incoming webhook events will be sent to the specified server URL. + +## Webhooks management + +The Python SDK provides a simple way to manage webhooks in Argilla. You can create, list, update, and delete webhooks using the SDK. + +### Create a webhook + +To create a new webhook in Argilla, you can define it in the `Webhook` class and then call the `create` method. + +```python +import argilla as rg + +client = rg.Argilla(api_url="", api_key="") + +webhook = rg.Webhook( + url="http://127.0.0.1:8000", + events=["dataset.created"], + description="My webhook" +) + +webhook.create() + +``` + +### List webhooks + +You can list all the existing webhooks in Argilla by accessing the `webhooks` attribute on the Argilla class and iterating over them. + +```python +import argilla as rg + +client = rg.Argilla(api_url="", api_key="") + +for webhook in client.webhooks: + print(webhook) + +``` + +### Update a webhook + +You can update a webhook using the `update` method. + +```python +import argilla as rg + +client = rg.Argilla(api_url="", api_key="") + +webhook = rg.Webhook( + url="http://127.0.0.1:8000", + events=["dataset.created"], + description="My webhook" +).create() + +webhook.events = ["dataset.updated"] +webhook.update() + +``` +> You should use IP address instead of localhost since the webhook validation expect a Top Level Domain (TLD) in the URL. + +### Delete a webhook + +You can delete a webhook using the `delete` method. + +```python +import argilla as rg + +client = rg.Argilla(api_url="", api_key="") + +for webhook in client.webhooks: + webhook.delete() + +``` + +## Deploying a webhooks server in a Hugging Face Space + +You can deploy your webhook in a Hugging Face Space. You can visit this [link](https://huggingface.co/spaces/argilla/argilla-webhooks/tree/main) to explore an example of a webhook server deployed in a Hugging Face Space. + + +## Events + +The following is a list of events that you can listen to in Argilla, grouped by resource type. + +### Dataset events + +- `dataset.created`: The Dataset resource was created. +- `dataset.updated`: The Dataset resource was updated. +- `dataset.deleted`: The Dataset resource was deleted. +- `dataset.published`: The Dataset resource was published. + +### Record events +- `record.created`: The Record resource was created. +- `record.updated`: The Record resource was updated. +- `record.deleted`: The Record resource was deleted. +- `record.completed`: The Record resource was completed (status="completed"). + +### Response events +- `response.created`: The Response resource was created. +- `response.updated`: The Response resource was updated. +- `response.deleted`: The Response resource was deleted. diff --git a/argilla/docs/how_to_guides/webhooks_internals.md b/argilla/docs/how_to_guides/webhooks_internals.md new file mode 100644 index 0000000000..180d9a0e28 --- /dev/null +++ b/argilla/docs/how_to_guides/webhooks_internals.md @@ -0,0 +1,1863 @@ +# Webhooks internal + +Argilla Webhooks implements [Standard Webhooks](https://www.standardwebhooks.com) to facilitate the integration of Argilla with listeners written in any language and ensure consistency and security. If you need to do a custom integration with Argilla webhooks take a look to the [specs](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) to have a better understanding of how to implement such integration. + +## Events payload + +The payload is the core part of every webhook. It is the actual data being sent as part of the webhook, and usually consists of important information about the event and related information. + +The payloads sent by Argilla webhooks will be a POST request with a JSON body with the following structure: + +```json +{ + "type": "example.event", + "version": 1, + "timestamp": "2022-11-03T20:26:10.344522Z", + "data": { + "foo": "bar", + } +} +``` + +Your listener must return any `2XX` status code value to indicate to Argilla that the webhook message has been successfully received. If a different status code is returned Argilla will retry up to 3 times. You have up to 20 seconds to give a response to an Argilla webhook request. + +The payload attributes are: + +* `type`: a full-stop delimited type string associated with the event. The type indicates the type of the event being sent. (e.g `"dataset.created"` or `"record.completed"`), indicates the schema of the payload (passed in `data` attribute).The following are the values that can be present on this attribute: + * `dataset.created` + * `dataset.updated` + * `dataset.deleted` + * `dataset.published` + * `record.created` + * `record.updated` + * `record.deleted` + * `record.completed` + * `response.created` + * `response.updated` + * `response.deleted` +* `version`: an integer with the version of the webhook payload sent. Right now we only support version `1`. +* `timestamp`: the timestamp of when the event occurred. +* `data`: the actual event data associated with the event. + +## Events payload examples + +In this section we will show payload examples for all the events emitted by Argilla webhooks. + +### Dataset events + +#### Created + +```json +{ + "type": "dataset.created", + "version": 1, + "timestamp": "2024-09-26T14:17:20.488053Z", + "data": { + "id": "3d673549-ad31-4485-97eb-31f9dcd0df71", + "name": "fineweb-edu-min", + "guidelines": null, + "allow_extra_metadata": true, + "status": "draft", + "distribution": { + "strategy": "overlap", + "min_submitted": 1 + }, + "workspace": { + "id": "350bc020-2cd2-4a67-8b23-37a15c4d8139", + "name": "argilla", + "inserted_at": "2024-09-05T11:39:20.377192", + "updated_at": "2024-09-05T11:39:20.377192" + }, + "questions": [], + "fields": [], + "metadata_properties": [], + "vectors_settings": [], + "last_activity_at": "2024-09-26T14:17:20.477163", + "inserted_at": "2024-09-26T14:17:20.477163", + "updated_at": "2024-09-26T14:17:20.477163" + } +} +``` + +#### Updated + +```json +{ + "type": "dataset.updated", + "version": 1, + "timestamp": "2024-09-26T14:17:20.504483Z", + "data": { + "id": "3d673549-ad31-4485-97eb-31f9dcd0df71", + "name": "fineweb-edu-min", + "guidelines": null, + "allow_extra_metadata": false, + "status": "draft", + "distribution": { + "strategy": "overlap", + "min_submitted": 1 + }, + "workspace": { + "id": "350bc020-2cd2-4a67-8b23-37a15c4d8139", + "name": "argilla", + "inserted_at": "2024-09-05T11:39:20.377192", + "updated_at": "2024-09-05T11:39:20.377192" + }, + "questions": [], + "fields": [ + { + "id": "77578693-9925-4c3d-a921-8c964cdd7acd", + "name": "text", + "title": "text", + "required": true, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-26T14:17:20.528738", + "updated_at": "2024-09-26T14:17:20.528738" + } + ] + "metadata_properties": [], + "vectors_settings": [], + "last_activity_at": "2024-09-26T14:17:20.497343", + "inserted_at": "2024-09-26T14:17:20.477163", + "updated_at": "2024-09-26T14:17:20.497343" + } +} +``` + +#### Deleted + +```json +{ + "type": "dataset.deleted", + "version": 1, + "timestamp": "2024-09-26T14:21:44.261872Z", + "data": { + "id": "3d673549-ad31-4485-97eb-31f9dcd0df71", + "name": "fineweb-edu-min", + "guidelines": null, + "allow_extra_metadata": false, + "status": "ready", + "distribution": { + "strategy": "overlap", + "min_submitted": 1 + }, + "workspace": { + "id": "350bc020-2cd2-4a67-8b23-37a15c4d8139", + "name": "argilla", + "inserted_at": "2024-09-05T11:39:20.377192", + "updated_at": "2024-09-05T11:39:20.377192" + }, + "questions": [ + { + "id": "80069251-4792-49e7-b58a-69a6117e8d32", + "name": "int_score", + "title": "Rate the quality of the text", + "description": null, + "required": true, + "settings": { + "type": "rating", + "options": [ + { + "value": 0 + }, + { + "value": 1 + }, + { + "value": 2 + }, + { + "value": 3 + }, + { + "value": 4 + }, + { + "value": 5 + } + ] + }, + "inserted_at": "2024-09-26T14:17:20.541716", + "updated_at": "2024-09-26T14:17:20.541716" + }, + { + "id": "5e7b45c3-b863-48c8-a1e8-2caa279b71e7", + "name": "comments", + "title": "Comments:", + "description": null, + "required": false, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-26T14:17:20.551750", + "updated_at": "2024-09-26T14:17:20.551750" + } + ], + "fields": [ + { + "id": "77578693-9925-4c3d-a921-8c964cdd7acd", + "name": "text", + "title": "text", + "required": true, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-26T14:17:20.528738", + "updated_at": "2024-09-26T14:17:20.528738" + } + ], + "metadata_properties": [ + { + "id": "284945d9-4bda-4fde-9ca0-b3928282ce83", + "name": "dump", + "title": "dump", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.560704", + "updated_at": "2024-09-26T14:17:20.560704" + }, + { + "id": "5b8f17e5-1be5-4d99-b3d3-567cfaf33fe3", + "name": "url", + "title": "url", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.570162", + "updated_at": "2024-09-26T14:17:20.570162" + }, + { + "id": "a18c60ca-0212-4b22-b1f4-ab3e0fc5ae95", + "name": "language", + "title": "language", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.578088", + "updated_at": "2024-09-26T14:17:20.578088" + }, + { + "id": "c5f6d407-87b7-4678-9c7b-28cd002fcefb", + "name": "language_score", + "title": "language_score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.585319", + "updated_at": "2024-09-26T14:17:20.585319" + }, + { + "id": "ed3ee682-5d12-4c58-91a2-b1cca89fe62b", + "name": "token_count", + "title": "token_count", + "settings": { + "min": null, + "max": null, + "type": "integer" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.593545", + "updated_at": "2024-09-26T14:17:20.593545" + }, + { + "id": "c807d5dd-3cf0-47b9-b07e-bcf03176115f", + "name": "score", + "title": "score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.601316", + "updated_at": "2024-09-26T14:17:20.601316" + } + ], + "vectors_settings": [], + "last_activity_at": "2024-09-26T14:17:20.675364", + "inserted_at": "2024-09-26T14:17:20.477163", + "updated_at": "2024-09-26T14:17:20.675364" + } +} +``` + +#### Published + +```json +{ + "type": "dataset.published", + "version": 1, + "timestamp": "2024-09-26T14:17:20.680921Z", + "data": { + "id": "3d673549-ad31-4485-97eb-31f9dcd0df71", + "name": "fineweb-edu-min", + "guidelines": null, + "allow_extra_metadata": false, + "status": "ready", + "distribution": { + "strategy": "overlap", + "min_submitted": 1 + }, + "workspace": { + "id": "350bc020-2cd2-4a67-8b23-37a15c4d8139", + "name": "argilla", + "inserted_at": "2024-09-05T11:39:20.377192", + "updated_at": "2024-09-05T11:39:20.377192" + }, + "questions": [ + { + "id": "80069251-4792-49e7-b58a-69a6117e8d32", + "name": "int_score", + "title": "Rate the quality of the text", + "description": null, + "required": true, + "settings": { + "type": "rating", + "options": [ + { + "value": 0 + }, + { + "value": 1 + }, + { + "value": 2 + }, + { + "value": 3 + }, + { + "value": 4 + }, + { + "value": 5 + } + ] + }, + "inserted_at": "2024-09-26T14:17:20.541716", + "updated_at": "2024-09-26T14:17:20.541716" + }, + { + "id": "5e7b45c3-b863-48c8-a1e8-2caa279b71e7", + "name": "comments", + "title": "Comments:", + "description": null, + "required": false, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-26T14:17:20.551750", + "updated_at": "2024-09-26T14:17:20.551750" + } + ], + "fields": [ + { + "id": "77578693-9925-4c3d-a921-8c964cdd7acd", + "name": "text", + "title": "text", + "required": true, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-26T14:17:20.528738", + "updated_at": "2024-09-26T14:17:20.528738" + } + ], + "metadata_properties": [ + { + "id": "284945d9-4bda-4fde-9ca0-b3928282ce83", + "name": "dump", + "title": "dump", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.560704", + "updated_at": "2024-09-26T14:17:20.560704" + }, + { + "id": "5b8f17e5-1be5-4d99-b3d3-567cfaf33fe3", + "name": "url", + "title": "url", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.570162", + "updated_at": "2024-09-26T14:17:20.570162" + }, + { + "id": "a18c60ca-0212-4b22-b1f4-ab3e0fc5ae95", + "name": "language", + "title": "language", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.578088", + "updated_at": "2024-09-26T14:17:20.578088" + }, + { + "id": "c5f6d407-87b7-4678-9c7b-28cd002fcefb", + "name": "language_score", + "title": "language_score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.585319", + "updated_at": "2024-09-26T14:17:20.585319" + }, + { + "id": "ed3ee682-5d12-4c58-91a2-b1cca89fe62b", + "name": "token_count", + "title": "token_count", + "settings": { + "min": null, + "max": null, + "type": "integer" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.593545", + "updated_at": "2024-09-26T14:17:20.593545" + }, + { + "id": "c807d5dd-3cf0-47b9-b07e-bcf03176115f", + "name": "score", + "title": "score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.601316", + "updated_at": "2024-09-26T14:17:20.601316" + } + ], + "vectors_settings": [], + "last_activity_at": "2024-09-26T14:17:20.675364", + "inserted_at": "2024-09-26T14:17:20.477163", + "updated_at": "2024-09-26T14:17:20.675364" + } +} +``` + +### Record events + +#### Created + +```json +{ + "type": "record.created", + "version": 1, + "timestamp": "2024-09-26T14:17:43.078165Z", + "data": { + "id": "49e0acda-df13-4f65-8137-2274b3e33c9c", + "status": "pending", + "fields": { + "text": "Taking Play Seriously\nBy ROBIN MARANTZ HENIG\nPublished: February 17, 2008\nOn a drizzly Tuesday night in late January, 200 people came out to hear a psychiatrist talk rhapsodically about play -- not just the intense, joyous play of children, but play for all people, at all ages, at all times." + }, + "metadata": { + "dump": "CC-MAIN-2013-20", + "url": "http://query.nytimes.com/gst/fullpage.html?res=9404E7DA1339F934A25751C0A96E9C8B63&scp=2&sq=taking%20play%20seriously&st=cse", + "language": "en", + "language_score": 0.9614589214324951, + "token_count": 1055, + "score": 2.5625 + }, + "external_id": "", + "dataset": { + "id": "3d673549-ad31-4485-97eb-31f9dcd0df71", + "name": "fineweb-edu-min", + "guidelines": null, + "allow_extra_metadata": false, + "status": "ready", + "distribution": { + "strategy": "overlap", + "min_submitted": 1 + }, + "workspace": { + "id": "350bc020-2cd2-4a67-8b23-37a15c4d8139", + "name": "argilla", + "inserted_at": "2024-09-05T11:39:20.377192", + "updated_at": "2024-09-05T11:39:20.377192" + }, + "questions": [ + { + "id": "80069251-4792-49e7-b58a-69a6117e8d32", + "name": "int_score", + "title": "Rate the quality of the text", + "description": null, + "required": true, + "settings": { + "type": "rating", + "options": [ + { + "value": 0 + }, + { + "value": 1 + }, + { + "value": 2 + }, + { + "value": 3 + }, + { + "value": 4 + }, + { + "value": 5 + } + ] + }, + "inserted_at": "2024-09-26T14:17:20.541716", + "updated_at": "2024-09-26T14:17:20.541716" + }, + { + "id": "5e7b45c3-b863-48c8-a1e8-2caa279b71e7", + "name": "comments", + "title": "Comments:", + "description": null, + "required": false, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-26T14:17:20.551750", + "updated_at": "2024-09-26T14:17:20.551750" + } + ], + "fields": [ + { + "id": "77578693-9925-4c3d-a921-8c964cdd7acd", + "name": "text", + "title": "text", + "required": true, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-26T14:17:20.528738", + "updated_at": "2024-09-26T14:17:20.528738" + } + ], + "metadata_properties": [ + { + "id": "284945d9-4bda-4fde-9ca0-b3928282ce83", + "name": "dump", + "title": "dump", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.560704", + "updated_at": "2024-09-26T14:17:20.560704" + }, + { + "id": "5b8f17e5-1be5-4d99-b3d3-567cfaf33fe3", + "name": "url", + "title": "url", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.570162", + "updated_at": "2024-09-26T14:17:20.570162" + }, + { + "id": "a18c60ca-0212-4b22-b1f4-ab3e0fc5ae95", + "name": "language", + "title": "language", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.578088", + "updated_at": "2024-09-26T14:17:20.578088" + }, + { + "id": "c5f6d407-87b7-4678-9c7b-28cd002fcefb", + "name": "language_score", + "title": "language_score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.585319", + "updated_at": "2024-09-26T14:17:20.585319" + }, + { + "id": "ed3ee682-5d12-4c58-91a2-b1cca89fe62b", + "name": "token_count", + "title": "token_count", + "settings": { + "min": null, + "max": null, + "type": "integer" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.593545", + "updated_at": "2024-09-26T14:17:20.593545" + }, + { + "id": "c807d5dd-3cf0-47b9-b07e-bcf03176115f", + "name": "score", + "title": "score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-26T14:17:20.601316", + "updated_at": "2024-09-26T14:17:20.601316" + } + ], + "vectors_settings": [], + "last_activity_at": "2024-09-26T14:17:20.675364", + "inserted_at": "2024-09-26T14:17:20.477163", + "updated_at": "2024-09-26T14:17:20.675364" + }, + "inserted_at": "2024-09-26T14:17:43.026852", + "updated_at": "2024-09-26T14:17:43.026852" + } +} +``` + +#### Updated + +```json +{ + "type": "record.updated", + "version": 1, + "timestamp": "2024-09-26T14:05:30.231988Z", + "data": { + "id": "88654411-4eec-4d17-ad73-e5baf59d0efb", + "status": "completed", + "fields": { + "text": "Throughout life there are many times when outside influences change or influence decision-making. The young child has inner motivation to learn and explore, but as he matures, finds outside sources to be a motivating force for development, as well." + }, + "metadata": { + "dump": "CC-MAIN-2013-20", + "url": "http://www.funderstanding.com/category/child-development/brain-child-development/", + "language": "en", + "language_score": 0.9633054733276367, + "token_count": 1062, + "score": 3.8125 + }, + "external_id": "", + "dataset": { + "id": "ae2961f0-18a4-49d5-ba0c-40fa863fc8f2", + "name": "fineweb-edu-min", + "guidelines": null, + "allow_extra_metadata": false, + "status": "ready", + "distribution": { + "strategy": "overlap", + "min_submitted": 1 + }, + "workspace": { + "id": "350bc020-2cd2-4a67-8b23-37a15c4d8139", + "name": "argilla", + "inserted_at": "2024-09-05T11:39:20.377192", + "updated_at": "2024-09-05T11:39:20.377192" + }, + "questions": [ + { + "id": "faeea416-5390-4721-943c-de7d0212ba20", + "name": "int_score", + "title": "Rate the quality of the text", + "description": null, + "required": true, + "settings": { + "type": "rating", + "options": [ + { + "value": 0 + }, + { + "value": 1 + }, + { + "value": 2 + }, + { + "value": 3 + }, + { + "value": 4 + }, + { + "value": 5 + } + ] + }, + "inserted_at": "2024-09-20T09:39:20.481193", + "updated_at": "2024-09-20T09:39:20.481193" + }, + { + "id": "0e14a758-a6d0-43ff-af5b-39f4e4d031ab", + "name": "comments", + "title": "Comments:", + "description": null, + "required": false, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-20T09:39:20.490851", + "updated_at": "2024-09-20T09:39:20.490851" + } + ], + "fields": [ + { + "id": "a4e81325-7d11-4dcf-af23-d3c867c75c9c", + "name": "text", + "title": "text", + "required": true, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-20T09:39:20.468254", + "updated_at": "2024-09-20T09:39:20.468254" + } + ], + "metadata_properties": [ + { + "id": "1259d700-2ff6-4315-a3c7-703bce3d65d7", + "name": "dump", + "title": "dump", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.499466", + "updated_at": "2024-09-20T09:39:20.499466" + }, + { + "id": "9d135f00-5a51-4506-a607-bc463dce1c2f", + "name": "url", + "title": "url", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.507944", + "updated_at": "2024-09-20T09:39:20.507944" + }, + { + "id": "98eced0d-d92f-486c-841c-a55085c7538b", + "name": "language", + "title": "language", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.517551", + "updated_at": "2024-09-20T09:39:20.517551" + }, + { + "id": "b9f9a3b9-7186-4e23-9147-b5aa52d0d045", + "name": "language_score", + "title": "language_score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.526219", + "updated_at": "2024-09-20T09:39:20.526219" + }, + { + "id": "0585c420-5885-4fce-9757-82c5199304bc", + "name": "token_count", + "title": "token_count", + "settings": { + "min": null, + "max": null, + "type": "integer" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.534559", + "updated_at": "2024-09-20T09:39:20.534559" + }, + { + "id": "ae31acb5-f198-4f0b-8d6c-13fcc80d10d1", + "name": "score", + "title": "score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.544562", + "updated_at": "2024-09-20T09:39:20.544562" + } + ], + "vectors_settings": [], + "last_activity_at": "2024-09-26T14:05:30.129734", + "inserted_at": "2024-09-20T09:39:20.433798", + "updated_at": "2024-09-26T14:05:30.130662" + }, + "inserted_at": "2024-09-20T09:39:23.148539", + "updated_at": "2024-09-26T14:05:30.224076" + } +} +``` + +#### Deleted + +```json +{ + "type": "record.deleted", + "version": 1, + "timestamp": "2024-09-26T14:45:30.464503Z", + "data": { + "id": "5b285767-18c9-46ab-a4ec-5e0ee4e26de9", + "status": "pending", + "fields": { + "text": "This tutorial shows how to send modifications of code in the right way: by using patches.\nThe word developer is used here for someone having a KDE SVN account.\nWe suppose that you have modified some code in KDE and that you are ready to share it. First a few important points:\nNow you have the modification as a source file. Sending the source file will not be helpful, as probably someone else has done other modifications to the original file in the meantime. So your modified file could not replace it." + }, + "metadata": { + "dump": "CC-MAIN-2013-20", + "url": "http://techbase.kde.org/index.php?title=Contribute/Send_Patches&oldid=40759", + "language": "en", + "language_score": 0.9597765207290649, + "token_count": 2482, + "score": 3.0625 + }, + "external_id": "", + "dataset": { + "id": "ae2961f0-18a4-49d5-ba0c-40fa863fc8f2", + "name": "fineweb-edu-min", + "guidelines": null, + "allow_extra_metadata": false, + "status": "ready", + "distribution": { + "strategy": "overlap", + "min_submitted": 1 + }, + "workspace": { + "id": "350bc020-2cd2-4a67-8b23-37a15c4d8139", + "name": "argilla", + "inserted_at": "2024-09-05T11:39:20.377192", + "updated_at": "2024-09-05T11:39:20.377192" + }, + "questions": [ + { + "id": "faeea416-5390-4721-943c-de7d0212ba20", + "name": "int_score", + "title": "Rate the quality of the text", + "description": null, + "required": true, + "settings": { + "type": "rating", + "options": [ + { + "value": 0 + }, + { + "value": 1 + }, + { + "value": 2 + }, + { + "value": 3 + }, + { + "value": 4 + }, + { + "value": 5 + } + ] + }, + "inserted_at": "2024-09-20T09:39:20.481193", + "updated_at": "2024-09-20T09:39:20.481193" + }, + { + "id": "0e14a758-a6d0-43ff-af5b-39f4e4d031ab", + "name": "comments", + "title": "Comments:", + "description": null, + "required": false, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-20T09:39:20.490851", + "updated_at": "2024-09-20T09:39:20.490851" + } + ], + "fields": [ + { + "id": "a4e81325-7d11-4dcf-af23-d3c867c75c9c", + "name": "text", + "title": "text", + "required": true, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-20T09:39:20.468254", + "updated_at": "2024-09-20T09:39:20.468254" + } + ], + "metadata_properties": [ + { + "id": "1259d700-2ff6-4315-a3c7-703bce3d65d7", + "name": "dump", + "title": "dump", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.499466", + "updated_at": "2024-09-20T09:39:20.499466" + }, + { + "id": "9d135f00-5a51-4506-a607-bc463dce1c2f", + "name": "url", + "title": "url", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.507944", + "updated_at": "2024-09-20T09:39:20.507944" + }, + { + "id": "98eced0d-d92f-486c-841c-a55085c7538b", + "name": "language", + "title": "language", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.517551", + "updated_at": "2024-09-20T09:39:20.517551" + }, + { + "id": "b9f9a3b9-7186-4e23-9147-b5aa52d0d045", + "name": "language_score", + "title": "language_score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.526219", + "updated_at": "2024-09-20T09:39:20.526219" + }, + { + "id": "0585c420-5885-4fce-9757-82c5199304bc", + "name": "token_count", + "title": "token_count", + "settings": { + "min": null, + "max": null, + "type": "integer" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.534559", + "updated_at": "2024-09-20T09:39:20.534559" + }, + { + "id": "ae31acb5-f198-4f0b-8d6c-13fcc80d10d1", + "name": "score", + "title": "score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.544562", + "updated_at": "2024-09-20T09:39:20.544562" + } + ], + "vectors_settings": [], + "last_activity_at": "2024-09-26T14:15:11.139023", + "inserted_at": "2024-09-20T09:39:20.433798", + "updated_at": "2024-09-26T14:15:11.141067" + }, + "inserted_at": "2024-09-20T09:39:23.148687", + "updated_at": "2024-09-20T09:39:23.148687" + } +} +``` + +#### Completed + +```json +{ + "type": "record.completed", + "version": 1, + "timestamp": "2024-09-26T14:05:30.236958Z", + "data": { + "id": "88654411-4eec-4d17-ad73-e5baf59d0efb", + "status": "completed", + "fields": { + "text": "Throughout life there are many times when outside influences change or influence decision-making. The young child has inner motivation to learn and explore, but as he matures, finds outside sources to be a motivating force for development, as well." + }, + "metadata": { + "dump": "CC-MAIN-2013-20", + "url": "http://www.funderstanding.com/category/child-development/brain-child-development/", + "language": "en", + "language_score": 0.9633054733276367, + "token_count": 1062, + "score": 3.8125 + }, + "external_id": "", + "dataset": { + "id": "ae2961f0-18a4-49d5-ba0c-40fa863fc8f2", + "name": "fineweb-edu-min", + "guidelines": null, + "allow_extra_metadata": false, + "status": "ready", + "distribution": { + "strategy": "overlap", + "min_submitted": 1 + }, + "workspace": { + "id": "350bc020-2cd2-4a67-8b23-37a15c4d8139", + "name": "argilla", + "inserted_at": "2024-09-05T11:39:20.377192", + "updated_at": "2024-09-05T11:39:20.377192" + }, + "questions": [ + { + "id": "faeea416-5390-4721-943c-de7d0212ba20", + "name": "int_score", + "title": "Rate the quality of the text", + "description": null, + "required": true, + "settings": { + "type": "rating", + "options": [ + { + "value": 0 + }, + { + "value": 1 + }, + { + "value": 2 + }, + { + "value": 3 + }, + { + "value": 4 + }, + { + "value": 5 + } + ] + }, + "inserted_at": "2024-09-20T09:39:20.481193", + "updated_at": "2024-09-20T09:39:20.481193" + }, + { + "id": "0e14a758-a6d0-43ff-af5b-39f4e4d031ab", + "name": "comments", + "title": "Comments:", + "description": null, + "required": false, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-20T09:39:20.490851", + "updated_at": "2024-09-20T09:39:20.490851" + } + ], + "fields": [ + { + "id": "a4e81325-7d11-4dcf-af23-d3c867c75c9c", + "name": "text", + "title": "text", + "required": true, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-20T09:39:20.468254", + "updated_at": "2024-09-20T09:39:20.468254" + } + ], + "metadata_properties": [ + { + "id": "1259d700-2ff6-4315-a3c7-703bce3d65d7", + "name": "dump", + "title": "dump", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.499466", + "updated_at": "2024-09-20T09:39:20.499466" + }, + { + "id": "9d135f00-5a51-4506-a607-bc463dce1c2f", + "name": "url", + "title": "url", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.507944", + "updated_at": "2024-09-20T09:39:20.507944" + }, + { + "id": "98eced0d-d92f-486c-841c-a55085c7538b", + "name": "language", + "title": "language", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.517551", + "updated_at": "2024-09-20T09:39:20.517551" + }, + { + "id": "b9f9a3b9-7186-4e23-9147-b5aa52d0d045", + "name": "language_score", + "title": "language_score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.526219", + "updated_at": "2024-09-20T09:39:20.526219" + }, + { + "id": "0585c420-5885-4fce-9757-82c5199304bc", + "name": "token_count", + "title": "token_count", + "settings": { + "min": null, + "max": null, + "type": "integer" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.534559", + "updated_at": "2024-09-20T09:39:20.534559" + }, + { + "id": "ae31acb5-f198-4f0b-8d6c-13fcc80d10d1", + "name": "score", + "title": "score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.544562", + "updated_at": "2024-09-20T09:39:20.544562" + } + ], + "vectors_settings": [], + "last_activity_at": "2024-09-26T14:05:30.129734", + "inserted_at": "2024-09-20T09:39:20.433798", + "updated_at": "2024-09-26T14:05:30.130662" + }, + "inserted_at": "2024-09-20T09:39:23.148539", + "updated_at": "2024-09-26T14:05:30.224076" + } +} +``` + +### Response events + +#### Created + +```json +{ + "type": "response.created", + "version": 1, + "timestamp": "2024-09-26T14:05:30.182364Z", + "data": { + "id": "7164a58e-3611-4b0a-98cc-9184bc92dc5a", + "values": { + "int_score": { + "value": 3 + } + }, + "status": "submitted", + "record": { + "id": "88654411-4eec-4d17-ad73-e5baf59d0efb", + "status": "pending", + "fields": { + "text": "Throughout life there are many times when outside influences change or influence decision-making. The young child has inner motivation to learn and explore, but as he matures, finds outside sources to be a motivating force for development, as well." + }, + "metadata": { + "dump": "CC-MAIN-2013-20", + "url": "http://www.funderstanding.com/category/child-development/brain-child-development/", + "language": "en", + "language_score": 0.9633054733276367, + "token_count": 1062, + "score": 3.8125 + }, + "external_id": "", + "dataset": { + "id": "ae2961f0-18a4-49d5-ba0c-40fa863fc8f2", + "name": "fineweb-edu-min", + "guidelines": null, + "allow_extra_metadata": false, + "status": "ready", + "distribution": { + "strategy": "overlap", + "min_submitted": 1 + }, + "workspace": { + "id": "350bc020-2cd2-4a67-8b23-37a15c4d8139", + "name": "argilla", + "inserted_at": "2024-09-05T11:39:20.377192", + "updated_at": "2024-09-05T11:39:20.377192" + }, + "questions": [ + { + "id": "faeea416-5390-4721-943c-de7d0212ba20", + "name": "int_score", + "title": "Rate the quality of the text", + "description": null, + "required": true, + "settings": { + "type": "rating", + "options": [ + { + "value": 0 + }, + { + "value": 1 + }, + { + "value": 2 + }, + { + "value": 3 + }, + { + "value": 4 + }, + { + "value": 5 + } + ] + }, + "inserted_at": "2024-09-20T09:39:20.481193", + "updated_at": "2024-09-20T09:39:20.481193" + }, + { + "id": "0e14a758-a6d0-43ff-af5b-39f4e4d031ab", + "name": "comments", + "title": "Comments:", + "description": null, + "required": false, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-20T09:39:20.490851", + "updated_at": "2024-09-20T09:39:20.490851" + } + ], + "fields": [ + { + "id": "a4e81325-7d11-4dcf-af23-d3c867c75c9c", + "name": "text", + "title": "text", + "required": true, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-20T09:39:20.468254", + "updated_at": "2024-09-20T09:39:20.468254" + } + ], + "metadata_properties": [ + { + "id": "1259d700-2ff6-4315-a3c7-703bce3d65d7", + "name": "dump", + "title": "dump", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.499466", + "updated_at": "2024-09-20T09:39:20.499466" + }, + { + "id": "9d135f00-5a51-4506-a607-bc463dce1c2f", + "name": "url", + "title": "url", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.507944", + "updated_at": "2024-09-20T09:39:20.507944" + }, + { + "id": "98eced0d-d92f-486c-841c-a55085c7538b", + "name": "language", + "title": "language", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.517551", + "updated_at": "2024-09-20T09:39:20.517551" + }, + { + "id": "b9f9a3b9-7186-4e23-9147-b5aa52d0d045", + "name": "language_score", + "title": "language_score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.526219", + "updated_at": "2024-09-20T09:39:20.526219" + }, + { + "id": "0585c420-5885-4fce-9757-82c5199304bc", + "name": "token_count", + "title": "token_count", + "settings": { + "min": null, + "max": null, + "type": "integer" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.534559", + "updated_at": "2024-09-20T09:39:20.534559" + }, + { + "id": "ae31acb5-f198-4f0b-8d6c-13fcc80d10d1", + "name": "score", + "title": "score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.544562", + "updated_at": "2024-09-20T09:39:20.544562" + } + ], + "vectors_settings": [], + "last_activity_at": "2024-09-26T14:05:30.129734", + "inserted_at": "2024-09-20T09:39:20.433798", + "updated_at": "2024-09-23T11:08:30.392833" + }, + "inserted_at": "2024-09-20T09:39:23.148539", + "updated_at": "2024-09-20T09:39:23.148539" + }, + "user": { + "id": "df114042-958d-42c6-9f03-ab49bd451c6c", + "first_name": "", + "last_name": null, + "username": "argilla", + "role": "owner", + "inserted_at": "2024-09-05T11:39:20.376463", + "updated_at": "2024-09-05T11:39:20.376463" + }, + "inserted_at": "2024-09-26T14:05:30.128332", + "updated_at": "2024-09-26T14:05:30.128332" + } +} +``` + +#### Updated + +```json +{ + "type": "response.updated", + "version": 1, + "timestamp": "2024-09-26T14:13:22.256501Z", + "data": { + "id": "38e4d537-c768-4ced-916e-31b74b220c36", + "values": { + "int_score": { + "value": 5 + } + }, + "status": "discarded", + "record": { + "id": "54b137ae-68a4-4aa4-ab2f-ef350ca96a6b", + "status": "completed", + "fields": { + "text": "Bolivia: Coca-chewing protest outside US embassy\nIndigenous activists in Bolivia have been holding a mass coca-chewing protest as part of campaign to end an international ban on the practice.\nHundreds of people chewed the leaf outside the US embassy in La Paz and in other cities across the country." + }, + "metadata": { + "dump": "CC-MAIN-2013-20", + "url": "http://www.bbc.co.uk/news/world-latin-america-12292661", + "language": "en", + "language_score": 0.9660392999649048, + "token_count": 484, + "score": 2.703125 + }, + "external_id": "", + "dataset": { + "id": "ae2961f0-18a4-49d5-ba0c-40fa863fc8f2", + "name": "fineweb-edu-min", + "guidelines": null, + "allow_extra_metadata": false, + "status": "ready", + "distribution": { + "strategy": "overlap", + "min_submitted": 1 + }, + "workspace": { + "id": "350bc020-2cd2-4a67-8b23-37a15c4d8139", + "name": "argilla", + "inserted_at": "2024-09-05T11:39:20.377192", + "updated_at": "2024-09-05T11:39:20.377192" + }, + "questions": [ + { + "id": "faeea416-5390-4721-943c-de7d0212ba20", + "name": "int_score", + "title": "Rate the quality of the text", + "description": null, + "required": true, + "settings": { + "type": "rating", + "options": [ + { + "value": 0 + }, + { + "value": 1 + }, + { + "value": 2 + }, + { + "value": 3 + }, + { + "value": 4 + }, + { + "value": 5 + } + ] + }, + "inserted_at": "2024-09-20T09:39:20.481193", + "updated_at": "2024-09-20T09:39:20.481193" + }, + { + "id": "0e14a758-a6d0-43ff-af5b-39f4e4d031ab", + "name": "comments", + "title": "Comments:", + "description": null, + "required": false, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-20T09:39:20.490851", + "updated_at": "2024-09-20T09:39:20.490851" + } + ], + "fields": [ + { + "id": "a4e81325-7d11-4dcf-af23-d3c867c75c9c", + "name": "text", + "title": "text", + "required": true, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-20T09:39:20.468254", + "updated_at": "2024-09-20T09:39:20.468254" + } + ], + "metadata_properties": [ + { + "id": "1259d700-2ff6-4315-a3c7-703bce3d65d7", + "name": "dump", + "title": "dump", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.499466", + "updated_at": "2024-09-20T09:39:20.499466" + }, + { + "id": "9d135f00-5a51-4506-a607-bc463dce1c2f", + "name": "url", + "title": "url", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.507944", + "updated_at": "2024-09-20T09:39:20.507944" + }, + { + "id": "98eced0d-d92f-486c-841c-a55085c7538b", + "name": "language", + "title": "language", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.517551", + "updated_at": "2024-09-20T09:39:20.517551" + }, + { + "id": "b9f9a3b9-7186-4e23-9147-b5aa52d0d045", + "name": "language_score", + "title": "language_score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.526219", + "updated_at": "2024-09-20T09:39:20.526219" + }, + { + "id": "0585c420-5885-4fce-9757-82c5199304bc", + "name": "token_count", + "title": "token_count", + "settings": { + "min": null, + "max": null, + "type": "integer" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.534559", + "updated_at": "2024-09-20T09:39:20.534559" + }, + { + "id": "ae31acb5-f198-4f0b-8d6c-13fcc80d10d1", + "name": "score", + "title": "score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.544562", + "updated_at": "2024-09-20T09:39:20.544562" + } + ], + "vectors_settings": [], + "last_activity_at": "2024-09-26T14:13:22.204670", + "inserted_at": "2024-09-20T09:39:20.433798", + "updated_at": "2024-09-26T14:07:09.788573" + }, + "inserted_at": "2024-09-20T09:39:23.148505", + "updated_at": "2024-09-26T14:06:06.726296" + }, + "user": { + "id": "df114042-958d-42c6-9f03-ab49bd451c6c", + "first_name": "", + "last_name": null, + "username": "argilla", + "role": "owner", + "inserted_at": "2024-09-05T11:39:20.376463", + "updated_at": "2024-09-05T11:39:20.376463" + }, + "inserted_at": "2024-09-26T14:06:06.672138", + "updated_at": "2024-09-26T14:13:22.206179" + } +} +``` + +#### Deleted + +```json +{ + "type": "response.deleted", + "version": 1, + "timestamp": "2024-09-26T14:15:11.138363Z", + "data": { + "id": "7164a58e-3611-4b0a-98cc-9184bc92dc5a", + "values": { + "int_score": { + "value": 3 + } + }, + "status": "submitted", + "record": { + "id": "88654411-4eec-4d17-ad73-e5baf59d0efb", + "status": "completed", + "fields": { + "text": "Throughout life there are many times when outside influences change or influence decision-making. The young child has inner motivation to learn and explore, but as he matures, finds outside sources to be a motivating force for development, as well." + }, + "metadata": { + "dump": "CC-MAIN-2013-20", + "url": "http://www.funderstanding.com/category/child-development/brain-child-development/", + "language": "en", + "language_score": 0.9633054733276367, + "token_count": 1062, + "score": 3.8125 + }, + "external_id": "", + "dataset": { + "id": "ae2961f0-18a4-49d5-ba0c-40fa863fc8f2", + "name": "fineweb-edu-min", + "guidelines": null, + "allow_extra_metadata": false, + "status": "ready", + "distribution": { + "strategy": "overlap", + "min_submitted": 1 + }, + "workspace": { + "id": "350bc020-2cd2-4a67-8b23-37a15c4d8139", + "name": "argilla", + "inserted_at": "2024-09-05T11:39:20.377192", + "updated_at": "2024-09-05T11:39:20.377192" + }, + "questions": [ + { + "id": "faeea416-5390-4721-943c-de7d0212ba20", + "name": "int_score", + "title": "Rate the quality of the text", + "description": null, + "required": true, + "settings": { + "type": "rating", + "options": [ + { + "value": 0 + }, + { + "value": 1 + }, + { + "value": 2 + }, + { + "value": 3 + }, + { + "value": 4 + }, + { + "value": 5 + } + ] + }, + "inserted_at": "2024-09-20T09:39:20.481193", + "updated_at": "2024-09-20T09:39:20.481193" + }, + { + "id": "0e14a758-a6d0-43ff-af5b-39f4e4d031ab", + "name": "comments", + "title": "Comments:", + "description": null, + "required": false, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-20T09:39:20.490851", + "updated_at": "2024-09-20T09:39:20.490851" + } + ], + "fields": [ + { + "id": "a4e81325-7d11-4dcf-af23-d3c867c75c9c", + "name": "text", + "title": "text", + "required": true, + "settings": { + "type": "text", + "use_markdown": false + }, + "inserted_at": "2024-09-20T09:39:20.468254", + "updated_at": "2024-09-20T09:39:20.468254" + } + ], + "metadata_properties": [ + { + "id": "1259d700-2ff6-4315-a3c7-703bce3d65d7", + "name": "dump", + "title": "dump", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.499466", + "updated_at": "2024-09-20T09:39:20.499466" + }, + { + "id": "9d135f00-5a51-4506-a607-bc463dce1c2f", + "name": "url", + "title": "url", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.507944", + "updated_at": "2024-09-20T09:39:20.507944" + }, + { + "id": "98eced0d-d92f-486c-841c-a55085c7538b", + "name": "language", + "title": "language", + "settings": { + "type": "terms", + "values": null + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.517551", + "updated_at": "2024-09-20T09:39:20.517551" + }, + { + "id": "b9f9a3b9-7186-4e23-9147-b5aa52d0d045", + "name": "language_score", + "title": "language_score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.526219", + "updated_at": "2024-09-20T09:39:20.526219" + }, + { + "id": "0585c420-5885-4fce-9757-82c5199304bc", + "name": "token_count", + "title": "token_count", + "settings": { + "min": null, + "max": null, + "type": "integer" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.534559", + "updated_at": "2024-09-20T09:39:20.534559" + }, + { + "id": "ae31acb5-f198-4f0b-8d6c-13fcc80d10d1", + "name": "score", + "title": "score", + "settings": { + "min": null, + "max": null, + "type": "float" + }, + "visible_for_annotators": true, + "inserted_at": "2024-09-20T09:39:20.544562", + "updated_at": "2024-09-20T09:39:20.544562" + } + ], + "vectors_settings": [], + "last_activity_at": "2024-09-26T14:13:22.204670", + "inserted_at": "2024-09-20T09:39:20.433798", + "updated_at": "2024-09-26T14:13:22.207478" + }, + "inserted_at": "2024-09-20T09:39:23.148539", + "updated_at": "2024-09-26T14:05:30.224076" + }, + "user": { + "id": "df114042-958d-42c6-9f03-ab49bd451c6c", + "first_name": "", + "last_name": null, + "username": "argilla", + "role": "owner", + "inserted_at": "2024-09-05T11:39:20.376463", + "updated_at": "2024-09-05T11:39:20.376463" + }, + "inserted_at": "2024-09-26T14:05:30.128332", + "updated_at": "2024-09-26T14:05:30.128332" + } +} +``` + +## How to implement a listener + +Argilla webhooks implements [Standard Webhooks](https://www.standardwebhooks.com) so you can use one of their libraries to implement the verification of webhooks events coming from Argilla, available in many different languages. + +The following example is a simple listener written in Ruby, using [sinatra](https://sinatrarb.com) and [standardwebhooks Ruby library](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries/ruby): + +```ruby +require "sinatra" +require "standardwebhooks" + +post "/webhook" do + wh = StandardWebhooks::Webhook.new("YOUR_SECRET") + + headers = { + "webhook-id" => env["HTTP_WEBHOOK_ID"], + "webhook-signature" => env["HTTP_WEBHOOK_SIGNATURE"], + "webhook-timestamp" => env["HTTP_WEBHOOK_TIMESTAMP"], + } + + puts wh.verify(request.body.read.to_s, headers) +end +``` + +In the previous snippet we are creating a [sinatra](https://sinatrarb.com) application that listens for `POST` requests on `/webhook` endpoint. We are using the [standardwebhooks Ruby library](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries/ruby) to verify the incoming webhook event and printing the verified payload on the console. diff --git a/argilla/docs/reference/argilla/SUMMARY.md b/argilla/docs/reference/argilla/SUMMARY.md index cfe33198e5..49d0ce459d 100644 --- a/argilla/docs/reference/argilla/SUMMARY.md +++ b/argilla/docs/reference/argilla/SUMMARY.md @@ -15,4 +15,5 @@ * [rg.Vector](records/vectors.md) * [rg.Metadata](records/metadata.md) * [rg.Query](search.md) +* [Webhooks](webhooks.md) * [rg.markdown](markdown.md) diff --git a/argilla/docs/reference/argilla/webhooks.md b/argilla/docs/reference/argilla/webhooks.md new file mode 100644 index 0000000000..3f71a4fb32 --- /dev/null +++ b/argilla/docs/reference/argilla/webhooks.md @@ -0,0 +1,61 @@ +--- +hide: footer +--- + +# `argilla.webhooks` + +Webhooks are a way for web applications to notify each other when something happens. For example, you might want to be +notified when a new dataset is created in Argilla. + +## Usage Examples + +To listen for incoming webhooks, you can use the `webhook_listener` decorator function to register a function to be called +when a webhook is received: + +```python +from argilla.webhooks import webhook_listener + +@webhook_listener(events="dataset.created") +async def my_webhook_listener(dataset): + print(dataset) +``` + +To manually create a new webhook, instantiate the `Webhook` object with the client and the name: + +```python +webhook = rg.Webhook( + url="https://somehost.com/webhook", + events=["dataset.created"], + description="My webhook" +) +webhook.create() +``` + +To retrieve a list of existing webhooks, use the `client.webhooks` attribute: + +```python +for webhook in client.webhooks(): + print(webhook) +``` + +--- + +::: src.argilla.webhooks._resource.Webhook + +::: src.argilla.webhooks._helpers.webhook_listener + +::: src.argilla.webhooks._helpers.get_webhook_server + +::: src.argilla.webhooks._helpers.set_webhook_server + +::: src.argilla.webhooks._handler.WebhookHandler + +::: src.argilla.webhooks._event.WebhookEvent + +::: src.argilla.webhooks._event.DatasetEvent + +::: src.argilla.webhooks._event.RecordEvent + +::: src.argilla.webhooks._event.UserResponseEvent + + diff --git a/argilla/mkdocs.yml b/argilla/mkdocs.yml index 9ce3d019b6..efa8a8e4b0 100644 --- a/argilla/mkdocs.yml +++ b/argilla/mkdocs.yml @@ -173,6 +173,7 @@ nav: - Query and filter records: how_to_guides/query.md - Import and export datasets: how_to_guides/import_export.md - Advanced: + - Use webhooks to respond to server events: how_to_guides/webhooks.md - Use Markdown to format rich content: how_to_guides/use_markdown_to_format_rich_content.md - Migrate users, workspaces and datasets to Argilla V2: how_to_guides/migrate_from_legacy_datasets.md - Tutorials: diff --git a/argilla/pdm.lock b/argilla/pdm.lock index a7bdf0b856..4c9ea2939e 100644 --- a/argilla/pdm.lock +++ b/argilla/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:6f49f28d670ea9bd2cddad3e1b380bad7b6b236a6d15a8ea443bca0c3882b19c" +content_hash = "sha256:344f869c491801601ba6165c094fea325ce823713cd0761164744a94431abf60" [[metadata.targets]] requires_python = ">=3.9,<3.13" @@ -611,7 +611,7 @@ name = "deprecated" version = "1.2.14" requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" summary = "Python @deprecated decorator to deprecate old python classes, functions or methods." -groups = ["dev"] +groups = ["default", "dev"] dependencies = [ "wrapt<2,>=1.10", ] @@ -1860,7 +1860,7 @@ name = "pillow" version = "10.4.0" requires_python = ">=3.8" summary = "Python Imaging Library (Fork)" -groups = ["dev"] +groups = ["default", "dev"] files = [ {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, @@ -2753,6 +2753,24 @@ files = [ {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, ] +[[package]] +name = "standardwebhooks" +version = "1.0.0" +requires_python = ">=3.6" +summary = "Standard Webhooks" +groups = ["default"] +dependencies = [ + "Deprecated", + "attrs>=21.3.0", + "httpx>=0.23.0", + "python-dateutil", + "types-Deprecated", + "types-python-dateutil", +] +files = [ + {file = "standardwebhooks-1.0.0.tar.gz", hash = "sha256:d94b99c0dcea84156e03adad94f8dba32d5454cc68e12ec2c824051b55bb67ff"}, +] + [[package]] name = "tinycss2" version = "1.3.0" @@ -2839,6 +2857,28 @@ files = [ {file = "typer-0.9.4.tar.gz", hash = "sha256:f714c2d90afae3a7929fcd72a3abb08df305e1ff61719381384211c4070af57f"}, ] +[[package]] +name = "types-deprecated" +version = "1.2.9.20240311" +requires_python = ">=3.8" +summary = "Typing stubs for Deprecated" +groups = ["default"] +files = [ + {file = "types-Deprecated-1.2.9.20240311.tar.gz", hash = "sha256:0680e89989a8142707de8103f15d182445a533c1047fd9b7e8c5459101e9b90a"}, + {file = "types_Deprecated-1.2.9.20240311-py3-none-any.whl", hash = "sha256:d7793aaf32ff8f7e49a8ac781de4872248e0694c4b75a7a8a186c51167463f9d"}, +] + +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20240906" +requires_python = ">=3.8" +summary = "Typing stubs for python-dateutil" +groups = ["default"] +files = [ + {file = "types-python-dateutil-2.9.0.20240906.tar.gz", hash = "sha256:9706c3b68284c25adffc47319ecc7947e5bb86b3773f843c73906fd598bc176e"}, + {file = "types_python_dateutil-2.9.0.20240906-py3-none-any.whl", hash = "sha256:27c8cc2d058ccb14946eebcaaa503088f4f6dbc4fb6093d3d456a49aef2753f6"}, +] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -2963,7 +3003,7 @@ name = "wrapt" version = "1.14.1" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" summary = "Module for decorators, wrappers and monkey patching." -groups = ["dev"] +groups = ["default", "dev"] files = [ {file = "wrapt-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:80bb5c256f1415f747011dc3604b59bc1f91c6e7150bd7db03b19170ee06b320"}, {file = "wrapt-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07f7a7d0f388028b2df1d916e94bbb40624c59b48ecc6cbc232546706fac74c2"}, diff --git a/argilla/pyproject.toml b/argilla/pyproject.toml index c0bda31039..47678f11ed 100644 --- a/argilla/pyproject.toml +++ b/argilla/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "rich>=10.0.0", "datasets>=2.0.0", "pillow>=9.5.0", + "standardwebhooks>=1.0.0", ] legacy = [ diff --git a/argilla/src/argilla/__init__.py b/argilla/src/argilla/__init__.py index 02614d336f..737818f92d 100644 --- a/argilla/src/argilla/__init__.py +++ b/argilla/src/argilla/__init__.py @@ -22,3 +22,4 @@ from argilla.responses import * # noqa from argilla.records import * # noqa from argilla.vectors import * # noqa +from argilla.webhooks import * # noqa diff --git a/argilla/src/argilla/_api/_webhooks.py b/argilla/src/argilla/_api/_webhooks.py index f09ef800fe..868abeb1c7 100644 --- a/argilla/src/argilla/_api/_webhooks.py +++ b/argilla/src/argilla/_api/_webhooks.py @@ -14,28 +14,13 @@ __all__ = ["WebhooksAPI"] -from typing import List, Optional +from typing import List import httpx -from pydantic import ConfigDict, Field from argilla._api._base import ResourceAPI from argilla._exceptions import api_error_handler -from argilla._models import ResourceModel - - -class WebhookModel(ResourceModel): - url: str - events: List[str] - enabled: bool = True - description: Optional[str] = None - - secret: Optional[str] = Field(None, description="Webhook secret. Read-only.") - - model_config = ConfigDict( - validate_assignment=True, - str_strip_whitespace=True, - ) +from argilla._models._webhook import WebhookModel class WebhooksAPI(ResourceAPI[WebhookModel]): diff --git a/argilla/src/argilla/_helpers/_resource_repr.py b/argilla/src/argilla/_helpers/_resource_repr.py index 1da5f67f89..0982e63653 100644 --- a/argilla/src/argilla/_helpers/_resource_repr.py +++ b/argilla/src/argilla/_helpers/_resource_repr.py @@ -26,6 +26,7 @@ # "len_column": "datasets", }, "User": {"columns": ["username", "id", "role", "updated_at"], "table_name": "Users"}, + "Webhook": {"columns": ["url", "id", "events", "enabled", "updated_at"], "table_name": "Webhooks"}, } diff --git a/argilla/src/argilla/_models/__init__.py b/argilla/src/argilla/_models/__init__.py index 0e3f21ded0..a6e14bd919 100644 --- a/argilla/src/argilla/_models/__init__.py +++ b/argilla/src/argilla/_models/__init__.py @@ -63,3 +63,4 @@ IntegerMetadataPropertySettings, ) from argilla._models._settings._vectors import VectorFieldModel +from argilla._models._webhook import WebhookModel, EventType diff --git a/argilla/src/argilla/_models/_webhook.py b/argilla/src/argilla/_models/_webhook.py new file mode 100644 index 0000000000..747162aec9 --- /dev/null +++ b/argilla/src/argilla/_models/_webhook.py @@ -0,0 +1,72 @@ +# Copyright 2024-present, Argilla, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from enum import Enum +from typing import List, Optional + +from pydantic import Field, ConfigDict + +from argilla._models._base import ResourceModel + + +class EventType(str, Enum): + dataset_created = "dataset.created" + dataset_updated = "dataset.updated" + dataset_deleted = "dataset.deleted" + dataset_published = "dataset.published" + + record_created = "record.created" + record_updated = "record.updated" + record_deleted = "record.deleted" + record_completed = "record.completed" + + response_created = "response.created" + response_updated = "response.updated" + response_deleted = "response.deleted" + + @property + def resource(self) -> str: + """ + Get the instance type of the event. + + Returns: + str: The instance type. It can be "dataset", "record", or "response". + + """ + return self.split(".")[0] + + @property + def action(self) -> str: + """ + Get the action type of the event. + + Returns: + str: The action type. It can be "created", "updated", "deleted", "published", or "completed". + + """ + return self.split(".")[1] + + +class WebhookModel(ResourceModel): + url: str + events: List[EventType] + enabled: bool = True + description: Optional[str] = None + + secret: Optional[str] = Field(None, description="Webhook secret. Read-only.") + + model_config = ConfigDict( + validate_assignment=True, + str_strip_whitespace=True, + ) diff --git a/argilla/src/argilla/client.py b/argilla/src/argilla/client.py index 94a997b7ce..0585468fb1 100644 --- a/argilla/src/argilla/client.py +++ b/argilla/src/argilla/client.py @@ -22,13 +22,14 @@ from argilla import _api from argilla._api._base import ResourceAPI from argilla._api._client import DEFAULT_HTTP_CONFIG +from argilla._api._webhooks import WebhookModel from argilla._exceptions import ArgillaError, NotFoundError from argilla._helpers import GenericIterator from argilla._helpers._resource_repr import ResourceHTMLReprMixin from argilla._models import DatasetModel, ResourceModel, UserModel, WorkspaceModel if TYPE_CHECKING: - from argilla import Dataset, User, Workspace + from argilla import Dataset, User, Workspace, Webhook __all__ = ["Argilla"] @@ -87,6 +88,11 @@ def users(self) -> "Users": """A collection of users on the server.""" return Users(client=self) + @property + def webhooks(self) -> "Webhooks": + """A collection of webhooks on the server.""" + return Webhooks(client=self) + @cached_property def me(self) -> "User": from argilla.users import User @@ -395,6 +401,69 @@ def _from_model(self, model: DatasetModel) -> "Dataset": return Dataset.from_model(model=model, client=self._client) +class Webhooks(Sequence["Webhook"], ResourceHTMLReprMixin): + """A webhooks class. It can be used to create a new webhook or to get an existing one.""" + + class _Iterator(GenericIterator["Webhook"]): + pass + + def __init__(self, client: "Argilla") -> None: + self._client = client + self._api = client.api.webhooks + + def __call__(self, id: Union[UUID, str]) -> Optional["Webhook"]: + """Get a webhook by id if exists. Otherwise, returns `None`""" + + model = _get_model_by_id(self._api, id) + if model: + return self._from_model(model) # noqa + warnings.warn(f"Webhook with id {id!r} not found") + + def __iter__(self): + return self._Iterator(self.list()) + + @overload + @abstractmethod + def __getitem__(self, index: int) -> "Webhook": ... + + @overload + @abstractmethod + def __getitem__(self, index: slice) -> Sequence["Webhook"]: ... + + def __getitem__(self, index) -> "Webhook": + model = self._api.list()[index] + return self._from_model(model) + + def __len__(self) -> int: + return len(self._api.list()) + + def add(self, webhook: "Webhook") -> "Webhook": + """Add a new webhook to the Argilla platform. + Args: + webhook: Webhook object. + + Returns: + Webhook: The created webhook. + """ + webhook._client = self._client + return webhook.create() + + def list(self) -> List["Webhook"]: + return [self._from_model(model) for model in self._api.list()] + + ############################ + # Private methods + ############################ + + def _repr_html_(self) -> str: + return self._represent_as_html(resources=self.list()) + + def _from_model(self, model: WebhookModel) -> "Webhook": + from argilla.webhooks import Webhook + + return Webhook.from_model(client=self._client, model=model) + + def _get_model_by_id(api: ResourceAPI, resource_id: Union[UUID, str]) -> Optional[ResourceModel]: """Get a resource model by id if found. Otherwise, `None`.""" try: diff --git a/argilla/src/argilla/responses.py b/argilla/src/argilla/responses.py index 2e4915e2f9..807627f624 100644 --- a/argilla/src/argilla/responses.py +++ b/argilla/src/argilla/responses.py @@ -189,6 +189,16 @@ def record(self, record: "Record") -> None: """Sets the record associated with the response""" self._record = record + @property + def record(self) -> "Record": + """Returns the record associated with the UserResponse""" + return self._record + + @record.setter + def record(self, record: "Record") -> None: + """Sets the record associated with the UserResponse""" + self._record = record + @classmethod def from_model(cls, model: UserResponseModel, record: "Record") -> "UserResponse": """Creates a UserResponse from a ResponseModel""" diff --git a/argilla/src/argilla/webhooks/__init__.py b/argilla/src/argilla/webhooks/__init__.py new file mode 100644 index 0000000000..4055cfb96b --- /dev/null +++ b/argilla/src/argilla/webhooks/__init__.py @@ -0,0 +1,43 @@ +# Copyright 2024-present, Argilla, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import TYPE_CHECKING + +from argilla.webhooks._event import RecordEvent, DatasetEvent, UserResponseEvent, WebhookEvent +from argilla.webhooks._handler import WebhookHandler +from argilla.webhooks._helpers import ( + webhook_listener, + get_webhook_server, + set_webhook_server, + start_webhook_server, + stop_webhook_server, +) +from argilla.webhooks._resource import Webhook + +if TYPE_CHECKING: + pass + +__all__ = [ + "Webhook", + "WebhookHandler", + "RecordEvent", + "DatasetEvent", + "UserResponseEvent", + "WebhookEvent", + "webhook_listener", + "get_webhook_server", + "set_webhook_server", + "start_webhook_server", + "stop_webhook_server", +] diff --git a/argilla/src/argilla/webhooks/_event.py b/argilla/src/argilla/webhooks/_event.py new file mode 100644 index 0000000000..8c329e22e7 --- /dev/null +++ b/argilla/src/argilla/webhooks/_event.py @@ -0,0 +1,179 @@ +# Copyright 2024-present, Argilla, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime +from typing import TYPE_CHECKING, Union +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + +from argilla import Dataset, Record, UserResponse, Workspace +from argilla._exceptions import ArgillaAPIError +from argilla._models import RecordModel, UserResponseModel, WorkspaceModel, EventType + +if TYPE_CHECKING: + from argilla import Argilla + +__all__ = ["RecordEvent", "DatasetEvent", "UserResponseEvent", "WebhookEvent"] + + +class RecordEvent(BaseModel): + """ + A parsed record event. + + Attributes: + type (EventType): The type of the event. + timestamp (datetime): The timestamp of the event. + record (Record): The record of the event. + """ + + type: EventType + timestamp: datetime + record: Record + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +class DatasetEvent(BaseModel): + """ + A parsed dataset event. + + Attributes: + type (EventType): The type of the event. + timestamp (datetime): The timestamp of the event. + dataset (Dataset): The dataset of the event. + """ + + type: EventType + timestamp: datetime + dataset: Dataset + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +class UserResponseEvent(BaseModel): + """ + A parsed user response event. + + Attributes: + type (EventType): The type of the event. + timestamp (datetime): The timestamp of the event. + response (UserResponse): The user response of the event. + """ + + type: EventType + timestamp: datetime + response: UserResponse + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +class WebhookEvent(BaseModel): + """ + A webhook event. + + Attributes: + type (EventType): The type of the event. + timestamp (datetime): The timestamp of the event. + data (dict): The data of the event. + """ + + type: EventType + timestamp: datetime + data: dict + + def parsed(self, client: "Argilla") -> Union[RecordEvent, DatasetEvent, UserResponseEvent, "WebhookEvent"]: + """ + Parse the webhook event. + + Args: + client: The Argilla client. + + Returns: + Event: The parsed event. + + """ + resource = self.type.resource + data = self.data or {} + + if resource == "dataset": + dataset = self._parse_dataset_from_webhook_data(data, client) + return DatasetEvent( + type=self.type, + timestamp=self.timestamp, + dataset=dataset, + ) + + elif resource == "record": + record = self._parse_record_from_webhook_data(data, client) + return RecordEvent( + type=self.type, + timestamp=self.timestamp, + record=record, + ) + + elif resource == "response": + user_response = self._parse_response_from_webhook_data(data, client) + return UserResponseEvent( + type=self.type, + timestamp=self.timestamp, + response=user_response, + ) + + return self + + @classmethod + def _parse_dataset_from_webhook_data(cls, data: dict, client: "Argilla") -> Dataset: + workspace = Workspace.from_model(WorkspaceModel.model_validate(data["workspace"]), client=client) + # TODO: Parse settings from the data + # settings = Settings._from_dict(data) + + dataset = Dataset(name=data["name"], workspace=workspace, client=client) + dataset.id = UUID(data["id"]) + + try: + dataset.get() + except ArgillaAPIError as _: + # TODO: Show notification + pass + finally: + return dataset + + @classmethod + def _parse_record_from_webhook_data(cls, data: dict, client: "Argilla") -> Record: + dataset = cls._parse_dataset_from_webhook_data(data["dataset"], client) + + record = Record.from_model(RecordModel.model_validate(data), dataset=dataset) + try: + record.get() + except ArgillaAPIError as _: + # TODO: Show notification + pass + finally: + return record + + @classmethod + def _parse_response_from_webhook_data(cls, data: dict, client: "Argilla") -> UserResponse: + record = cls._parse_record_from_webhook_data(data["record"], client) + + # TODO: Link the user resource to the response + user_response = UserResponse.from_model( + model=UserResponseModel(**data, user_id=data["user"]["id"]), + record=record, + ) + + return user_response + + +Event = Union[RecordEvent, DatasetEvent, UserResponseEvent, WebhookEvent] diff --git a/argilla/src/argilla/webhooks/_handler.py b/argilla/src/argilla/webhooks/_handler.py new file mode 100644 index 0000000000..ca6ca9a915 --- /dev/null +++ b/argilla/src/argilla/webhooks/_handler.py @@ -0,0 +1,78 @@ +# Copyright 2024-present, Argilla, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Callable, TYPE_CHECKING + +from argilla.webhooks._event import WebhookEvent + +if TYPE_CHECKING: + from fastapi import Request + from argilla.webhooks._resource import Webhook + + +class WebhookHandler: + """ + The `WebhookHandler` class is used to handle incoming webhook requests. This class handles the + request verification and event object creation. + + Attributes: + webhook (Webhook): The webhook object. + """ + + def __init__(self, webhook: "Webhook"): + self.webhook = webhook + + def handle(self, func: Callable, raw_event: bool = False) -> Callable: + """ + This method handles the incoming webhook requests and calls the provided function. + + Parameters: + func (Callable): The function to be called when a webhook event is received. + raw_event (bool): Whether to pass the raw event object to the function. + + Returns: + + """ + from fastapi import Request + + async def request_handler(request: Request): + event = await self._verify_request(request) + if event.type not in self.webhook.events: + return + + if raw_event: + return await func(event) + + return await func(**event.parsed(self.webhook._client).model_dump()) + + return request_handler + + async def _verify_request(self, request: "Request") -> WebhookEvent: + """ + Verify the request signature and return the event object. + + Arguments: + request (Request): The request object. + + Returns: + WebhookEvent: The event object. + """ + + from standardwebhooks.webhooks import Webhook + + body = await request.body() + headers = dict(request.headers) + + json = Webhook(whsecret=self.webhook.secret).verify(body, headers) + return WebhookEvent.model_validate(json) diff --git a/argilla/src/argilla/webhooks/_helpers.py b/argilla/src/argilla/webhooks/_helpers.py new file mode 100644 index 0000000000..f25c834d55 --- /dev/null +++ b/argilla/src/argilla/webhooks/_helpers.py @@ -0,0 +1,202 @@ +# Copyright 2024-present, Argilla, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import os +import time +import warnings +from threading import Thread +from typing import TYPE_CHECKING, Optional, Callable, Union, List + +import argilla as rg +from argilla import Argilla +from argilla.webhooks._handler import WebhookHandler +from argilla.webhooks._resource import Webhook + +if TYPE_CHECKING: + from fastapi import FastAPI + +__all__ = ["webhook_listener", "get_webhook_server", "set_webhook_server", "start_webhook_server"] + + +def _compute_default_webhook_server_url() -> str: + """ + Compute the webhook server URL. + + Returns: + str: The webhook server URL. If the environment variable `SPACE_HOST` is set, it will return `https://`. + Otherwise, it will return the value of the environment variable `WEBHOOK_SERVER_URL` or `http://127.0.0.1:8000`. + + """ + if space_host := os.getenv("SPACE_HOST"): + return f"https://{space_host}" + + return os.getenv("WEBHOOK_SERVER_URL", "http://127.0.0.1:8000") + + +def _webhook_url_for_func(func: Callable) -> str: + """ + Compute the full webhook URL for a given function. + + Parameters: + func (Callable): The function to compute the webhook URL for. + + Returns: + str: The full webhook URL. + + """ + webhook_server_url = _compute_default_webhook_server_url() + + return f"{webhook_server_url}/{func.__name__}" + + +def webhook_listener( + events: Union[str, List[str]], + description: Optional[str] = None, + client: Optional["Argilla"] = None, + server: Optional["FastAPI"] = None, + raw_event: bool = False, +) -> Callable: + """ + Decorator to create a webhook listener for a function. + + Parameters: + events (Union[str, List[str]]): The events to listen to. + description (Optional[str]): The description of the webhook. + client (Optional[Argilla]): The Argilla client to use. Defaults to the default client. + server (Optional[FastAPI]): The FastAPI server to use. Defaults to the default server. + raw_event (bool): Whether to pass the raw event to the function. Defaults to False. + + Returns: + Callable: The decorated function. + + """ + + client = client or rg.Argilla._get_default() + server = server or get_webhook_server() + + if isinstance(events, str): + events = [events] + + def wrapper(func: Callable) -> Callable: + webhook_url = _webhook_url_for_func(func) + + webhook = None + for argilla_webhook in client.webhooks: + if argilla_webhook.url == webhook_url and argilla_webhook.events == events: + warnings.warn(f"Found existing webhook with for URL {argilla_webhook.url}: {argilla_webhook}") + webhook = argilla_webhook + webhook.description = description or webhook.description + webhook.enabled = True + webhook.update() + break + + if not webhook: + webhook = Webhook( + url=webhook_url, + events=events, + description=description or f"Webhook for {func.__name__}", + ).create() + + request_handler = WebhookHandler(webhook).handle(func, raw_event) + server.post(f"/{func.__name__}", tags=["Argilla Webhooks"])(request_handler) + + return request_handler + + return wrapper + + +def get_webhook_server() -> "FastAPI": + """ + Get the current webhook server. If it does not exist, it will create one. + + Returns: + FastAPI: The webhook server. + + """ + from fastapi import FastAPI + + global _server + if not _server: + _server = FastAPI() + return _server + + +def set_webhook_server(app: "FastAPI"): + """ + Set the webhook server. This should only be called once. + + Parameters: + app (FastAPI): The webhook server. + + """ + global _server + + if _server: + raise ValueError("Server already set") + + _server = app + + +class _WebhookServerRunner: + """ + Class to run the webhook server in a separate thread. + """ + + def __init__(self, server: "FastAPI"): + import uvicorn + + self._server = uvicorn.Server(uvicorn.Config(app=server)) + self._thread = Thread(target=self._server.run, daemon=True) + + def start(self): + """Start the webhook server""" + self._thread.start() + while not self._server.started and self._thread.is_alive(): + time.sleep(1e-3) + + def stop(self): + """Stop the webhook server""" + self._server.should_exit = True + self._thread.join() + + +def start_webhook_server(): + """Start the webhook runner.""" + + global _server_runner + + if _server_runner: + warnings.warn("Server already started") + else: + server = get_webhook_server() + + _server_runner = _WebhookServerRunner(server) + _server_runner.start() + + +def stop_webhook_server(): + """Stop the webhook runner.""" + + global _server_runner + + if not _server_runner: + warnings.warn("Server not started") + else: + try: + _server_runner.stop() + finally: + _server_runner = None + + +_server: Optional["FastAPI"] = None +_server_runner: Optional[_WebhookServerRunner] = None diff --git a/argilla/src/argilla/webhooks/_resource.py b/argilla/src/argilla/webhooks/_resource.py new file mode 100644 index 0000000000..61c8302b4c --- /dev/null +++ b/argilla/src/argilla/webhooks/_resource.py @@ -0,0 +1,98 @@ +# Copyright 2024-present, Argilla, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from typing import List, Optional + +from argilla import Argilla +from argilla._api._webhooks import WebhookModel, WebhooksAPI +from argilla._models import EventType +from argilla._resource import Resource + + +class Webhook(Resource): + """ + The `Webhook` resource. It represents a webhook that can be used to receive events from the Argilla Server. + + Args: + url (str): The URL of the webhook endpoint. + events (List[EventType]): The events that the webhook is subscribed to. + description (Optional[str]): The description of the webhook. + _client (Argilla): The client used to interact with the Argilla Server. + + """ + + _model: WebhookModel + _api: WebhooksAPI + + def __init__(self, url: str, events: List[EventType], description: Optional[str] = None, _client: Argilla = None): + client = _client or Argilla._get_default() + api = client.api.webhooks + events = events or [] + + super().__init__(api=api, client=client) + + self._model = WebhookModel(url=url, events=list(events), description=description) + + @property + def url(self) -> str: + """The URL of the webhook.""" + return self._model.url + + @url.setter + def url(self, value: str): + self._model.url = value + + @property + def events(self) -> List[EventType]: + """The events that the webhook is subscribed to.""" + return self._model.events + + @events.setter + def events(self, value: List[EventType]): + self._model.events = value + + @property + def enabled(self) -> bool: + """Whether the webhook is enabled.""" + return self._model.enabled + + @enabled.setter + def enabled(self, value: bool): + self._model.enabled = value + + @property + def description(self) -> Optional[str]: + """The description of the webhook.""" + return self._model.description + + @description.setter + def description(self, value: Optional[str]): + self._model.description = value + + @property + def secret(self) -> str: + """The secret of the webhook.""" + return self._model.secret + + @classmethod + def from_model(cls, model: WebhookModel, client: Optional["Argilla"] = None) -> "Webhook": + instance = cls(url=model.url, events=model.events, _client=client) + instance._model = model + + return instance + + def _with_client(self, client: "Argilla") -> "Webhook": + self._client = client + self._api = client.api.webhooks + + return self diff --git a/examples/webhooks/basic-webhooks/README.md b/examples/webhooks/basic-webhooks/README.md new file mode 100644 index 0000000000..89fa305276 --- /dev/null +++ b/examples/webhooks/basic-webhooks/README.md @@ -0,0 +1,31 @@ +## Description + +This is a basic webhook example to show how to setup webhook listeners using the argilla SDK + +## Running the app + +1. Start argilla server and argilla worker +```bash +pdm server start +pdm worker +``` + +2. Add the `localhost.org` alias in the `/etc/hosts` file to comply with the Top Level Domain URL requirement. +``` +## +# Host Database +# +# localhost is used to configure the loopback interface +# when the system is booting. Do not change this entry. +## +127.0.0.1 localhost localhost.org +``` + +2. Start the app +```bash +uvicorn main:server +``` + +## Testing the app + +You can see in se server logs traces when working with dataset, records and responses in the argilla server diff --git a/examples/webhooks/basic-webhooks/main.py b/examples/webhooks/basic-webhooks/main.py new file mode 100644 index 0000000000..7b0050de2c --- /dev/null +++ b/examples/webhooks/basic-webhooks/main.py @@ -0,0 +1,76 @@ +import os +from datetime import datetime + +import argilla as rg + +# Environment variables with defaults +API_KEY = os.environ.get("ARGILLA_API_KEY", "argilla.apikey") +API_URL = os.environ.get("ARGILLA_API_URL", "http://localhost:6900") + +# Initialize Argilla client +client = rg.Argilla(api_key=API_KEY, api_url=API_URL) + +# Show the existing webhooks in the argilla server +for webhook in client.webhooks: + print(webhook.url) + + +# Create a webhook listener using the decorator +# This decorator will : +# 1. Create the webhook in the argilla server +# 2. Create a POST endpoint in the server +# 3. Handle the incoming requests to verify the webhook signature +# 4. Ignoring the events other than the ones specified in the `events` argument +# 5. Parse the incoming request and call the decorated function with the parsed data +# +# Each event will be passed as a keyword argument to the decorated function depending on the event type. +# The event types are: +# - record: created, updated, deleted and completed +# - response: created, updated, deleted +# - dataset: created, updated, published, deleted +# Related resources will be passed as keyword arguments to the decorated function +# (for example the dataset for a record-related event, or the record for a response-related event) +# When a resource is deleted +@rg.webhook_listener(events=["record.created", "record.completed"]) +async def listen_record( + record: rg.Record, dataset: rg.Dataset, type: str, timestamp: datetime +): + print(f"Received record event of type {type} at {timestamp}") + + action = "completed" if type == "record.completed" else "created" + print(f"A record with id {record.id} has been {action} for dataset {dataset.name}!") + + +@rg.webhook_listener(events="response.updated") +async def trigger_something_on_response_updated(response: rg.UserResponse, **kwargs): + print( + f"The user response {response.id} has been updated with the following responses:" + ) + print([response.serialize() for response in response.responses]) + + +@rg.webhook_listener(events=["dataset.created", "dataset.updated", "dataset.published"]) +async def with_raw_payload( + type: str, + timestamp: datetime, + dataset: rg.Dataset, + **kwargs, +): + print(f"Event type {type} at {timestamp}") + print(dataset.settings) + + +@rg.webhook_listener(events="dataset.deleted") +async def on_dataset_deleted( + data: dict, + **kwargs, +): + print(f"Dataset {data} has been deleted!") + + +# Set the webhook server. The server is a FastAPI instance, so you need to expose it in order to run it using uvicorn: +# ```bash +# uvicorn main:webhook_server --reload +# ``` + +server = rg.get_webhook_server() diff --git a/examples/webhooks/basic-webhooks/requirements.txt b/examples/webhooks/basic-webhooks/requirements.txt new file mode 100644 index 0000000000..11f77bdd21 --- /dev/null +++ b/examples/webhooks/basic-webhooks/requirements.txt @@ -0,0 +1,3 @@ +argilla @ git+https://github.com/argilla-io/argilla.git@feat/argilla/working-with-webhooks#subdirectory=argilla +fastapi +uvicorn[standard] From 39dcfc240454631986fec6215fd9b347d2edba9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Thu, 31 Oct 2024 16:28:47 +0100 Subject: [PATCH 18/24] chore: small improvements after reviewing PR --- argilla-server/src/argilla_server/contexts/datasets.py | 10 +++------- argilla-server/src/argilla_server/jobs/dataset_jobs.py | 3 +-- argilla-server/src/argilla_server/jobs/hub_jobs.py | 5 +---- argilla-server/src/argilla_server/jobs/queues.py | 3 ++- .../src/argilla_server/webhooks/v1/schemas.py | 5 +---- 5 files changed, 8 insertions(+), 18 deletions(-) diff --git a/argilla-server/src/argilla_server/contexts/datasets.py b/argilla-server/src/argilla_server/contexts/datasets.py index cbca24e49b..af59d0736a 100644 --- a/argilla-server/src/argilla_server/contexts/datasets.py +++ b/argilla-server/src/argilla_server/contexts/datasets.py @@ -180,7 +180,7 @@ def _allowed_roles_for_metadata_property_create(metadata_property_create: Metada async def publish_dataset(db: AsyncSession, search_engine: SearchEngine, dataset: Dataset) -> Dataset: await DatasetPublishValidator.validate(db, dataset) - dataset = await dataset.update(db, status=DatasetStatus.ready, autocommit=True) + dataset = await dataset.update(db, status=DatasetStatus.ready) await search_engine.create_index(dataset) @@ -269,7 +269,6 @@ async def create_metadata_property( settings=metadata_property_create.settings.dict(), allowed_roles=_allowed_roles_for_metadata_property_create(metadata_property_create), dataset_id=dataset.id, - autocommit=True, ) if dataset.is_ready: @@ -326,7 +325,6 @@ async def create_vector_settings( title=vector_settings_create.title, dimensions=vector_settings_create.dimensions, dataset_id=dataset.id, - autocommit=True, ) if dataset.is_ready: @@ -763,7 +761,7 @@ async def delete_records( await build_record_event_v1(db, RecordEvent.deleted, record), ) - records = await Record.delete_many(db, params) + records = await Record.delete_many(db, conditions=params) await search_engine.delete_records(dataset=dataset, records=records) @@ -964,7 +962,6 @@ async def upsert_suggestion( db, schema=SuggestionCreateWithRecordId(record_id=record.id, **suggestion_create.dict()), constraints=[Suggestion.record_id, Suggestion.question_id], - autocommit=True, ) await _preload_suggestion_relationships_before_index(db, suggestion) @@ -981,7 +978,6 @@ async def delete_suggestions( await Suggestion.delete_many( db=db, conditions=[Suggestion.id.in_(suggestions_ids), Suggestion.record_id == record.id], - autocommit=True, ) for suggestion in suggestions: @@ -1004,7 +1000,7 @@ async def list_suggestions_by_id_and_record_id( async def delete_suggestion(db: AsyncSession, search_engine: SearchEngine, suggestion: Suggestion) -> Suggestion: - suggestion = await suggestion.delete(db, autocommit=True) + suggestion = await suggestion.delete(db) await search_engine.delete_record_suggestion(suggestion) diff --git a/argilla-server/src/argilla_server/jobs/dataset_jobs.py b/argilla-server/src/argilla_server/jobs/dataset_jobs.py index 6a0045fd1d..07cec95bb0 100644 --- a/argilla-server/src/argilla_server/jobs/dataset_jobs.py +++ b/argilla-server/src/argilla_server/jobs/dataset_jobs.py @@ -21,12 +21,11 @@ from argilla_server.models import Record, Response from argilla_server.database import AsyncSessionLocal -from argilla_server.jobs.queues import LOW_QUEUE +from argilla_server.jobs.queues import LOW_QUEUE, JOB_TIMEOUT_DISABLED from argilla_server.search_engine.base import SearchEngine from argilla_server.settings import settings from argilla_server.contexts import distribution -JOB_TIMEOUT_DISABLED = -1 JOB_RECORDS_YIELD_PER = 100 diff --git a/argilla-server/src/argilla_server/jobs/hub_jobs.py b/argilla-server/src/argilla_server/jobs/hub_jobs.py index cbbbf3f8ef..6e079bde7e 100644 --- a/argilla-server/src/argilla_server/jobs/hub_jobs.py +++ b/argilla-server/src/argilla_server/jobs/hub_jobs.py @@ -24,10 +24,7 @@ from argilla_server.database import AsyncSessionLocal from argilla_server.search_engine.base import SearchEngine from argilla_server.api.schemas.v1.datasets import HubDatasetMapping -from argilla_server.jobs.queues import LOW_QUEUE - -# TODO: Move this to be defined on jobs queues as a shared constant -JOB_TIMEOUT_DISABLED = -1 +from argilla_server.jobs.queues import LOW_QUEUE, JOB_TIMEOUT_DISABLED HUB_DATASET_TAKE_ROWS = 10_000 diff --git a/argilla-server/src/argilla_server/jobs/queues.py b/argilla-server/src/argilla_server/jobs/queues.py index 2ba5ead309..ff7670ceac 100644 --- a/argilla-server/src/argilla_server/jobs/queues.py +++ b/argilla-server/src/argilla_server/jobs/queues.py @@ -18,8 +18,9 @@ from argilla_server.settings import settings - REDIS_CONNECTION = redis.from_url(settings.redis_url) LOW_QUEUE = Queue("low", connection=REDIS_CONNECTION) HIGH_QUEUE = Queue("high", connection=REDIS_CONNECTION) + +JOB_TIMEOUT_DISABLED = -1 diff --git a/argilla-server/src/argilla_server/webhooks/v1/schemas.py b/argilla-server/src/argilla_server/webhooks/v1/schemas.py index 14df8a6d0d..9db5aae9b1 100644 --- a/argilla-server/src/argilla_server/webhooks/v1/schemas.py +++ b/argilla-server/src/argilla_server/webhooks/v1/schemas.py @@ -49,7 +49,6 @@ class DatasetQuestionEventSchema(BaseModel): description: Optional[str] required: bool settings: dict - # dataset_id: UUID inserted_at: datetime updated_at: datetime @@ -63,7 +62,6 @@ class DatasetFieldEventSchema(BaseModel): title: str required: bool settings: dict - # dataset_id: UUID inserted_at: datetime updated_at: datetime @@ -77,7 +75,6 @@ class DatasetMetadataPropertyEventSchema(BaseModel): title: str settings: dict visible_for_annotators: bool - # dataset_id: UUID inserted_at: datetime updated_at: datetime @@ -90,7 +87,6 @@ class DatasetVectorSettingsEventSchema(BaseModel): name: str title: str dimensions: int - # dataset_id: UUID inserted_at: datetime updated_at: datetime @@ -126,6 +122,7 @@ class RecordEventSchema(BaseModel): fields: dict metadata: Optional[dict] = Field(None, alias="metadata_") external_id: Optional[str] + # TODO: # responses: # - Create a new `GET /api/v1/records/{record_id}/responses` endpoint. # - Or use `/api/v1/records/{record_id}` endpoint. From 9bbdd62028c000d51bc462eca98b37fd0c295748 Mon Sep 17 00:00:00 2001 From: Francisco Aranda Date: Mon, 11 Nov 2024 14:07:15 +0100 Subject: [PATCH 19/24] chore: update CHANGELOG --- argilla/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/argilla/CHANGELOG.md b/argilla/CHANGELOG.md index b3374854cf..d26a7f4f1d 100644 --- a/argilla/CHANGELOG.md +++ b/argilla/CHANGELOG.md @@ -21,6 +21,7 @@ These are the section headers that we use: ### Added - Added `Argilla.deploy_on_spaces` to deploy the Argilla server on Hugging Face Spaces. ([#5547](https://github.com/argilla-io/argilla/pull/5547)) +- Add support to webhooks. ([#5467](https://github.com/argilla-io/argilla/pull/5467)) ### Changed From 32b890ec3a4cc2c027f833d8fc403eb4c6e81a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Tue, 19 Nov 2024 13:15:04 +0100 Subject: [PATCH 20/24] improve: use StrEnum for Webhooks Enums --- .../src/argilla_server/webhooks/v1/enums.py | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/argilla-server/src/argilla_server/webhooks/v1/enums.py b/argilla-server/src/argilla_server/webhooks/v1/enums.py index fce902e476..1f7e92970d 100644 --- a/argilla-server/src/argilla_server/webhooks/v1/enums.py +++ b/argilla-server/src/argilla_server/webhooks/v1/enums.py @@ -12,10 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. -from enum import Enum +try: + from enum import StrEnum +except ImportError: + from enum import Enum + class StrEnum(str, Enum): + """Custom StrEnum class for Python <3.11 compatibility.""" -class WebhookEvent(str, Enum): + def __str__(self): + return str(self.value) + + +class WebhookEvent(StrEnum): dataset_created = "dataset.created" dataset_updated = "dataset.updated" dataset_deleted = "dataset.deleted" @@ -30,34 +39,22 @@ class WebhookEvent(str, Enum): response_updated = "response.updated" response_deleted = "response.deleted" - def __str__(self): - return str(self.value) - -class DatasetEvent(str, Enum): +class DatasetEvent(StrEnum): created = WebhookEvent.dataset_created.value updated = WebhookEvent.dataset_updated.value deleted = WebhookEvent.dataset_deleted.value published = WebhookEvent.dataset_published.value - def __str__(self): - return str(self.value) - -class RecordEvent(str, Enum): +class RecordEvent(StrEnum): created = WebhookEvent.record_created.value updated = WebhookEvent.record_updated.value deleted = WebhookEvent.record_deleted.value completed = WebhookEvent.record_completed.value - def __str__(self): - return str(self.value) - -class ResponseEvent(str, Enum): +class ResponseEvent(StrEnum): created = WebhookEvent.response_created.value updated = WebhookEvent.response_updated.value deleted = WebhookEvent.response_deleted.value - - def __str__(self): - return str(self.value) From 462b880d2ce9174f639119dcd084f6a0377a20ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Tue, 19 Nov 2024 13:43:50 +0100 Subject: [PATCH 21/24] improve: define a StrEnum custom class at utils package --- argilla-server/pyproject.toml | 1 - argilla-server/src/argilla_server/enums.py | 8 +------ .../src/argilla_server/utils/str_enum.py | 22 +++++++++++++++++++ .../src/argilla_server/webhooks/v1/enums.py | 8 +------ argilla/pdm.lock | 2 +- 5 files changed, 25 insertions(+), 16 deletions(-) create mode 100644 argilla-server/src/argilla_server/utils/str_enum.py diff --git a/argilla-server/pyproject.toml b/argilla-server/pyproject.toml index 3026db5486..4e0e0adaab 100644 --- a/argilla-server/pyproject.toml +++ b/argilla-server/pyproject.toml @@ -1,5 +1,4 @@ [project] -# Remove me name = "argilla-server" dynamic = ["version"] description = "Open-source tool for exploring, labeling, and monitoring data for NLP projects." diff --git a/argilla-server/src/argilla_server/enums.py b/argilla-server/src/argilla_server/enums.py index 7d88323695..03acc8b1b1 100644 --- a/argilla-server/src/argilla_server/enums.py +++ b/argilla-server/src/argilla_server/enums.py @@ -16,13 +16,7 @@ try: from enum import StrEnum except ImportError: - from enum import Enum - - class StrEnum(str, Enum): - """Custom StrEnum class for Python <3.11 compatibility.""" - - def __str__(self): - return str(self.value) + from argilla_server.utils.str_enum import StrEnum class FieldType(StrEnum): diff --git a/argilla-server/src/argilla_server/utils/str_enum.py b/argilla-server/src/argilla_server/utils/str_enum.py new file mode 100644 index 0000000000..29108d4f4c --- /dev/null +++ b/argilla-server/src/argilla_server/utils/str_enum.py @@ -0,0 +1,22 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from enum import Enum + + +class StrEnum(str, Enum): + """Custom StrEnum class for Python <3.11 compatibility.""" + + def __str__(self): + return str(self.value) diff --git a/argilla-server/src/argilla_server/webhooks/v1/enums.py b/argilla-server/src/argilla_server/webhooks/v1/enums.py index 1f7e92970d..25d05688c2 100644 --- a/argilla-server/src/argilla_server/webhooks/v1/enums.py +++ b/argilla-server/src/argilla_server/webhooks/v1/enums.py @@ -15,13 +15,7 @@ try: from enum import StrEnum except ImportError: - from enum import Enum - - class StrEnum(str, Enum): - """Custom StrEnum class for Python <3.11 compatibility.""" - - def __str__(self): - return str(self.value) + from argilla_server.utils.str_enum import StrEnum class WebhookEvent(StrEnum): diff --git a/argilla/pdm.lock b/argilla/pdm.lock index 86b3265111..3c304fcfd1 100644 --- a/argilla/pdm.lock +++ b/argilla/pdm.lock @@ -8,7 +8,7 @@ lock_version = "4.5.0" content_hash = "sha256:154336258f112fb111f039e0099a194a54ee424d267a3d70290e115acda22154" [[metadata.targets]] -requires_python = ">=3.9,<3.13" +requires_python = ">=3.9" [[package]] name = "aiohappyeyeballs" From f1b93e388ed459642baf2202fe9dc58223fc3dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Tue, 19 Nov 2024 13:54:16 +0100 Subject: [PATCH 22/24] chore: fix problem with pdm dependencies --- argilla/pdm.lock | 271 +++++++++++------------------------------ argilla/pyproject.toml | 14 +-- 2 files changed, 78 insertions(+), 207 deletions(-) diff --git a/argilla/pdm.lock b/argilla/pdm.lock index 3c304fcfd1..ad9998a2ba 100644 --- a/argilla/pdm.lock +++ b/argilla/pdm.lock @@ -16,7 +16,6 @@ version = "2.4.0" requires_python = ">=3.8" summary = "Happy Eyeballs for asyncio" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "aiohappyeyeballs-2.4.0-py3-none-any.whl", hash = "sha256:7ce92076e249169a13c2f49320d1967425eaf1f407522d707d59cac7628d62bd"}, {file = "aiohappyeyeballs-2.4.0.tar.gz", hash = "sha256:55a1714f084e63d49639800f95716da97a1f173d46a16dfcfda0016abb93b6b2"}, @@ -28,7 +27,6 @@ version = "3.10.5" requires_python = ">=3.8" summary = "Async http client/server framework (asyncio)" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "aiohappyeyeballs>=2.3.0", "aiosignal>=1.1.2", @@ -108,7 +106,6 @@ version = "1.3.1" requires_python = ">=3.7" summary = "aiosignal: a list of registered asynchronous callbacks" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "frozenlist>=1.1.0", ] @@ -123,7 +120,6 @@ version = "0.7.0" requires_python = ">=3.8" summary = "Reusable constraint types to use with typing.Annotated" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "typing-extensions>=4.0.0; python_version < \"3.9\"", ] @@ -138,7 +134,6 @@ version = "4.4.0" requires_python = ">=3.8" summary = "High level compatibility layer for multiple asynchronous event loop implementations" groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "exceptiongroup>=1.0.2; python_version < \"3.11\"", "idna>=2.8", @@ -155,7 +150,6 @@ name = "asttokens" version = "2.4.1" summary = "Annotate AST trees with source code positions" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "six>=1.12.0", "typing; python_version < \"3.5\"", @@ -171,7 +165,7 @@ version = "4.0.3" requires_python = ">=3.7" summary = "Timeout context manager for asyncio programs" groups = ["default"] -marker = "python_version < \"3.11\" and python_version >= \"3.9\"" +marker = "python_version < \"3.11\"" dependencies = [ "typing-extensions>=3.6.5; python_version < \"3.8\"", ] @@ -186,7 +180,6 @@ version = "24.2.0" requires_python = ">=3.7" summary = "Classes Without Boilerplate" groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "importlib-metadata; python_version < \"3.8\"", ] @@ -201,7 +194,6 @@ version = "2.16.0" requires_python = ">=3.8" summary = "Internationalization utilities" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "pytz>=2015.7; python_version < \"3.9\"", ] @@ -216,7 +208,6 @@ version = "4.12.3" requires_python = ">=3.6.0" summary = "Screen-scraping library" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "soupsieve>1.2", ] @@ -231,7 +222,6 @@ version = "24.8.0" requires_python = ">=3.8" summary = "The uncompromising code formatter." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "click>=8.0.0", "mypy-extensions>=0.4.3", @@ -268,7 +258,6 @@ version = "6.1.0" requires_python = ">=3.8" summary = "An easy safelist-based HTML-sanitizing tool." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "six>=1.9.0", "webencodings", @@ -284,7 +273,6 @@ version = "1.2.1" requires_python = ">=3.8" summary = "A simple, correct Python build frontend" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "colorama; os_name == \"nt\"", "importlib-metadata>=4.6; python_full_version < \"3.10.2\"", @@ -303,7 +291,6 @@ version = "1.7.1" requires_python = ">=3.8" summary = "cffi-based cairo bindings for Python" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "cffi>=1.1.0", ] @@ -318,7 +305,6 @@ version = "2.7.1" requires_python = ">=3.5" summary = "A Simple SVG Converter based on Cairo" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "cairocffi", "cssselect2", @@ -337,7 +323,6 @@ version = "2024.7.4" requires_python = ">=3.6" summary = "Python package for providing Mozilla's CA Bundle." groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, @@ -349,7 +334,6 @@ version = "1.17.0" requires_python = ">=3.8" summary = "Foreign Function Interface for Python calling C code." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "pycparser", ] @@ -410,7 +394,6 @@ version = "3.4.0" requires_python = ">=3.8" summary = "Validate configuration and produce human readable error messages." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, @@ -422,7 +405,6 @@ version = "3.3.2" requires_python = ">=3.7.0" summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, @@ -494,7 +476,6 @@ version = "8.1.7" requires_python = ">=3.7" summary = "Composable command line interface toolkit" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "colorama; platform_system == \"Windows\"", "importlib-metadata; python_version < \"3.8\"", @@ -510,7 +491,6 @@ version = "0.4.6" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" summary = "Cross-platform colored terminal text." groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -522,7 +502,6 @@ version = "0.7.0" requires_python = ">=3.7" summary = "CSS selectors for Python ElementTree" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "tinycss2", "webencodings", @@ -538,7 +517,6 @@ version = "2.21.0" requires_python = ">=3.8.0" summary = "HuggingFace community-driven open-source library of datasets" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "aiohttp", "dill<0.3.9,>=0.3.0", @@ -566,7 +544,6 @@ version = "5.1.1" requires_python = ">=3.5" summary = "Decorators for Humans" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, @@ -578,7 +555,6 @@ version = "0.7.1" requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" summary = "XML bomb protection for Python stdlib modules" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, @@ -586,17 +562,16 @@ files = [ [[package]] name = "deprecated" -version = "1.2.14" -requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.2.15" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" summary = "Python @deprecated decorator to deprecate old python classes, functions or methods." groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "wrapt<2,>=1.10", ] files = [ - {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, - {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, + {file = "Deprecated-1.2.15-py2.py3-none-any.whl", hash = "sha256:353bc4a8ac4bfc96800ddab349d89c25dec1079f65fd53acdcc1e0b975b21320"}, + {file = "deprecated-1.2.15.tar.gz", hash = "sha256:683e561a90de76239796e6b6feac66b99030d2dd3fcf61ef996330f14bbb9b0d"}, ] [[package]] @@ -605,7 +580,6 @@ version = "0.3.8" requires_python = ">=3.8" summary = "serialize all of Python" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"}, {file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"}, @@ -616,7 +590,6 @@ name = "distlib" version = "0.3.8" summary = "Distribution utilities" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, @@ -628,7 +601,7 @@ version = "1.2.2" requires_python = ">=3.7" summary = "Backport of PEP 654 (exception groups)" groups = ["default", "dev"] -marker = "python_version < \"3.11\" and python_version >= \"3.9\"" +marker = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -640,7 +613,6 @@ version = "2.0.1" requires_python = ">=3.5" summary = "Get the currently executing AST node of a frame, and other information" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "executing-2.0.1-py2.py3-none-any.whl", hash = "sha256:eac49ca94516ccc753f9fb5ce82603156e590b27525a8bc32cce8ae302eb61bc"}, {file = "executing-2.0.1.tar.gz", hash = "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147"}, @@ -651,7 +623,6 @@ name = "fastjsonschema" version = "2.20.0" summary = "Fastest Python implementation of JSON schema" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "fastjsonschema-2.20.0-py3-none-any.whl", hash = "sha256:5875f0b0fa7a0043a91e93a9b8f793bcbbba9691e7fd83dca95c28ba26d21f0a"}, {file = "fastjsonschema-2.20.0.tar.gz", hash = "sha256:3d48fc5300ee96f5d116f10fe6f28d938e6008f59a6a025c2649475b87f76a23"}, @@ -663,7 +634,6 @@ version = "3.15.4" requires_python = ">=3.8" summary = "A platform independent file lock." groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, @@ -675,7 +645,6 @@ version = "7.1.1" requires_python = ">=3.8.1" summary = "the modular source code checker: pep8 pyflakes and co" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "mccabe<0.8.0,>=0.7.0", "pycodestyle<2.13.0,>=2.12.0", @@ -692,7 +661,6 @@ version = "1.4.1" requires_python = ">=3.8" summary = "A list-like structure which implements collections.abc.MutableSequence" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, @@ -764,7 +732,6 @@ version = "2024.6.1" requires_python = ">=3.8" summary = "File-system specification" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "fsspec-2024.6.1-py3-none-any.whl", hash = "sha256:3cb443f8bcd2efb31295a5b9fdb02aee81d8452c80d28f97a6d0959e6cee101e"}, {file = "fsspec-2024.6.1.tar.gz", hash = "sha256:fad7d7e209dd4c1208e3bbfda706620e0da5142bebbd9c384afb95b07e798e49"}, @@ -777,7 +744,6 @@ extras = ["http"] requires_python = ">=3.8" summary = "File-system specification" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "aiohttp!=4.0.0a0,!=4.0.0a1", "fsspec==2024.6.1", @@ -792,7 +758,6 @@ name = "ghp-import" version = "2.1.0" summary = "Copy your docs directly to the gh-pages branch." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "python-dateutil>=2.8.1", ] @@ -807,7 +772,6 @@ version = "4.0.11" requires_python = ">=3.7" summary = "Git Object Database" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "smmap<6,>=3.0.1", ] @@ -822,7 +786,6 @@ version = "3.1.43" requires_python = ">=3.7" summary = "GitPython is a Python library used to interact with Git repositories" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "gitdb<5,>=4.0.1", "typing-extensions>=3.7.4.3; python_version < \"3.8\"", @@ -838,7 +801,6 @@ version = "1.2.0" requires_python = ">=3.8" summary = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "astunparse>=1.6; python_version < \"3.9\"", "colorama>=0.4", @@ -854,7 +816,6 @@ version = "0.14.0" requires_python = ">=3.7" summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "typing-extensions; python_version < \"3.8\"", ] @@ -869,7 +830,6 @@ version = "1.0.5" requires_python = ">=3.8" summary = "A minimal low-level HTTP client." groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "certifi", "h11<0.15,>=0.13", @@ -885,7 +845,6 @@ version = "0.26.0" requires_python = ">=3.8" summary = "The next generation HTTP client." groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "anyio", "certifi", @@ -904,7 +863,6 @@ version = "0.24.6" requires_python = ">=3.8.0" summary = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "filelock", "fsspec>=2023.5.0", @@ -925,7 +883,6 @@ version = "2.6.0" requires_python = ">=3.8" summary = "File identification library for Python" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"}, {file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"}, @@ -937,7 +894,6 @@ version = "3.8" requires_python = ">=3.6" summary = "Internationalized Domain Names in Applications (IDNA)" groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac"}, {file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"}, @@ -949,7 +905,6 @@ version = "8.4.0" requires_python = ">=3.8" summary = "Read metadata from Python packages" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "typing-extensions>=3.6.4; python_version < \"3.8\"", "zipp>=0.5", @@ -965,7 +920,6 @@ version = "6.4.4" requires_python = ">=3.8" summary = "Read resources from Python packages" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "zipp>=3.1.0; python_version < \"3.10\"", ] @@ -980,7 +934,6 @@ version = "2.0.0" requires_python = ">=3.7" summary = "brain-dead simple config-ini parsing" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -992,7 +945,6 @@ version = "8.18.1" requires_python = ">=3.9" summary = "IPython: Productive Interactive Computing" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "colorama; sys_platform == \"win32\"", "decorator", @@ -1017,7 +969,6 @@ version = "0.19.1" requires_python = ">=3.6" summary = "An autocompletion tool for Python that can be used for text editors." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "parso<0.9.0,>=0.8.3", ] @@ -1032,7 +983,6 @@ version = "3.1.4" requires_python = ">=3.7" summary = "A very fast and expressive template engine." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "MarkupSafe>=2.0", ] @@ -1047,7 +997,6 @@ version = "4.23.0" requires_python = ">=3.8" summary = "An implementation of JSON Schema validation for Python" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "attrs>=22.2.0", "importlib-resources>=1.4.0; python_version < \"3.9\"", @@ -1067,7 +1016,6 @@ version = "2023.12.1" requires_python = ">=3.8" summary = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "importlib-resources>=1.4.0; python_version < \"3.9\"", "referencing>=0.31.0", @@ -1083,7 +1031,6 @@ version = "8.6.2" requires_python = ">=3.8" summary = "Jupyter protocol implementation and client libraries" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "importlib-metadata>=4.8.3; python_version < \"3.10\"", "jupyter-core!=5.0.*,>=4.12", @@ -1103,7 +1050,6 @@ version = "5.7.2" requires_python = ">=3.8" summary = "Jupyter core package. A base package on which Jupyter projects rely." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "platformdirs>=2.5", "pywin32>=300; sys_platform == \"win32\" and platform_python_implementation != \"PyPy\"", @@ -1120,7 +1066,6 @@ version = "0.3.0" requires_python = ">=3.8" summary = "Pygments theme using JupyterLab CSS variables" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780"}, {file = "jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d"}, @@ -1132,7 +1077,6 @@ version = "3.7" requires_python = ">=3.8" summary = "Python implementation of John Gruber's Markdown." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "importlib-metadata>=4.4; python_version < \"3.10\"", ] @@ -1147,7 +1091,6 @@ version = "3.0.0" requires_python = ">=3.8" summary = "Python port of markdown-it. Markdown parsing, done right!" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "mdurl~=0.1", ] @@ -1162,7 +1105,6 @@ version = "2.1.5" requires_python = ">=3.7" summary = "Safely add untrusted strings to HTML/XML markup." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, @@ -1213,7 +1155,6 @@ version = "0.2.0" requires_python = ">=3.7" summary = "Plausible Analytics implementation for Material for MkDocs" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "mkdocs", "mkdocs-material", @@ -1229,7 +1170,6 @@ version = "0.1.7" requires_python = ">=3.8" summary = "Inline Matplotlib backend for Jupyter" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "traitlets", ] @@ -1244,7 +1184,6 @@ version = "0.7.0" requires_python = ">=3.6" summary = "McCabe checker, plugin for flake8" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, @@ -1256,7 +1195,6 @@ version = "0.1.2" requires_python = ">=3.7" summary = "Markdown URL utilities" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -1268,7 +1206,6 @@ version = "1.3.4" requires_python = ">=3.6" summary = "A deep merge function for 🐍." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, @@ -1279,7 +1216,6 @@ name = "mike" version = "2.1.3" summary = "Manage multiple versions of your MkDocs-powered documentation" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "importlib-metadata", "importlib-resources", @@ -1301,7 +1237,6 @@ version = "3.0.2" requires_python = ">=3.7" summary = "A sane and fast Markdown parser with useful plugins and renderers" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "mistune-3.0.2-py3-none-any.whl", hash = "sha256:71481854c30fdbc938963d3605b72501f5c10a9320ecd412c121c163a1c7d205"}, {file = "mistune-3.0.2.tar.gz", hash = "sha256:fc7f93ded930c92394ef2cb6f04a8aabab4117a91449e72dcc8dfa646a508be8"}, @@ -1313,7 +1248,6 @@ version = "1.6.0" requires_python = ">=3.8" summary = "Project documentation with Markdown." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "click>=7.0", "colorama>=0.4; platform_system == \"Windows\"", @@ -1341,7 +1275,6 @@ version = "1.1.0" requires_python = ">=3.8" summary = "Automatically link across pages in MkDocs." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "Markdown>=3.3", "markupsafe>=2.0.1", @@ -1358,7 +1291,6 @@ version = "0.5.0" requires_python = ">=3.7" summary = "MkDocs plugin to programmatically generate documentation pages during the build" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "mkdocs>=1.0.3", ] @@ -1373,7 +1305,6 @@ version = "0.2.0" requires_python = ">=3.8" summary = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "importlib-metadata>=4.3; python_version < \"3.10\"", "mergedeep>=1.3.4", @@ -1391,7 +1322,6 @@ version = "0.6.1" requires_python = ">=3.7" summary = "MkDocs plugin to specify the navigation in Markdown instead of YAML" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "mkdocs>=1.0.3", ] @@ -1406,7 +1336,6 @@ version = "9.5.33" requires_python = ">=3.8" summary = "Documentation that simply works" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "babel~=2.10", "colorama~=0.4", @@ -1431,7 +1360,6 @@ version = "1.3.1" requires_python = ">=3.8" summary = "Extension pack for Python Markdown and MkDocs Material." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, @@ -1443,7 +1371,6 @@ version = "1.0.3" requires_python = ">=3.7" summary = "MkDocs plugin to open outgoing links and PDFs in new tab." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "mkdocs", ] @@ -1458,7 +1385,6 @@ version = "0.3.9" requires_python = ">=3.8" summary = "MkDocs plugin to allow clickable sections that lead to an index page" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "mkdocs>=1.2", ] @@ -1473,7 +1399,6 @@ version = "0.25.2" requires_python = ">=3.8" summary = "Automatic documentation from sources, for MkDocs." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "Jinja2>=2.11.1", "Markdown>=3.3", @@ -1497,7 +1422,6 @@ version = "1.10.8" requires_python = ">=3.8" summary = "A Python handler for mkdocstrings." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "griffe>=0.49", "mkdocstrings>=0.25", @@ -1514,7 +1438,6 @@ extras = ["python"] requires_python = ">=3.8" summary = "Automatic documentation from sources, for MkDocs." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "mkdocstrings-python>=0.5.2", "mkdocstrings==0.25.2", @@ -1529,7 +1452,6 @@ name = "mknotebooks" version = "0.8.0" summary = "Plugin for mkdocs to generate markdown documents from jupyter notebooks." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "gitpython", "jupyter-client", @@ -1547,7 +1469,6 @@ version = "6.0.5" requires_python = ">=3.7" summary = "multidict implementation" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"}, {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"}, @@ -1619,7 +1540,6 @@ version = "0.70.16" requires_python = ">=3.8" summary = "better multiprocessing and multithreading in Python" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "dill>=0.3.8", ] @@ -1641,7 +1561,6 @@ version = "1.0.0" requires_python = ">=3.5" summary = "Type system extensions for programs checked with the mypy type checker." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -1653,7 +1572,6 @@ version = "0.10.0" requires_python = ">=3.8.0" summary = "A client library for executing notebooks. Formerly nbconvert's ExecutePreprocessor." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "jupyter-client>=6.1.12", "jupyter-core!=5.0.*,>=4.12", @@ -1671,7 +1589,6 @@ version = "7.16.4" requires_python = ">=3.8" summary = "Converting Jupyter Notebooks (.ipynb files) to other formats. Output formats include asciidoc, html, latex, markdown, pdf, py, rst, script. nbconvert can be used both as a Python library (`import nbconvert`) or as a command line tool (invoked as `jupyter nbconvert ...`)." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "beautifulsoup4", "bleach!=5.0.0", @@ -1701,7 +1618,6 @@ version = "5.10.4" requires_python = ">=3.8" summary = "The Jupyter Notebook format" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "fastjsonschema>=2.15", "jsonschema>=2.6", @@ -1719,7 +1635,6 @@ version = "1.9.1" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" summary = "Node.js virtual environment builder" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, @@ -1731,7 +1646,6 @@ version = "1.26.4" requires_python = ">=3.9" summary = "Fundamental package for array computing in Python" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, @@ -1777,7 +1691,6 @@ version = "24.1" requires_python = ">=3.8" summary = "Core utilities for Python packages" groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, @@ -1788,7 +1701,6 @@ name = "paginate" version = "0.5.7" summary = "Divides large result sets into pages for easier browsing" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591"}, {file = "paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945"}, @@ -1800,7 +1712,6 @@ version = "2.2.2" requires_python = ">=3.9" summary = "Powerful data structures for data analysis, time series, and statistics" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "numpy>=1.22.4; python_version < \"3.11\"", "numpy>=1.23.2; python_version == \"3.11\"", @@ -1847,7 +1758,6 @@ version = "1.5.1" requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" summary = "Utilities for writing pandoc filters in python" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc"}, {file = "pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e"}, @@ -1859,7 +1769,6 @@ version = "0.8.4" requires_python = ">=3.6" summary = "A Python Parser" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, @@ -1871,7 +1780,6 @@ version = "0.12.1" requires_python = ">=3.8" summary = "Utility library for gitignore style pattern matching of file paths." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -1882,7 +1790,7 @@ name = "pexpect" version = "4.9.0" summary = "Pexpect allows easy control of interactive console applications." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\" and sys_platform != \"win32\"" +marker = "sys_platform != \"win32\"" dependencies = [ "ptyprocess>=0.5", ] @@ -1897,7 +1805,6 @@ version = "10.4.0" requires_python = ">=3.8" summary = "Python Imaging Library (Fork)" groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, @@ -1966,7 +1873,6 @@ version = "4.2.2" requires_python = ">=3.8" summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, @@ -1978,7 +1884,6 @@ version = "1.5.0" requires_python = ">=3.8" summary = "plugin and hook calling mechanisms for python" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -1990,7 +1895,6 @@ version = "3.8.0" requires_python = ">=3.9" summary = "A framework for managing and maintaining multi-language pre-commit hooks." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "cfgv>=2.0.0", "identify>=1.0.0", @@ -2009,7 +1913,6 @@ version = "3.0.47" requires_python = ">=3.7.0" summary = "Library for building powerful interactive command lines in Python" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "wcwidth", ] @@ -2023,7 +1926,7 @@ name = "ptyprocess" version = "0.7.0" summary = "Run a subprocess in a pseudo terminal" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\" and sys_platform != \"win32\"" +marker = "sys_platform != \"win32\"" files = [ {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, @@ -2034,7 +1937,6 @@ name = "pure-eval" version = "0.2.3" summary = "Safely evaluate AST nodes without side effects" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, @@ -2046,7 +1948,6 @@ version = "18.0.0" requires_python = ">=3.9" summary = "Python library for Apache Arrow" groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "pyarrow-18.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:2333f93260674e185cfbf208d2da3007132572e56871f451ba1a556b45dae6e2"}, {file = "pyarrow-18.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:4c381857754da44326f3a49b8b199f7f87a51c2faacd5114352fc78de30d3aba"}, @@ -2069,6 +1970,19 @@ files = [ {file = "pyarrow-18.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:00178509f379415a3fcf855af020e3340254f990a8534294ec3cf674d6e255fd"}, {file = "pyarrow-18.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a71ab0589a63a3e987beb2bc172e05f000a5c5be2636b4b263c44034e215b5d7"}, {file = "pyarrow-18.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe92efcdbfa0bcf2fa602e466d7f2905500f33f09eb90bf0bcf2e6ca41b574c8"}, + {file = "pyarrow-18.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:907ee0aa8ca576f5e0cdc20b5aeb2ad4d3953a3b4769fc4b499e00ef0266f02f"}, + {file = "pyarrow-18.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:66dcc216ebae2eb4c37b223feaf82f15b69d502821dde2da138ec5a3716e7463"}, + {file = "pyarrow-18.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1daf7c425f58527900876354390ee41b0ae962a73ad0959b9d829def583bb1"}, + {file = "pyarrow-18.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:871b292d4b696b09120ed5bde894f79ee2a5f109cb84470546471df264cae136"}, + {file = "pyarrow-18.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:082ba62bdcb939824ba1ce10b8acef5ab621da1f4c4805e07bfd153617ac19d4"}, + {file = "pyarrow-18.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2c664ab88b9766413197733c1720d3dcd4190e8fa3bbdc3710384630a0a7207b"}, + {file = "pyarrow-18.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc892be34dbd058e8d189b47db1e33a227d965ea8805a235c8a7286f7fd17d3a"}, + {file = "pyarrow-18.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:28f9c39a56d2c78bf6b87dcc699d520ab850919d4a8c7418cd20eda49874a2ea"}, + {file = "pyarrow-18.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:f1a198a50c409ab2d009fbf20956ace84567d67f2c5701511d4dd561fae6f32e"}, + {file = "pyarrow-18.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5bd7fd32e3ace012d43925ea4fc8bd1b02cc6cc1e9813b518302950e89b5a22"}, + {file = "pyarrow-18.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:336addb8b6f5208be1b2398442c703a710b6b937b1a046065ee4db65e782ff5a"}, + {file = "pyarrow-18.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:45476490dd4adec5472c92b4d253e245258745d0ccaabe706f8d03288ed60a79"}, + {file = "pyarrow-18.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:b46591222c864e7da7faa3b19455196416cd8355ff6c2cc2e65726a760a3c420"}, {file = "pyarrow-18.0.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:eb7e3abcda7e1e6b83c2dc2909c8d045881017270a119cc6ee7fdcfe71d02df8"}, {file = "pyarrow-18.0.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:09f30690b99ce34e0da64d20dab372ee54431745e4efb78ac938234a282d15f9"}, {file = "pyarrow-18.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d5ca5d707e158540312e09fd907f9f49bacbe779ab5236d9699ced14d2293b8"}, @@ -2085,7 +1999,6 @@ version = "2.12.1" requires_python = ">=3.8" summary = "Python style guide checker" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3"}, {file = "pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521"}, @@ -2097,7 +2010,6 @@ version = "2.22" requires_python = ">=3.8" summary = "C parser in Python" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, @@ -2109,7 +2021,6 @@ version = "2.8.2" requires_python = ">=3.8" summary = "Data validation using Python type hints" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "annotated-types>=0.4.0", "pydantic-core==2.20.1", @@ -2127,7 +2038,6 @@ version = "2.20.1" requires_python = ">=3.8" summary = "Core functionality for Pydantic validation and serialization" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "typing-extensions!=4.7.0,>=4.6.0", ] @@ -2205,7 +2115,6 @@ version = "3.2.0" requires_python = ">=3.8" summary = "passive checker of Python programs" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, @@ -2217,7 +2126,6 @@ version = "2.18.0" requires_python = ">=3.8" summary = "Pygments is a syntax highlighting package written in Python." groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, @@ -2229,7 +2137,6 @@ version = "10.9" requires_python = ">=3.8" summary = "Extension pack for Python Markdown." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "markdown>=3.6", "pyyaml", @@ -2245,7 +2152,6 @@ version = "3.1.4" requires_python = ">=3.6.8" summary = "pyparsing module - Classes and methods to define and execute parsing grammars" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "pyparsing-3.1.4-py3-none-any.whl", hash = "sha256:a6a7ee4235a3f944aa1fa2249307708f893fe5717dc603503c6c7969c070fb7c"}, {file = "pyparsing-3.1.4.tar.gz", hash = "sha256:f86ec8d1a83f11977c9a6ea7598e8c27fc5cddfa5b07ea2241edbbde1d7bc032"}, @@ -2257,7 +2163,6 @@ version = "1.1.0" requires_python = ">=3.7" summary = "Wrappers to call pyproject.toml-based build backend hooks." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "pyproject_hooks-1.1.0-py3-none-any.whl", hash = "sha256:7ceeefe9aec63a1064c18d939bdc3adf2d8aa1988a510afec15151578b232aa2"}, {file = "pyproject_hooks-1.1.0.tar.gz", hash = "sha256:4b37730834edbd6bd37f26ece6b44802fb1c1ee2ece0e54ddff8bfc06db86965"}, @@ -2269,7 +2174,6 @@ version = "8.3.2" requires_python = ">=3.8" summary = "pytest: simple powerful testing with Python" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "colorama; sys_platform == \"win32\"", "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", @@ -2289,7 +2193,6 @@ version = "0.29.0" requires_python = ">=3.9" summary = "Send responses to httpx." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "httpx==0.26.*", "pytest<9,>=7", @@ -2305,7 +2208,6 @@ version = "3.14.0" requires_python = ">=3.8" summary = "Thin-wrapper around the mock package for easier use with pytest" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "pytest>=6.2.5", ] @@ -2320,7 +2222,6 @@ version = "1.6.3" requires_python = ">=3.9" summary = "Adds the ability to retry flaky tests in CI environments" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "pytest>=7.0.0", ] @@ -2335,7 +2236,6 @@ version = "2.9.0.post0" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" summary = "Extensions to the standard Python datetime module" groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "six>=1.5", ] @@ -2349,7 +2249,6 @@ name = "pytz" version = "2024.1" summary = "World timezone definitions, modern and historical" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, @@ -2360,7 +2259,7 @@ name = "pywin32" version = "306" summary = "Python for Window Extensions" groups = ["dev"] -marker = "sys_platform == \"win32\" and platform_python_implementation != \"PyPy\" and python_version < \"3.13\" and python_version >= \"3.9\"" +marker = "sys_platform == \"win32\" and platform_python_implementation != \"PyPy\"" files = [ {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, @@ -2380,7 +2279,6 @@ version = "6.0.2" requires_python = ">=3.8" summary = "YAML parser and emitter for Python" groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -2427,7 +2325,6 @@ version = "0.1" requires_python = ">=3.6" summary = "A custom YAML tag for referencing environment variables in YAML files. " groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "pyyaml", ] @@ -2442,7 +2339,6 @@ version = "26.2.0" requires_python = ">=3.7" summary = "Python bindings for 0MQ" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "cffi; implementation_name == \"pypy\"", ] @@ -2514,7 +2410,6 @@ version = "0.35.1" requires_python = ">=3.8" summary = "JSON Referencing + Python" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "attrs>=22.2.0", "rpds-py>=0.7.0", @@ -2530,7 +2425,6 @@ version = "2024.7.24" requires_python = ">=3.8" summary = "Alternative regular expression module, to replace re." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "regex-2024.7.24-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b0d3f567fafa0633aee87f08b9276c7062da9616931382993c03808bb68ce"}, {file = "regex-2024.7.24-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3426de3b91d1bc73249042742f45c2148803c111d1175b283270177fdf669024"}, @@ -2603,7 +2497,6 @@ version = "2.32.3" requires_python = ">=3.8" summary = "Python HTTP for Humans." groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "certifi>=2017.4.17", "charset-normalizer<4,>=2", @@ -2621,7 +2514,6 @@ version = "13.7.1" requires_python = ">=3.7.0" summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "markdown-it-py>=2.2.0", "pygments<3.0.0,>=2.13.0", @@ -2638,7 +2530,6 @@ version = "0.20.0" requires_python = ">=3.8" summary = "Python bindings to Rust's persistent data structures (rpds)" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "rpds_py-0.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3ad0fda1635f8439cde85c700f964b23ed5fc2d28016b32b9ee5fe30da5c84e2"}, {file = "rpds_py-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9bb4a0d90fdb03437c109a17eade42dfbf6190408f29b2744114d11586611d6f"}, @@ -2725,7 +2616,6 @@ version = "0.6.2" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "ruff-0.6.2-py3-none-linux_armv6l.whl", hash = "sha256:5c8cbc6252deb3ea840ad6a20b0f8583caab0c5ef4f9cca21adc5a92b8f79f3c"}, {file = "ruff-0.6.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:17002fe241e76544448a8e1e6118abecbe8cd10cf68fde635dad480dba594570"}, @@ -2753,7 +2643,6 @@ version = "1.16.0" requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" summary = "Python 2 and 3 compatibility utilities" groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, @@ -2765,7 +2654,6 @@ version = "5.0.1" requires_python = ">=3.7" summary = "A pure Python implementation of a sliding window memory map manager" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"}, {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, @@ -2777,7 +2665,6 @@ version = "1.3.1" requires_python = ">=3.7" summary = "Sniff out which async library your code is running under" groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -2789,7 +2676,6 @@ version = "2.6" requires_python = ">=3.8" summary = "A modern CSS selector implementation for Beautiful Soup." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"}, {file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"}, @@ -2800,7 +2686,6 @@ name = "stack-data" version = "0.6.3" summary = "Extract data from python stack frames and tracebacks for informative displays" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "asttokens>=2.1.0", "executing>=1.2.0", @@ -2817,7 +2702,6 @@ version = "1.0.0" requires_python = ">=3.6" summary = "Standard Webhooks" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "Deprecated", "attrs>=21.3.0", @@ -2836,7 +2720,6 @@ version = "1.3.0" requires_python = ">=3.8" summary = "A tiny CSS parser" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "webencodings>=0.4", ] @@ -2851,7 +2734,7 @@ version = "2.0.1" requires_python = ">=3.7" summary = "A lil' TOML parser" groups = ["dev"] -marker = "python_version < \"3.11\" and python_version >= \"3.9\"" +marker = "python_version < \"3.11\"" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, @@ -2863,7 +2746,6 @@ version = "6.4.1" requires_python = ">=3.8" summary = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:163b0aafc8e23d8cdc3c9dfb24c5368af84a81e3364745ccb4427669bf84aec8"}, {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6d5ce3437e18a2b66fbadb183c1d3364fb03f2be71299e7d10dbeeb69f4b2a14"}, @@ -2884,7 +2766,6 @@ version = "4.66.5" requires_python = ">=3.7" summary = "Fast, Extensible Progress Meter" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "colorama; platform_system == \"Windows\"", ] @@ -2899,7 +2780,6 @@ version = "5.14.3" requires_python = ">=3.8" summary = "Traitlets Python configuration system" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, @@ -2907,26 +2787,24 @@ files = [ [[package]] name = "types-deprecated" -version = "1.2.9.20240311" +version = "1.2.15.20241117" requires_python = ">=3.8" summary = "Typing stubs for Deprecated" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ - {file = "types-Deprecated-1.2.9.20240311.tar.gz", hash = "sha256:0680e89989a8142707de8103f15d182445a533c1047fd9b7e8c5459101e9b90a"}, - {file = "types_Deprecated-1.2.9.20240311-py3-none-any.whl", hash = "sha256:d7793aaf32ff8f7e49a8ac781de4872248e0694c4b75a7a8a186c51167463f9d"}, + {file = "types-Deprecated-1.2.15.20241117.tar.gz", hash = "sha256:924002c8b7fddec51ba4949788a702411a2e3636cd9b2a33abd8ee119701d77e"}, + {file = "types_Deprecated-1.2.15.20241117-py3-none-any.whl", hash = "sha256:a0cc5e39f769fc54089fd8e005416b55d74aa03f6964d2ed1a0b0b2e28751884"}, ] [[package]] name = "types-python-dateutil" -version = "2.9.0.20240906" +version = "2.9.0.20241003" requires_python = ">=3.8" summary = "Typing stubs for python-dateutil" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ - {file = "types-python-dateutil-2.9.0.20240906.tar.gz", hash = "sha256:9706c3b68284c25adffc47319ecc7947e5bb86b3773f843c73906fd598bc176e"}, - {file = "types_python_dateutil-2.9.0.20240906-py3-none-any.whl", hash = "sha256:27c8cc2d058ccb14946eebcaaa503088f4f6dbc4fb6093d3d456a49aef2753f6"}, + {file = "types-python-dateutil-2.9.0.20241003.tar.gz", hash = "sha256:58cb85449b2a56d6684e41aeefb4c4280631246a0da1a719bdbe6f3fb0317446"}, + {file = "types_python_dateutil-2.9.0.20241003-py3-none-any.whl", hash = "sha256:250e1d8e80e7bbc3a6c99b907762711d1a1cdd00e978ad39cb5940f6f0a87f3d"}, ] [[package]] @@ -2935,7 +2813,6 @@ version = "4.12.2" requires_python = ">=3.8" summary = "Backported and Experimental Type Hints for Python 3.8+" groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, @@ -2947,7 +2824,6 @@ version = "2024.1" requires_python = ">=2" summary = "Provider of IANA time zone data" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, @@ -2959,7 +2835,6 @@ version = "2.2.2" requires_python = ">=3.8" summary = "HTTP library with thread-safe connection pooling, file post, and more." groups = ["default", "dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, @@ -2970,7 +2845,6 @@ name = "verspec" version = "0.1.0" summary = "Flexible version handling" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31"}, {file = "verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e"}, @@ -2982,7 +2856,6 @@ version = "20.26.3" requires_python = ">=3.7" summary = "Virtual Python Environment builder" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "distlib<1,>=0.3.7", "filelock<4,>=3.12.2", @@ -3000,7 +2873,6 @@ version = "4.0.2" requires_python = ">=3.8" summary = "Filesystem events monitoring" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "watchdog-4.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ede7f010f2239b97cc79e6cb3c249e72962404ae3865860855d5cbe708b0fd22"}, {file = "watchdog-4.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2cffa171445b0efa0726c561eca9a27d00a1f2b83846dbd5a4f639c4f8ca8e1"}, @@ -3036,7 +2908,6 @@ name = "wcwidth" version = "0.2.13" summary = "Measures the displayed width of unicode strings in a terminal" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "backports-functools-lru-cache>=1.2.1; python_version < \"3.2\"", ] @@ -3050,7 +2921,6 @@ name = "webencodings" version = "0.5.1" summary = "Character encoding aliases for legacy web content" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, @@ -3058,43 +2928,53 @@ files = [ [[package]] name = "wrapt" -version = "1.14.1" -requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "1.16.0" +requires_python = ">=3.6" summary = "Module for decorators, wrappers and monkey patching." groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" -files = [ - {file = "wrapt-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:80bb5c256f1415f747011dc3604b59bc1f91c6e7150bd7db03b19170ee06b320"}, - {file = "wrapt-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07f7a7d0f388028b2df1d916e94bbb40624c59b48ecc6cbc232546706fac74c2"}, - {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02b41b633c6261feff8ddd8d11c711df6842aba629fdd3da10249a53211a72c4"}, - {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fe803deacd09a233e4762a1adcea5db5d31e6be577a43352936179d14d90069"}, - {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:257fd78c513e0fb5cdbe058c27a0624c9884e735bbd131935fd49e9fe719d310"}, - {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4fcc4649dc762cddacd193e6b55bc02edca674067f5f98166d7713b193932b7f"}, - {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:11871514607b15cfeb87c547a49bca19fde402f32e2b1c24a632506c0a756656"}, - {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8ad85f7f4e20964db4daadcab70b47ab05c7c1cf2a7c1e51087bfaa83831854c"}, - {file = "wrapt-1.14.1-cp310-cp310-win32.whl", hash = "sha256:a9a52172be0b5aae932bef82a79ec0a0ce87288c7d132946d645eba03f0ad8a8"}, - {file = "wrapt-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:6d323e1554b3d22cfc03cd3243b5bb815a51f5249fdcbb86fda4bf62bab9e164"}, - {file = "wrapt-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ecee4132c6cd2ce5308e21672015ddfed1ff975ad0ac8d27168ea82e71413f55"}, - {file = "wrapt-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2020f391008ef874c6d9e208b24f28e31bcb85ccff4f335f15a3251d222b92d9"}, - {file = "wrapt-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2feecf86e1f7a86517cab34ae6c2f081fd2d0dac860cb0c0ded96d799d20b335"}, - {file = "wrapt-1.14.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:240b1686f38ae665d1b15475966fe0472f78e71b1b4903c143a842659c8e4cb9"}, - {file = "wrapt-1.14.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9008dad07d71f68487c91e96579c8567c98ca4c3881b9b113bc7b33e9fd78b8"}, - {file = "wrapt-1.14.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6447e9f3ba72f8e2b985a1da758767698efa72723d5b59accefd716e9e8272bf"}, - {file = "wrapt-1.14.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:acae32e13a4153809db37405f5eba5bac5fbe2e2ba61ab227926a22901051c0a"}, - {file = "wrapt-1.14.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49ef582b7a1152ae2766557f0550a9fcbf7bbd76f43fbdc94dd3bf07cc7168be"}, - {file = "wrapt-1.14.1-cp311-cp311-win32.whl", hash = "sha256:358fe87cc899c6bb0ddc185bf3dbfa4ba646f05b1b0b9b5a27c2cb92c2cea204"}, - {file = "wrapt-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:26046cd03936ae745a502abf44dac702a5e6880b2b01c29aea8ddf3353b68224"}, - {file = "wrapt-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3232822c7d98d23895ccc443bbdf57c7412c5a65996c30442ebe6ed3df335383"}, - {file = "wrapt-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:988635d122aaf2bdcef9e795435662bcd65b02f4f4c1ae37fbee7401c440b3a7"}, - {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cca3c2cdadb362116235fdbd411735de4328c61425b0aa9f872fd76d02c4e86"}, - {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d52a25136894c63de15a35bc0bdc5adb4b0e173b9c0d07a2be9d3ca64a332735"}, - {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40e7bc81c9e2b2734ea4bc1aceb8a8f0ceaac7c5299bc5d69e37c44d9081d43b"}, - {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b9b7a708dd92306328117d8c4b62e2194d00c365f18eff11a9b53c6f923b01e3"}, - {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6a9a25751acb379b466ff6be78a315e2b439d4c94c1e99cb7266d40a537995d3"}, - {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:34aa51c45f28ba7f12accd624225e2b1e5a3a45206aa191f6f9aac931d9d56fe"}, - {file = "wrapt-1.14.1-cp39-cp39-win32.whl", hash = "sha256:dee0ce50c6a2dd9056c20db781e9c1cfd33e77d2d569f5d1d9321c641bb903d5"}, - {file = "wrapt-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:dee60e1de1898bde3b238f18340eec6148986da0455d8ba7848d50470a7a32fb"}, - {file = "wrapt-1.14.1.tar.gz", hash = "sha256:380a85cf89e0e69b7cfbe2ea9f765f004ff419f34194018a6827ac0e3edfed4d"}, +files = [ + {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, + {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, + {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, + {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, + {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, + {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, + {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, + {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, + {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, + {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, + {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, + {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, ] [[package]] @@ -3103,7 +2983,6 @@ version = "3.5.0" requires_python = ">=3.7" summary = "Python binding for xxHash" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "xxhash-3.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ece616532c499ee9afbb83078b1b952beffef121d989841f7f4b3dc5ac0fd212"}, {file = "xxhash-3.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3171f693dbc2cef6477054a665dc255d996646b4023fe56cb4db80e26f4cc520"}, @@ -3184,7 +3063,6 @@ version = "1.9.4" requires_python = ">=3.7" summary = "Yet another URL library" groups = ["default"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" dependencies = [ "idna>=2.0", "multidict>=4.0", @@ -3261,7 +3139,6 @@ version = "3.20.0" requires_python = ">=3.8" summary = "Backport of pathlib-compatible object wrapper for zip files" groups = ["dev"] -marker = "python_version < \"3.13\" and python_version >= \"3.9\"" files = [ {file = "zipp-3.20.0-py3-none-any.whl", hash = "sha256:58da6168be89f0be59beb194da1250516fdaa062ccebd30127ac65d30045e10d"}, {file = "zipp-3.20.0.tar.gz", hash = "sha256:0145e43d89664cfe1a2e533adc75adafed82fe2da404b4bbb6b026c0157bdb31"}, diff --git a/argilla/pyproject.toml b/argilla/pyproject.toml index 2eb698b676..1872e0a575 100644 --- a/argilla/pyproject.toml +++ b/argilla/pyproject.toml @@ -1,12 +1,10 @@ [project] name = "argilla" description = "The Argilla python server SDK" -authors = [ - {name = "Argilla", email = "contact@argilla.io"}, -] +authors = [{ name = "Argilla", email = "contact@argilla.io" }] requires-python = ">= 3.9" readme = "README.md" -license = {text = "Apache 2.0"} +license = { text = "Apache 2.0" } dynamic = ["version"] @@ -21,9 +19,7 @@ dependencies = [ "standardwebhooks>=1.0.0", ] -legacy = [ - "argilla-v1[listeners]", -] +legacy = ["argilla-v1[listeners]"] [build-system] requires = ["pdm-backend"] @@ -72,6 +68,4 @@ dev = [ test = { cmd = "pytest tests", env_file = ".env.test" } lint = "ruff check" format = "black ." -all = {composite = ["format", "lint", "test"]} - - +all = { composite = ["format", "lint", "test"] } From 7740799bf41112bc8870f3beeb544b808cd3085c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Tue, 19 Nov 2024 14:30:32 +0100 Subject: [PATCH 23/24] change: use default queue instead of low queue for jobs --- argilla-server/CHANGELOG.md | 1 + argilla-server/docker/argilla-hf-spaces/Procfile | 2 +- argilla-server/src/argilla_server/cli/worker.py | 4 ++-- argilla-server/src/argilla_server/jobs/dataset_jobs.py | 4 ++-- argilla-server/src/argilla_server/jobs/hub_jobs.py | 4 ++-- argilla-server/src/argilla_server/jobs/queues.py | 2 +- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/argilla-server/CHANGELOG.md b/argilla-server/CHANGELOG.md index 81c10821fd..c1fee89500 100644 --- a/argilla-server/CHANGELOG.md +++ b/argilla-server/CHANGELOG.md @@ -22,6 +22,7 @@ These are the section headers that we use: - Added new webhook events when responses are created, updated, deleted. ([#5468](https://github.com/argilla-io/argilla/pull/5468)) - Added new webhook events when datasets are created, updated, deleted or published. ([#5468](https://github.com/argilla-io/argilla/pull/5468)) - Added new webhook events when records are created, updated, deleted or completed. ([#5489](https://github.com/argilla-io/argilla/pull/5489)) +- Added new `high` RQ queue to process high priority background jobs. ([#5467](https://github.com/argilla-io/argilla/pull/5467)) ### Changed diff --git a/argilla-server/docker/argilla-hf-spaces/Procfile b/argilla-server/docker/argilla-hf-spaces/Procfile index 42d2e496e0..940bc02e6d 100644 --- a/argilla-server/docker/argilla-hf-spaces/Procfile +++ b/argilla-server/docker/argilla-hf-spaces/Procfile @@ -1,5 +1,5 @@ elastic: /usr/share/elasticsearch/bin/elasticsearch redis: /usr/bin/redis-server worker_high: sleep 30; rq worker-pool --num-workers 2 high -worker_low: sleep 30; rq worker-pool --num-workers 1 low +worker_default: sleep 30; rq worker-pool --num-workers 1 default argilla: sleep 30; /bin/bash start_argilla_server.sh diff --git a/argilla-server/src/argilla_server/cli/worker.py b/argilla-server/src/argilla_server/cli/worker.py index befbc88c66..cce9330ed9 100644 --- a/argilla-server/src/argilla_server/cli/worker.py +++ b/argilla-server/src/argilla_server/cli/worker.py @@ -16,13 +16,13 @@ from typing import List -from argilla_server.jobs.queues import LOW_QUEUE, HIGH_QUEUE +from argilla_server.jobs.queues import DEFAULT_QUEUE, HIGH_QUEUE DEFAULT_NUM_WORKERS = 2 def worker( - queues: List[str] = typer.Option([LOW_QUEUE.name, HIGH_QUEUE.name], help="Name of queues to listen"), + queues: List[str] = typer.Option([DEFAULT_QUEUE.name, HIGH_QUEUE.name], help="Name of queues to listen"), num_workers: int = typer.Option(DEFAULT_NUM_WORKERS, help="Number of workers to start"), ) -> None: from rq.worker_pool import WorkerPool diff --git a/argilla-server/src/argilla_server/jobs/dataset_jobs.py b/argilla-server/src/argilla_server/jobs/dataset_jobs.py index 07cec95bb0..a34c92e8ae 100644 --- a/argilla-server/src/argilla_server/jobs/dataset_jobs.py +++ b/argilla-server/src/argilla_server/jobs/dataset_jobs.py @@ -21,7 +21,7 @@ from argilla_server.models import Record, Response from argilla_server.database import AsyncSessionLocal -from argilla_server.jobs.queues import LOW_QUEUE, JOB_TIMEOUT_DISABLED +from argilla_server.jobs.queues import DEFAULT_QUEUE, JOB_TIMEOUT_DISABLED from argilla_server.search_engine.base import SearchEngine from argilla_server.settings import settings from argilla_server.contexts import distribution @@ -29,7 +29,7 @@ JOB_RECORDS_YIELD_PER = 100 -@job(LOW_QUEUE, timeout=JOB_TIMEOUT_DISABLED, retry=Retry(max=3)) +@job(DEFAULT_QUEUE, timeout=JOB_TIMEOUT_DISABLED, retry=Retry(max=3)) async def update_dataset_records_status_job(dataset_id: UUID) -> None: """This Job updates the status of all the records in the dataset when the distribution strategy changes.""" diff --git a/argilla-server/src/argilla_server/jobs/hub_jobs.py b/argilla-server/src/argilla_server/jobs/hub_jobs.py index 6e079bde7e..3c3611cfdd 100644 --- a/argilla-server/src/argilla_server/jobs/hub_jobs.py +++ b/argilla-server/src/argilla_server/jobs/hub_jobs.py @@ -24,12 +24,12 @@ from argilla_server.database import AsyncSessionLocal from argilla_server.search_engine.base import SearchEngine from argilla_server.api.schemas.v1.datasets import HubDatasetMapping -from argilla_server.jobs.queues import LOW_QUEUE, JOB_TIMEOUT_DISABLED +from argilla_server.jobs.queues import DEFAULT_QUEUE, JOB_TIMEOUT_DISABLED HUB_DATASET_TAKE_ROWS = 10_000 -@job(LOW_QUEUE, timeout=JOB_TIMEOUT_DISABLED, retry=Retry(max=3)) +@job(DEFAULT_QUEUE, timeout=JOB_TIMEOUT_DISABLED, retry=Retry(max=3)) async def import_dataset_from_hub_job(name: str, subset: str, split: str, dataset_id: UUID, mapping: dict) -> None: async with AsyncSessionLocal() as db: dataset = await Dataset.get_or_raise( diff --git a/argilla-server/src/argilla_server/jobs/queues.py b/argilla-server/src/argilla_server/jobs/queues.py index ff7670ceac..8733f7f902 100644 --- a/argilla-server/src/argilla_server/jobs/queues.py +++ b/argilla-server/src/argilla_server/jobs/queues.py @@ -20,7 +20,7 @@ REDIS_CONNECTION = redis.from_url(settings.redis_url) -LOW_QUEUE = Queue("low", connection=REDIS_CONNECTION) +DEFAULT_QUEUE = Queue("default", connection=REDIS_CONNECTION) HIGH_QUEUE = Queue("high", connection=REDIS_CONNECTION) JOB_TIMEOUT_DISABLED = -1 From fe69e554ea8a4f2f4d357d4896c6eaadfe257ee7 Mon Sep 17 00:00:00 2001 From: Francisco Aranda Date: Tue, 19 Nov 2024 14:46:52 +0100 Subject: [PATCH 24/24] chore: Remove env/hosts section --- examples/webhooks/basic-webhooks/README.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/examples/webhooks/basic-webhooks/README.md b/examples/webhooks/basic-webhooks/README.md index 89fa305276..24f64a0c3e 100644 --- a/examples/webhooks/basic-webhooks/README.md +++ b/examples/webhooks/basic-webhooks/README.md @@ -10,17 +10,6 @@ pdm server start pdm worker ``` -2. Add the `localhost.org` alias in the `/etc/hosts` file to comply with the Top Level Domain URL requirement. -``` -## -# Host Database -# -# localhost is used to configure the loopback interface -# when the system is booting. Do not change this entry. -## -127.0.0.1 localhost localhost.org -``` - 2. Start the app ```bash uvicorn main:server