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}> + + + + + + + + + + 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}> + + + + + + + + + + + """ + + 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."