diff --git a/poetry.lock b/poetry.lock
index 0b9afae..f9a134d 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,5 +1,19 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+description = "Reusable constraint types to use with typing.Annotated"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
+ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
+]
+
+[package.dependencies]
+typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""}
+
[[package]]
name = "backports-tarfile"
version = "1.2.0"
@@ -927,6 +941,157 @@ files = [
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
]
+[[package]]
+name = "pydantic"
+version = "2.10.6"
+description = "Data validation using Python type hints"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584"},
+ {file = "pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236"},
+]
+
+[package.dependencies]
+annotated-types = ">=0.6.0"
+pydantic-core = "2.27.2"
+typing-extensions = ">=4.12.2"
+
+[package.extras]
+email = ["email-validator (>=2.0.0)"]
+timezone = ["tzdata"]
+
+[[package]]
+name = "pydantic-core"
+version = "2.27.2"
+description = "Core functionality for Pydantic validation and serialization"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35"},
+ {file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
+
+[[package]]
+name = "pydantic-xml"
+version = "2.14.2"
+description = "pydantic xml extension"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pydantic_xml-2.14.2-py3-none-any.whl", hash = "sha256:c21f5b777ae39d6cb6da7474b3f97a90d42a22cdc8dc3db7cf53d9b1ba119a33"},
+ {file = "pydantic_xml-2.14.2.tar.gz", hash = "sha256:73206dfd623e838791a612ef398834732bfa2b4b4b853b0126d1298f71199d78"},
+]
+
+[package.dependencies]
+pydantic = ">=2.6.0,<2.10.0b1 || >2.10.0b1"
+pydantic-core = ">=2.15.0"
+
+[package.extras]
+docs = ["Sphinx (>=5.3.0,<6.0.0)", "furo (>=2022.12.7,<2023.0.0)", "sphinx-copybutton (>=0.5.1,<0.6.0)", "sphinx_design (>=0.3.0,<0.4.0)", "toml (>=0.10.2,<0.11.0)"]
+lxml = ["lxml (>=4.9.0)"]
+
[[package]]
name = "pyflakes"
version = "2.5.0"
@@ -1337,4 +1502,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.8"
-content-hash = "cce97a3eba063cec408c554481efa57bff23a20a1baa86bd40590f9bffdc30e3"
+content-hash = "9c717c3cef42122fb097449fa23aa714e345666637ae525a16d4ab027b21156d"
diff --git a/pyproject.toml b/pyproject.toml
index 5327879..685c19e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,6 +15,7 @@ python = ">=3.8"
tqdm = "*"
pyyaml = "*"
pillow = "*"
+pydantic-xml = "*"
[tool.poetry.group.dev.dependencies]
mypy = "*"
diff --git a/src/labelformat/formats/__init__.py b/src/labelformat/formats/__init__.py
index 3d6f840..38a29dc 100644
--- a/src/labelformat/formats/__init__.py
+++ b/src/labelformat/formats/__init__.py
@@ -4,6 +4,7 @@
COCOObjectDetectionInput,
COCOObjectDetectionOutput,
)
+from labelformat.formats.cvat import CVATObjectDetectionInput, CVATObjectDetectionOutput
from labelformat.formats.kitti import (
KittiObjectDetectionInput,
KittiObjectDetectionOutput,
@@ -53,6 +54,8 @@
"COCOInstanceSegmentationOutput",
"COCOObjectDetectionInput",
"COCOObjectDetectionOutput",
+ "CVATObjectDetectionInput",
+ "CVATObjectDetectionOutput",
"KittiObjectDetectionInput",
"KittiObjectDetectionOutput",
"LabelboxObjectDetectionInput",
diff --git a/src/labelformat/formats/cvat.py b/src/labelformat/formats/cvat.py
new file mode 100644
index 0000000..41e7214
--- /dev/null
+++ b/src/labelformat/formats/cvat.py
@@ -0,0 +1,234 @@
+import logging
+from argparse import ArgumentParser
+from enum import Enum
+from pathlib import Path
+from typing import Dict, Iterable, List, Literal, Optional
+
+from pydantic_xml import BaseXmlModel, attr, element
+
+from labelformat.cli.registry import Task, cli_register
+from labelformat.model.bounding_box import BoundingBox, BoundingBoxFormat
+from labelformat.model.category import Category
+from labelformat.model.image import Image
+from labelformat.model.object_detection import (
+ ImageObjectDetection,
+ ObjectDetectionInput,
+ ObjectDetectionOutput,
+ SingleObjectDetection,
+)
+from labelformat.types import ParseError
+
+logger = logging.getLogger(__name__)
+
+
+# The following Pydantic XML models describe the structure of CVAT XML files.
+class CVATLabel(BaseXmlModel, tag="label", search_mode="unordered"): # type: ignore
+ name: str = element()
+
+
+class CVATLabels(BaseXmlModel, tag="labels", search_mode="unordered"): # type: ignore
+ label_list: List[CVATLabel] = element(tag="label")
+
+
+class CVATTask(BaseXmlModel, tag="task", search_mode="unordered"): # type: ignore
+ labels: Optional[CVATLabels] = element(tag="labels")
+
+
+class CVATJob(BaseXmlModel, tag="job", search_mode="unordered"): # type: ignore
+ labels: Optional[CVATLabels] = element(tag="labels")
+
+
+class CVATProject(BaseXmlModel, tag="project", search_mode="unordered"): # type: ignore
+ labels: Optional[CVATLabels] = element(tag="labels")
+
+
+class CVATMeta(BaseXmlModel, tag="meta", search_mode="unordered"): # type: ignore
+ task: Optional[CVATTask] = element(default=None)
+ job: Optional[CVATJob] = element(default=None)
+ project: Optional[CVATProject] = element(default=None)
+
+
+class CVATBox(BaseXmlModel, tag="box", search_mode="unordered"): # type: ignore
+ label: str = attr()
+ xtl: float = attr()
+ ytl: float = attr()
+ xbr: float = attr()
+ ybr: float = attr()
+
+
+class CVATImage(BaseXmlModel, tag="image", search_mode="unordered"): # type: ignore
+ id: int = attr()
+ name: str = attr() # Filename
+ width: int = attr()
+ height: int = attr()
+ boxes: List[CVATBox] = element(tag="box", default=[])
+
+
+class CVATAnnotations(BaseXmlModel, tag="annotations", search_mode="unordered"): # type: ignore
+ meta: CVATMeta = element()
+ images: List[CVATImage] = element(tag="image", default=[])
+
+
+class _CVATBaseInput:
+ @staticmethod
+ def add_cli_arguments(parser: ArgumentParser) -> None:
+ parser.add_argument(
+ "--input-file",
+ type=Path,
+ required=True,
+ help="Path to input CVAT XML file",
+ )
+
+ def __init__(self, input_file: Path) -> None:
+ xml_text = input_file.read_text()
+ try:
+ self._data = CVATAnnotations.from_xml(xml_text)
+ except Exception as ex:
+ raise ValueError(f"Could not parse XML file {input_file}: {ex}") from ex
+
+ def get_categories(self) -> Iterable["Category"]:
+ meta = self._data.meta
+ labels: Optional[List[CVATLabel]] = None
+ if meta.task is not None and meta.task.labels:
+ labels = meta.task.labels.label_list
+ elif meta.job is not None and meta.job.labels:
+ labels = meta.job.labels.label_list
+ elif meta.project is not None and meta.project.labels:
+ labels = meta.project.labels.label_list
+ if labels is None:
+ raise ValueError(
+ "Could not find labels in meta/task, meta/job, or meta/project"
+ )
+ for idx, label in enumerate(labels, start=1):
+ yield Category(id=idx, name=label.name)
+
+ def get_images(self) -> Iterable["Image"]:
+ for img in self._data.images:
+ yield Image(
+ id=img.id,
+ filename=img.name,
+ width=img.width,
+ height=img.height,
+ )
+
+
+@cli_register(format="cvat", task=Task.OBJECT_DETECTION)
+class CVATObjectDetectionInput(_CVATBaseInput, ObjectDetectionInput):
+ def get_labels(self) -> Iterable["ImageObjectDetection"]:
+ category_by_name: Dict[str, Category] = {
+ cat.name: cat for cat in self.get_categories()
+ }
+ for img in self._data.images:
+ objects = []
+ for box in img.boxes:
+ cat = category_by_name.get(box.label)
+ if cat is None:
+ raise ParseError(f"Unknown category name '{box.label}'.")
+ objects.append(
+ SingleObjectDetection(
+ category=cat,
+ box=BoundingBox.from_format(
+ bbox=[box.xtl, box.ytl, box.xbr, box.ybr],
+ format=BoundingBoxFormat.XYXY,
+ ),
+ )
+ )
+ yield ImageObjectDetection(
+ image=Image(
+ id=img.id, filename=img.name, width=img.width, height=img.height
+ ),
+ objects=objects,
+ )
+
+
+class AnnotationScope(Enum):
+ TASK = "task"
+ JOB = "job"
+ PROJECT = "project"
+
+ @staticmethod
+ def allowed_values() -> str:
+ return ", ".join(scope.value for scope in AnnotationScope)
+
+
+class _CVATBaseOutput:
+ @staticmethod
+ def add_cli_arguments(parser: ArgumentParser) -> None:
+ parser.add_argument(
+ "--output-folder",
+ type=Path,
+ required=True,
+ help="Output folder to store generated CVAT XML annotations file",
+ )
+ parser.add_argument(
+ "--output-annotation-scope",
+ choices=[scope.value for scope in AnnotationScope],
+ default="task",
+ help="Define the annotation scope to determine the XML structure. Allowed values: "
+ + AnnotationScope.allowed_values(),
+ )
+
+ def __init__(
+ self,
+ output_folder: Path,
+ output_annotation_scope: Literal["task", "job", "project"] = "task",
+ ) -> None:
+ try:
+ self._annotation_scope = AnnotationScope(output_annotation_scope)
+ except ValueError:
+ raise ValueError(
+ f"annotation_scope must be one of the allowed values: {AnnotationScope.allowed_values()}"
+ )
+ self._output_folder = output_folder
+
+
+@cli_register(format="cvat", task=Task.OBJECT_DETECTION)
+class CVATObjectDetectionOutput(_CVATBaseOutput, ObjectDetectionOutput):
+ def save(self, label_input: ObjectDetectionInput) -> None:
+ images = [
+ CVATImage(
+ id=label.image.id,
+ name=label.image.filename,
+ width=label.image.width,
+ height=label.image.height,
+ boxes=[
+ CVATBox(
+ label=obj.category.name,
+ xtl=obj.box.xmin,
+ ytl=obj.box.ymin,
+ xbr=obj.box.xmax,
+ ybr=obj.box.ymax,
+ )
+ for obj in label.objects
+ ],
+ )
+ for label in label_input.get_labels()
+ ]
+ labels = CVATLabels(
+ label_list=[
+ CVATLabel(name=cat.name) for cat in label_input.get_categories()
+ ]
+ )
+ if self._annotation_scope == AnnotationScope.TASK:
+ meta = CVATMeta(task=CVATTask(labels=labels))
+ elif self._annotation_scope == AnnotationScope.PROJECT:
+ meta = CVATMeta(project=CVATProject(labels=labels))
+ elif self._annotation_scope == AnnotationScope.JOB:
+ meta = CVATMeta(job=CVATJob(labels=labels))
+ else:
+ raise ValueError(
+ f"Unknown annotation_scope: {self._annotation_scope}. Allowed values: {AnnotationScope.allowed_values()}."
+ )
+ annotations = CVATAnnotations(meta=meta, images=images)
+
+ self._output_folder.mkdir(parents=True, exist_ok=True)
+ output_file = self._output_folder / "annotations.xml"
+ # Convert the Pydantic model to an XML format (as bytes or string).
+ xml_bytes = annotations.to_xml()
+ # Ensure the XML output is a string — decode bytes or use as-is if it's already a string.
+ if isinstance(xml_bytes, bytes):
+ xml_string = xml_bytes.decode("utf-8")
+ else:
+ xml_string = xml_bytes
+ with output_file.open("w", encoding="utf-8") as f:
+ f.write(xml_string)
diff --git a/tests/unit/formats/test_cvat.py b/tests/unit/formats/test_cvat.py
new file mode 100644
index 0000000..a89e40b
--- /dev/null
+++ b/tests/unit/formats/test_cvat.py
@@ -0,0 +1,212 @@
+import xml.etree.ElementTree as ET
+from pathlib import Path
+from typing import Optional
+
+import pytest
+
+from labelformat.formats.cvat import (
+ AnnotationScope,
+ CVATObjectDetectionInput,
+ CVATObjectDetectionOutput,
+)
+from labelformat.model.bounding_box import BoundingBox
+from labelformat.model.category import Category
+from labelformat.model.image import Image
+from labelformat.model.object_detection import (
+ ImageObjectDetection,
+ SingleObjectDetection,
+)
+from labelformat.types import ParseError
+
+
+# Helper for creating temp XML files
+def create_xml_file(tmp_path: Path, content: str) -> Path:
+ xml_path = tmp_path / "labels" / "annotations_in.xml"
+ xml_path.parent.mkdir(parents=True, exist_ok=True)
+ xml_path.write_text(content.strip())
+ return xml_path
+
+
+class TestCVATObjectDetectionInput:
+ @pytest.mark.parametrize(
+ "annotation_scope",
+ [AnnotationScope.TASK, AnnotationScope.PROJECT, AnnotationScope.JOB],
+ )
+ def test_get_labels(
+ self, tmp_path: Path, annotation_scope: AnnotationScope
+ ) -> None:
+ annotation = f"""
+
+ 1.1
+
+ <{annotation_scope.value}>
+
+
+
+
+ {annotation_scope.value}>
+
+
+
+
+ 1.1
+
+ """
+
+ xml_path = create_xml_file(tmp_path, annotation)
+ label_input = CVATObjectDetectionInput(xml_path)
+
+ # Validate categories.
+ categories = list(label_input.get_categories())
+ assert categories == [
+ Category(id=1, name="label1"),
+ Category(id=2, name="label2"),
+ ]
+
+ # Validate labels.
+ labels = list(label_input.get_labels())
+ assert labels == [
+ ImageObjectDetection(
+ image=Image(id=0, filename="img0.jpg", width=10, height=8),
+ objects=[
+ SingleObjectDetection(
+ category=Category(id=1, name="label1"),
+ box=BoundingBox(xmin=4.0, ymin=0.0, xmax=4.0, ymax=2.0),
+ )
+ ],
+ )
+ ]
+
+ def test___init___invalid_xml(self, tmp_path: Path) -> None:
+ invalid_annotation = """
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+ xml_path = create_xml_file(tmp_path, invalid_annotation)
+
+ with pytest.raises(
+ ValueError,
+ match="Input should be a valid number, unable to parse string as a number",
+ ):
+ label_input = CVATObjectDetectionInput(xml_path)
+
+ def test___init____missing_attributes_for_image(self, tmp_path: Path) -> None:
+ invalid_annotation = """
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+ xml_path = create_xml_file(tmp_path, invalid_annotation)
+
+ with pytest.raises(
+ ValueError,
+ match="validation error for CVATAnnotations\nimages.0.height",
+ ):
+ label_input = CVATObjectDetectionInput(xml_path)
+
+ def test_get_labels_invalid_category_name(self, tmp_path: Path) -> None:
+ invalid_annotation = """
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+ xml_path = create_xml_file(tmp_path, invalid_annotation)
+
+ with pytest.raises(ParseError, match="Unknown category name 'label2'"):
+ label_input = CVATObjectDetectionInput(xml_path)
+ list(label_input.get_labels())
+
+
+def _compare_xml_elements(elem1: ET.Element, elem2: ET.Element) -> bool:
+ """Recursively compare two XML elements for tag, attributes, and text."""
+
+ def normalize(text: Optional[str]) -> str:
+ return (text or "").strip().replace("\n", "").replace(" ", "")
+
+ if elem1.tag != elem2.tag or normalize(elem1.text) != normalize(elem2.text):
+ return False
+
+ if elem1.attrib != elem2.attrib:
+ return False
+
+ children1 = list(elem1)
+ children2 = list(elem2)
+
+ if len(children1) != len(children2):
+ return False
+
+ return all(_compare_xml_elements(c1, c2) for c1, c2 in zip(children1, children2))
+
+
+class TestCVATObjectDetectionOutput:
+ @pytest.mark.parametrize(
+ "annotation_scope",
+ [AnnotationScope.TASK, AnnotationScope.PROJECT, AnnotationScope.JOB],
+ )
+ def test_save_cyclic_load_and_save(
+ self, tmp_path: Path, annotation_scope: AnnotationScope
+ ) -> None:
+ annotation = f"""
+
+ <{annotation_scope.value}>
+
+
+
+
+ {annotation_scope.value}>
+
+
+
+
+
+ """
+
+ input_xml_path = create_xml_file(tmp_path, annotation)
+ label_input = CVATObjectDetectionInput(input_xml_path)
+ output_folder = tmp_path / "labels"
+
+ CVATObjectDetectionOutput(
+ output_folder=output_folder, output_annotation_scope=annotation_scope.value
+ ).save(label_input=label_input)
+
+ assert output_folder.exists()
+ assert output_folder.is_dir()
+ filepaths = list(output_folder.glob("**/*.xml"))
+ assert len(filepaths) == 2
+
+ output_xml_path = tmp_path / "labels" / "annotations.xml"
+ # Compare XML structure.
+ input_tree = ET.parse(input_xml_path)
+ output_tree = ET.parse(output_xml_path)
+
+ assert _compare_xml_elements(
+ input_tree.getroot(), output_tree.getroot()
+ ), "The output XML structure doesn't match the input XML."