From 7bb339d22553a06888b1405050987f1010b36e6d Mon Sep 17 00:00:00 2001 From: Linkous Sharp Date: Wed, 8 May 2024 09:50:14 -0500 Subject: [PATCH 1/8] Update dependencies with reported vulnerabilities --- poetry.lock | 412 ++++++++++++++++++++++++++++--------------------- pyproject.toml | 11 +- 2 files changed, 245 insertions(+), 178 deletions(-) diff --git a/poetry.lock b/poetry.lock index d8499248..8ac0b31a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -13,87 +13,87 @@ files = [ [[package]] name = "aiohttp" -version = "3.9.3" +version = "3.9.5" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" files = [ - {file = "aiohttp-3.9.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:939677b61f9d72a4fa2a042a5eee2a99a24001a67c13da113b2e30396567db54"}, - {file = "aiohttp-3.9.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1f5cd333fcf7590a18334c90f8c9147c837a6ec8a178e88d90a9b96ea03194cc"}, - {file = "aiohttp-3.9.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82e6aa28dd46374f72093eda8bcd142f7771ee1eb9d1e223ff0fa7177a96b4a5"}, - {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f56455b0c2c7cc3b0c584815264461d07b177f903a04481dfc33e08a89f0c26b"}, - {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bca77a198bb6e69795ef2f09a5f4c12758487f83f33d63acde5f0d4919815768"}, - {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e083c285857b78ee21a96ba1eb1b5339733c3563f72980728ca2b08b53826ca5"}, - {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab40e6251c3873d86ea9b30a1ac6d7478c09277b32e14745d0d3c6e76e3c7e29"}, - {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df822ee7feaaeffb99c1a9e5e608800bd8eda6e5f18f5cfb0dc7eeb2eaa6bbec"}, - {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:acef0899fea7492145d2bbaaaec7b345c87753168589cc7faf0afec9afe9b747"}, - {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cd73265a9e5ea618014802ab01babf1940cecb90c9762d8b9e7d2cc1e1969ec6"}, - {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a78ed8a53a1221393d9637c01870248a6f4ea5b214a59a92a36f18151739452c"}, - {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:6b0e029353361f1746bac2e4cc19b32f972ec03f0f943b390c4ab3371840aabf"}, - {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7cf5c9458e1e90e3c390c2639f1017a0379a99a94fdfad3a1fd966a2874bba52"}, - {file = "aiohttp-3.9.3-cp310-cp310-win32.whl", hash = "sha256:3e59c23c52765951b69ec45ddbbc9403a8761ee6f57253250c6e1536cacc758b"}, - {file = "aiohttp-3.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:055ce4f74b82551678291473f66dc9fb9048a50d8324278751926ff0ae7715e5"}, - {file = "aiohttp-3.9.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6b88f9386ff1ad91ace19d2a1c0225896e28815ee09fc6a8932fded8cda97c3d"}, - {file = "aiohttp-3.9.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c46956ed82961e31557b6857a5ca153c67e5476972e5f7190015018760938da2"}, - {file = "aiohttp-3.9.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07b837ef0d2f252f96009e9b8435ec1fef68ef8b1461933253d318748ec1acdc"}, - {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad46e6f620574b3b4801c68255492e0159d1712271cc99d8bdf35f2043ec266"}, - {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ed3e046ea7b14938112ccd53d91c1539af3e6679b222f9469981e3dac7ba1ce"}, - {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:039df344b45ae0b34ac885ab5b53940b174530d4dd8a14ed8b0e2155b9dddccb"}, - {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7943c414d3a8d9235f5f15c22ace69787c140c80b718dcd57caaade95f7cd93b"}, - {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84871a243359bb42c12728f04d181a389718710129b36b6aad0fc4655a7647d4"}, - {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5eafe2c065df5401ba06821b9a054d9cb2848867f3c59801b5d07a0be3a380ae"}, - {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9d3c9b50f19704552f23b4eaea1fc082fdd82c63429a6506446cbd8737823da3"}, - {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:f033d80bc6283092613882dfe40419c6a6a1527e04fc69350e87a9df02bbc283"}, - {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:2c895a656dd7e061b2fd6bb77d971cc38f2afc277229ce7dd3552de8313a483e"}, - {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1f5a71d25cd8106eab05f8704cd9167b6e5187bcdf8f090a66c6d88b634802b4"}, - {file = "aiohttp-3.9.3-cp311-cp311-win32.whl", hash = "sha256:50fca156d718f8ced687a373f9e140c1bb765ca16e3d6f4fe116e3df7c05b2c5"}, - {file = "aiohttp-3.9.3-cp311-cp311-win_amd64.whl", hash = "sha256:5fe9ce6c09668063b8447f85d43b8d1c4e5d3d7e92c63173e6180b2ac5d46dd8"}, - {file = "aiohttp-3.9.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:38a19bc3b686ad55804ae931012f78f7a534cce165d089a2059f658f6c91fa60"}, - {file = "aiohttp-3.9.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:770d015888c2a598b377bd2f663adfd947d78c0124cfe7b959e1ef39f5b13869"}, - {file = "aiohttp-3.9.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee43080e75fc92bf36219926c8e6de497f9b247301bbf88c5c7593d931426679"}, - {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52df73f14ed99cee84865b95a3d9e044f226320a87af208f068ecc33e0c35b96"}, - {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc9b311743a78043b26ffaeeb9715dc360335e5517832f5a8e339f8a43581e4d"}, - {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b955ed993491f1a5da7f92e98d5dad3c1e14dc175f74517c4e610b1f2456fb11"}, - {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504b6981675ace64c28bf4a05a508af5cde526e36492c98916127f5a02354d53"}, - {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fe5571784af92b6bc2fda8d1925cccdf24642d49546d3144948a6a1ed58ca5"}, - {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ba39e9c8627edc56544c8628cc180d88605df3892beeb2b94c9bc857774848ca"}, - {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e5e46b578c0e9db71d04c4b506a2121c0cb371dd89af17a0586ff6769d4c58c1"}, - {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:938a9653e1e0c592053f815f7028e41a3062e902095e5a7dc84617c87267ebd5"}, - {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:c3452ea726c76e92f3b9fae4b34a151981a9ec0a4847a627c43d71a15ac32aa6"}, - {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff30218887e62209942f91ac1be902cc80cddb86bf00fbc6783b7a43b2bea26f"}, - {file = "aiohttp-3.9.3-cp312-cp312-win32.whl", hash = "sha256:38f307b41e0bea3294a9a2a87833191e4bcf89bb0365e83a8be3a58b31fb7f38"}, - {file = "aiohttp-3.9.3-cp312-cp312-win_amd64.whl", hash = "sha256:b791a3143681a520c0a17e26ae7465f1b6f99461a28019d1a2f425236e6eedb5"}, - {file = "aiohttp-3.9.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0ed621426d961df79aa3b963ac7af0d40392956ffa9be022024cd16297b30c8c"}, - {file = "aiohttp-3.9.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7f46acd6a194287b7e41e87957bfe2ad1ad88318d447caf5b090012f2c5bb528"}, - {file = "aiohttp-3.9.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feeb18a801aacb098220e2c3eea59a512362eb408d4afd0c242044c33ad6d542"}, - {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f734e38fd8666f53da904c52a23ce517f1b07722118d750405af7e4123933511"}, - {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b40670ec7e2156d8e57f70aec34a7216407848dfe6c693ef131ddf6e76feb672"}, - {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fdd215b7b7fd4a53994f238d0f46b7ba4ac4c0adb12452beee724ddd0743ae5d"}, - {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:017a21b0df49039c8f46ca0971b3a7fdc1f56741ab1240cb90ca408049766168"}, - {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e99abf0bba688259a496f966211c49a514e65afa9b3073a1fcee08856e04425b"}, - {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:648056db9a9fa565d3fa851880f99f45e3f9a771dd3ff3bb0c048ea83fb28194"}, - {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8aacb477dc26797ee089721536a292a664846489c49d3ef9725f992449eda5a8"}, - {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:522a11c934ea660ff8953eda090dcd2154d367dec1ae3c540aff9f8a5c109ab4"}, - {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5bce0dc147ca85caa5d33debc4f4d65e8e8b5c97c7f9f660f215fa74fc49a321"}, - {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4b4af9f25b49a7be47c0972139e59ec0e8285c371049df1a63b6ca81fdd216a2"}, - {file = "aiohttp-3.9.3-cp38-cp38-win32.whl", hash = "sha256:298abd678033b8571995650ccee753d9458dfa0377be4dba91e4491da3f2be63"}, - {file = "aiohttp-3.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:69361bfdca5468c0488d7017b9b1e5ce769d40b46a9f4a2eed26b78619e9396c"}, - {file = "aiohttp-3.9.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0fa43c32d1643f518491d9d3a730f85f5bbaedcbd7fbcae27435bb8b7a061b29"}, - {file = "aiohttp-3.9.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:835a55b7ca49468aaaac0b217092dfdff370e6c215c9224c52f30daaa735c1c1"}, - {file = "aiohttp-3.9.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06a9b2c8837d9a94fae16c6223acc14b4dfdff216ab9b7202e07a9a09541168f"}, - {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abf151955990d23f84205286938796c55ff11bbfb4ccfada8c9c83ae6b3c89a3"}, - {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59c26c95975f26e662ca78fdf543d4eeaef70e533a672b4113dd888bd2423caa"}, - {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f95511dd5d0e05fd9728bac4096319f80615aaef4acbecb35a990afebe953b0e"}, - {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:595f105710293e76b9dc09f52e0dd896bd064a79346234b521f6b968ffdd8e58"}, - {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7c8b816c2b5af5c8a436df44ca08258fc1a13b449393a91484225fcb7545533"}, - {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f1088fa100bf46e7b398ffd9904f4808a0612e1d966b4aa43baa535d1b6341eb"}, - {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f59dfe57bb1ec82ac0698ebfcdb7bcd0e99c255bd637ff613760d5f33e7c81b3"}, - {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:361a1026c9dd4aba0109e4040e2aecf9884f5cfe1b1b1bd3d09419c205e2e53d"}, - {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:363afe77cfcbe3a36353d8ea133e904b108feea505aa4792dad6585a8192c55a"}, - {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e2c45c208c62e955e8256949eb225bd8b66a4c9b6865729a786f2aa79b72e9d"}, - {file = "aiohttp-3.9.3-cp39-cp39-win32.whl", hash = "sha256:f7217af2e14da0856e082e96ff637f14ae45c10a5714b63c77f26d8884cf1051"}, - {file = "aiohttp-3.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:27468897f628c627230dba07ec65dc8d0db566923c48f29e084ce382119802bc"}, - {file = "aiohttp-3.9.3.tar.gz", hash = "sha256:90842933e5d1ff760fae6caca4b2b3edba53ba8f4b71e95dacf2818a2aca06f7"}, + {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcde4c397f673fdec23e6b05ebf8d4751314fa7c24f93334bf1f1364c1c69ac7"}, + {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"}, + {file = "aiohttp-3.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae79c1bc12c34082d92bf9422764f799aee4746fd7a392db46b7fd357d4a17a"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d3ebb9e1316ec74277d19c5f482f98cc65a73ccd5430540d6d11682cd857430"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84dabd95154f43a2ea80deffec9cb44d2e301e38a0c9d331cc4aa0166fe28ae3"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a02fbeca6f63cb1f0475c799679057fc9268b77075ab7cf3f1c600e81dd46b"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c26959ca7b75ff768e2776d8055bf9582a6267e24556bb7f7bd29e677932be72"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:714d4e5231fed4ba2762ed489b4aec07b2b9953cf4ee31e9871caac895a839c0"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7a6a8354f1b62e15d48e04350f13e726fa08b62c3d7b8401c0a1314f02e3558"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c413016880e03e69d166efb5a1a95d40f83d5a3a648d16486592c49ffb76d0db"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ff84aeb864e0fac81f676be9f4685f0527b660f1efdc40dcede3c251ef1e867f"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ad7f2919d7dac062f24d6f5fe95d401597fbb015a25771f85e692d043c9d7832"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:702e2c7c187c1a498a4e2b03155d52658fdd6fda882d3d7fbb891a5cf108bb10"}, + {file = "aiohttp-3.9.5-cp310-cp310-win32.whl", hash = "sha256:67c3119f5ddc7261d47163ed86d760ddf0e625cd6246b4ed852e82159617b5fb"}, + {file = "aiohttp-3.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:471f0ef53ccedec9995287f02caf0c068732f026455f07db3f01a46e49d76bbb"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ae53e33ee7476dd3d1132f932eeb39bf6125083820049d06edcdca4381f342"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c088c4d70d21f8ca5c0b8b5403fe84a7bc8e024161febdd4ef04575ef35d474d"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:639d0042b7670222f33b0028de6b4e2fad6451462ce7df2af8aee37dcac55424"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26383adb94da5e7fb388d441bf09c61e5e35f455a3217bfd790c6b6bc64b2ee"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66331d00fb28dc90aa606d9a54304af76b335ae204d1836f65797d6fe27f1ca2"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff550491f5492ab5ed3533e76b8567f4b37bd2995e780a1f46bca2024223233"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f22eb3a6c1080d862befa0a89c380b4dafce29dc6cd56083f630073d102eb595"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a81b1143d42b66ffc40a441379387076243ef7b51019204fd3ec36b9f69e77d6"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f64fd07515dad67f24b6ea4a66ae2876c01031de91c93075b8093f07c0a2d93d"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:93e22add827447d2e26d67c9ac0161756007f152fdc5210277d00a85f6c92323"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:55b39c8684a46e56ef8c8d24faf02de4a2b2ac60d26cee93bc595651ff545de9"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4715a9b778f4293b9f8ae7a0a7cef9829f02ff8d6277a39d7f40565c737d3771"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:afc52b8d969eff14e069a710057d15ab9ac17cd4b6753042c407dcea0e40bf75"}, + {file = "aiohttp-3.9.5-cp311-cp311-win32.whl", hash = "sha256:b3df71da99c98534be076196791adca8819761f0bf6e08e07fd7da25127150d6"}, + {file = "aiohttp-3.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:88e311d98cc0bf45b62fc46c66753a83445f5ab20038bcc1b8a1cc05666f428a"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c7a4b7a6cf5b6eb11e109a9755fd4fda7d57395f8c575e166d363b9fc3ec4678"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0a158704edf0abcac8ac371fbb54044f3270bdbc93e254a82b6c82be1ef08f3c"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d153f652a687a8e95ad367a86a61e8d53d528b0530ef382ec5aaf533140ed00f"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82a6a97d9771cb48ae16979c3a3a9a18b600a8505b1115cfe354dfb2054468b4"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60cdbd56f4cad9f69c35eaac0fbbdf1f77b0ff9456cebd4902f3dd1cf096464c"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8676e8fd73141ded15ea586de0b7cda1542960a7b9ad89b2b06428e97125d4fa"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da00da442a0e31f1c69d26d224e1efd3a1ca5bcbf210978a2ca7426dfcae9f58"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f634d540dd099c262e9f887c8bbacc959847cfe5da7a0e2e1cf3f14dbf2daf"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:320e8618eda64e19d11bdb3bd04ccc0a816c17eaecb7e4945d01deee2a22f95f"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:2faa61a904b83142747fc6a6d7ad8fccff898c849123030f8e75d5d967fd4a81"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:8c64a6dc3fe5db7b1b4d2b5cb84c4f677768bdc340611eca673afb7cf416ef5a"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:393c7aba2b55559ef7ab791c94b44f7482a07bf7640d17b341b79081f5e5cd1a"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c671dc117c2c21a1ca10c116cfcd6e3e44da7fcde37bf83b2be485ab377b25da"}, + {file = "aiohttp-3.9.5-cp312-cp312-win32.whl", hash = "sha256:5a7ee16aab26e76add4afc45e8f8206c95d1d75540f1039b84a03c3b3800dd59"}, + {file = "aiohttp-3.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:5ca51eadbd67045396bc92a4345d1790b7301c14d1848feaac1d6a6c9289e888"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:694d828b5c41255e54bc2dddb51a9f5150b4eefa9886e38b52605a05d96566e8"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0605cc2c0088fcaae79f01c913a38611ad09ba68ff482402d3410bf59039bfb8"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4558e5012ee03d2638c681e156461d37b7a113fe13970d438d95d10173d25f78"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbc053ac75ccc63dc3a3cc547b98c7258ec35a215a92bd9f983e0aac95d3d5b"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4109adee842b90671f1b689901b948f347325045c15f46b39797ae1bf17019de"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6ea1a5b409a85477fd8e5ee6ad8f0e40bf2844c270955e09360418cfd09abac"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3c2890ca8c59ee683fd09adf32321a40fe1cf164e3387799efb2acebf090c11"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3916c8692dbd9d55c523374a3b8213e628424d19116ac4308e434dbf6d95bbdd"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8d1964eb7617907c792ca00b341b5ec3e01ae8c280825deadbbd678447b127e1"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5ab8e1f6bee051a4bf6195e38a5c13e5e161cb7bad83d8854524798bd9fcd6e"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52c27110f3862a1afbcb2af4281fc9fdc40327fa286c4625dfee247c3ba90156"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7f64cbd44443e80094309875d4f9c71d0401e966d191c3d469cde4642bc2e031"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b4f72fbb66279624bfe83fd5eb6aea0022dad8eec62b71e7bf63ee1caadeafe"}, + {file = "aiohttp-3.9.5-cp38-cp38-win32.whl", hash = "sha256:6380c039ec52866c06d69b5c7aad5478b24ed11696f0e72f6b807cfb261453da"}, + {file = "aiohttp-3.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:da22dab31d7180f8c3ac7c7635f3bcd53808f374f6aa333fe0b0b9e14b01f91a"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1732102949ff6087589408d76cd6dea656b93c896b011ecafff418c9661dc4ed"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6021d296318cb6f9414b48e6a439a7f5d1f665464da507e8ff640848ee2a58a"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:239f975589a944eeb1bad26b8b140a59a3a320067fb3cd10b75c3092405a1372"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b7b30258348082826d274504fbc7c849959f1989d86c29bc355107accec6cfb"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd2adf5c87ff6d8b277814a28a535b59e20bfea40a101db6b3bdca7e9926bc24"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a3d838441bebcf5cf442700e3963f58b5c33f015341f9ea86dcd7d503c07e2"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e3a1ae66e3d0c17cf65c08968a5ee3180c5a95920ec2731f53343fac9bad106"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c69e77370cce2d6df5d12b4e12bdcca60c47ba13d1cbbc8645dd005a20b738b"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf56238f4bbf49dab8c2dc2e6b1b68502b1e88d335bea59b3f5b9f4c001475"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d1469f228cd9ffddd396d9948b8c9cd8022b6d1bf1e40c6f25b0fb90b4f893ed"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:45731330e754f5811c314901cebdf19dd776a44b31927fa4b4dbecab9e457b0c"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3fcb4046d2904378e3aeea1df51f697b0467f2aac55d232c87ba162709478c46"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8cf142aa6c1a751fcb364158fd710b8a9be874b81889c2bd13aa8893197455e2"}, + {file = "aiohttp-3.9.5-cp39-cp39-win32.whl", hash = "sha256:7b179eea70833c8dee51ec42f3b4097bd6370892fa93f510f76762105568cf09"}, + {file = "aiohttp-3.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:38d80498e2e169bc61418ff36170e0aad0cd268da8b38a17c4cf29d254a8b3f1"}, + {file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"}, ] [package.dependencies] @@ -121,6 +121,17 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "annotated-types" +version = "0.6.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, + {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, +] + [[package]] name = "anyio" version = "3.6.2" @@ -264,36 +275,33 @@ yaml = ["PyYAML"] [[package]] name = "black" -version = "23.1.0" +version = "24.4.2" description = "The uncompromising code formatter." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "black-23.1.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:b6a92a41ee34b883b359998f0c8e6eb8e99803aa8bf3123bf2b2e6fec505a221"}, - {file = "black-23.1.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:57c18c5165c1dbe291d5306e53fb3988122890e57bd9b3dcb75f967f13411a26"}, - {file = "black-23.1.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:9880d7d419bb7e709b37e28deb5e68a49227713b623c72b2b931028ea65f619b"}, - {file = "black-23.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6663f91b6feca5d06f2ccd49a10f254f9298cc1f7f49c46e498a0771b507104"}, - {file = "black-23.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9afd3f493666a0cd8f8df9a0200c6359ac53940cbde049dcb1a7eb6ee2dd7074"}, - {file = "black-23.1.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:bfffba28dc52a58f04492181392ee380e95262af14ee01d4bc7bb1b1c6ca8d27"}, - {file = "black-23.1.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c1c476bc7b7d021321e7d93dc2cbd78ce103b84d5a4cf97ed535fbc0d6660648"}, - {file = "black-23.1.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:382998821f58e5c8238d3166c492139573325287820963d2f7de4d518bd76958"}, - {file = "black-23.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf649fda611c8550ca9d7592b69f0637218c2369b7744694c5e4902873b2f3a"}, - {file = "black-23.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:121ca7f10b4a01fd99951234abdbd97728e1240be89fde18480ffac16503d481"}, - {file = "black-23.1.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:a8471939da5e824b891b25751955be52ee7f8a30a916d570a5ba8e0f2eb2ecad"}, - {file = "black-23.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8178318cb74f98bc571eef19068f6ab5613b3e59d4f47771582f04e175570ed8"}, - {file = "black-23.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a436e7881d33acaf2536c46a454bb964a50eff59b21b51c6ccf5a40601fbef24"}, - {file = "black-23.1.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:a59db0a2094d2259c554676403fa2fac3473ccf1354c1c63eccf7ae65aac8ab6"}, - {file = "black-23.1.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:0052dba51dec07ed029ed61b18183942043e00008ec65d5028814afaab9a22fd"}, - {file = "black-23.1.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:49f7b39e30f326a34b5c9a4213213a6b221d7ae9d58ec70df1c4a307cf2a1580"}, - {file = "black-23.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:162e37d49e93bd6eb6f1afc3e17a3d23a823042530c37c3c42eeeaf026f38468"}, - {file = "black-23.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b70eb40a78dfac24842458476135f9b99ab952dd3f2dab738c1881a9b38b753"}, - {file = "black-23.1.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:a29650759a6a0944e7cca036674655c2f0f63806ddecc45ed40b7b8aa314b651"}, - {file = "black-23.1.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:bb460c8561c8c1bec7824ecbc3ce085eb50005883a6203dcfb0122e95797ee06"}, - {file = "black-23.1.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c91dfc2c2a4e50df0026f88d2215e166616e0c80e86004d0003ece0488db2739"}, - {file = "black-23.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a951cc83ab535d248c89f300eccbd625e80ab880fbcfb5ac8afb5f01a258ac9"}, - {file = "black-23.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0680d4380db3719ebcfb2613f34e86c8e6d15ffeabcf8ec59355c5e7b85bb555"}, - {file = "black-23.1.0-py3-none-any.whl", hash = "sha256:7a0f701d314cfa0896b9001df70a530eb2472babb76086344e688829efd97d32"}, - {file = "black-23.1.0.tar.gz", hash = "sha256:b0bd97bea8903f5a2ba7219257a44e3f1f9d00073d6cc1add68f0beec69692ac"}, + {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, + {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, + {file = "black-24.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063"}, + {file = "black-24.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96"}, + {file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"}, + {file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"}, + {file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"}, + {file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"}, + {file = "black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d"}, + {file = "black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04"}, + {file = "black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc"}, + {file = "black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0"}, + {file = "black-24.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7"}, + {file = "black-24.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94"}, + {file = "black-24.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8"}, + {file = "black-24.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c"}, + {file = "black-24.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1"}, + {file = "black-24.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741"}, + {file = "black-24.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"}, + {file = "black-24.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7"}, + {file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"}, + {file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"}, ] [package.dependencies] @@ -303,11 +311,11 @@ packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] +d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] @@ -864,24 +872,22 @@ tests = ["asttokens", "littleutils", "pytest", "rich"] [[package]] name = "fastapi" -version = "0.95.0" +version = "0.100.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.7" files = [ - {file = "fastapi-0.95.0-py3-none-any.whl", hash = "sha256:daf73bbe844180200be7966f68e8ec9fd8be57079dff1bacb366db32729e6eb5"}, - {file = "fastapi-0.95.0.tar.gz", hash = "sha256:99d4fdb10e9dd9a24027ac1d0bd4b56702652056ca17a6c8721eec4ad2f14e18"}, + {file = "fastapi-0.100.1-py3-none-any.whl", hash = "sha256:ec6dd52bfc4eff3063cfcd0713b43c87640fefb2687bbbe3d8a08d94049cdf32"}, + {file = "fastapi-0.100.1.tar.gz", hash = "sha256:522700d7a469e4a973d92321ab93312448fbe20fca9c8da97effc7e7bc56df23"}, ] [package.dependencies] -pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" -starlette = ">=0.26.1,<0.27.0" +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<3.0.0" +starlette = ">=0.27.0,<0.28.0" +typing-extensions = ">=4.5.0" [package.extras] -all = ["email-validator (>=1.1.1)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -dev = ["pre-commit (>=2.17.0,<3.0.0)", "ruff (==0.0.138)", "uvicorn[standard] (>=0.12.0,<0.21.0)"] -doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer-cli (>=0.0.13,<0.0.14)", "typer[all] (>=0.6.1,<0.8.0)"] -test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==23.1.0)", "coverage[toml] (>=6.5.0,<8.0)", "databases[sqlite] (>=0.3.2,<0.7.0)", "email-validator (>=1.1.1,<2.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.23.0,<0.24.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.982)", "orjson (>=3.2.1,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=7.1.3,<8.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.7)", "pyyaml (>=5.3.1,<7.0.0)", "ruff (==0.0.138)", "sqlalchemy (>=1.3.18,<1.4.43)", "types-orjson (==3.6.2)", "types-ujson (==5.7.0.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"] +all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] [[package]] name = "filelock" @@ -1408,13 +1414,13 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.4" +version = "3.7" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] [[package]] @@ -2184,55 +2190,113 @@ files = [ [[package]] name = "pydantic" -version = "1.10.6" -description = "Data validation and settings management using python type hints" +version = "2.7.1" +description = "Data validation using Python type hints" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.7.1-py3-none-any.whl", hash = "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5"}, + {file = "pydantic-2.7.1.tar.gz", hash = "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.18.2" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.18.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" files = [ - {file = "pydantic-1.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9289065611c48147c1dd1fd344e9d57ab45f1d99b0fb26c51f1cf72cd9bcd31"}, - {file = "pydantic-1.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c32b6bba301490d9bb2bf5f631907803135e8085b6aa3e5fe5a770d46dd0160"}, - {file = "pydantic-1.10.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd9b9e98068fa1068edfc9eabde70a7132017bdd4f362f8b4fd0abed79c33083"}, - {file = "pydantic-1.10.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c84583b9df62522829cbc46e2b22e0ec11445625b5acd70c5681ce09c9b11c4"}, - {file = "pydantic-1.10.6-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b41822064585fea56d0116aa431fbd5137ce69dfe837b599e310034171996084"}, - {file = "pydantic-1.10.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:61f1f08adfaa9cc02e0cbc94f478140385cbd52d5b3c5a657c2fceb15de8d1fb"}, - {file = "pydantic-1.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:32937835e525d92c98a1512218db4eed9ddc8f4ee2a78382d77f54341972c0e7"}, - {file = "pydantic-1.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbd5c531b22928e63d0cb1868dee76123456e1de2f1cb45879e9e7a3f3f1779b"}, - {file = "pydantic-1.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e277bd18339177daa62a294256869bbe84df1fb592be2716ec62627bb8d7c81d"}, - {file = "pydantic-1.10.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89f15277d720aa57e173954d237628a8d304896364b9de745dcb722f584812c7"}, - {file = "pydantic-1.10.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b243b564cea2576725e77aeeda54e3e0229a168bc587d536cd69941e6797543d"}, - {file = "pydantic-1.10.6-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3ce13a558b484c9ae48a6a7c184b1ba0e5588c5525482681db418268e5f86186"}, - {file = "pydantic-1.10.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3ac1cd4deed871dfe0c5f63721e29debf03e2deefa41b3ed5eb5f5df287c7b70"}, - {file = "pydantic-1.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:b1eb6610330a1dfba9ce142ada792f26bbef1255b75f538196a39e9e90388bf4"}, - {file = "pydantic-1.10.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4ca83739c1263a044ec8b79df4eefc34bbac87191f0a513d00dd47d46e307a65"}, - {file = "pydantic-1.10.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea4e2a7cb409951988e79a469f609bba998a576e6d7b9791ae5d1e0619e1c0f2"}, - {file = "pydantic-1.10.6-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53de12b4608290992a943801d7756f18a37b7aee284b9ffa794ee8ea8153f8e2"}, - {file = "pydantic-1.10.6-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:60184e80aac3b56933c71c48d6181e630b0fbc61ae455a63322a66a23c14731a"}, - {file = "pydantic-1.10.6-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:415a3f719ce518e95a92effc7ee30118a25c3d032455d13e121e3840985f2efd"}, - {file = "pydantic-1.10.6-cp37-cp37m-win_amd64.whl", hash = "sha256:72cb30894a34d3a7ab6d959b45a70abac8a2a93b6480fc5a7bfbd9c935bdc4fb"}, - {file = "pydantic-1.10.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3091d2eaeda25391405e36c2fc2ed102b48bac4b384d42b2267310abae350ca6"}, - {file = "pydantic-1.10.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:751f008cd2afe812a781fd6aa2fb66c620ca2e1a13b6a2152b1ad51553cb4b77"}, - {file = "pydantic-1.10.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12e837fd320dd30bd625be1b101e3b62edc096a49835392dcf418f1a5ac2b832"}, - {file = "pydantic-1.10.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:587d92831d0115874d766b1f5fddcdde0c5b6c60f8c6111a394078ec227fca6d"}, - {file = "pydantic-1.10.6-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:476f6674303ae7965730a382a8e8d7fae18b8004b7b69a56c3d8fa93968aa21c"}, - {file = "pydantic-1.10.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3a2be0a0f32c83265fd71a45027201e1278beaa82ea88ea5b345eea6afa9ac7f"}, - {file = "pydantic-1.10.6-cp38-cp38-win_amd64.whl", hash = "sha256:0abd9c60eee6201b853b6c4be104edfba4f8f6c5f3623f8e1dba90634d63eb35"}, - {file = "pydantic-1.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6195ca908045054dd2d57eb9c39a5fe86409968b8040de8c2240186da0769da7"}, - {file = "pydantic-1.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43cdeca8d30de9a897440e3fb8866f827c4c31f6c73838e3a01a14b03b067b1d"}, - {file = "pydantic-1.10.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c19eb5163167489cb1e0161ae9220dadd4fc609a42649e7e84a8fa8fff7a80f"}, - {file = "pydantic-1.10.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:012c99a9c0d18cfde7469aa1ebff922e24b0c706d03ead96940f5465f2c9cf62"}, - {file = "pydantic-1.10.6-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:528dcf7ec49fb5a84bf6fe346c1cc3c55b0e7603c2123881996ca3ad79db5bfc"}, - {file = "pydantic-1.10.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:163e79386c3547c49366e959d01e37fc30252285a70619ffc1b10ede4758250a"}, - {file = "pydantic-1.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:189318051c3d57821f7233ecc94708767dd67687a614a4e8f92b4a020d4ffd06"}, - {file = "pydantic-1.10.6-py3-none-any.whl", hash = "sha256:acc6783751ac9c9bc4680379edd6d286468a1dc8d7d9906cd6f1186ed682b2b0"}, - {file = "pydantic-1.10.6.tar.gz", hash = "sha256:cf95adb0d1671fc38d8c43dd921ad5814a735e7d9b4d9e437c088002863854fd"}, -] - -[package.dependencies] -typing-extensions = ">=4.2.0" - -[package.extras] -dotenv = ["python-dotenv (>=0.10.4)"] -email = ["email-validator (>=1.0.3)"] + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81"}, + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38"}, + {file = "pydantic_core-2.18.2-cp310-none-win32.whl", hash = "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027"}, + {file = "pydantic_core-2.18.2-cp310-none-win_amd64.whl", hash = "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664"}, + {file = "pydantic_core-2.18.2-cp311-none-win32.whl", hash = "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e"}, + {file = "pydantic_core-2.18.2-cp311-none-win_amd64.whl", hash = "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3"}, + {file = "pydantic_core-2.18.2-cp311-none-win_arm64.whl", hash = "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3"}, + {file = "pydantic_core-2.18.2-cp312-none-win32.whl", hash = "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038"}, + {file = "pydantic_core-2.18.2-cp312-none-win_amd64.whl", hash = "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438"}, + {file = "pydantic_core-2.18.2-cp312-none-win_arm64.whl", hash = "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788"}, + {file = "pydantic_core-2.18.2-cp38-none-win32.whl", hash = "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350"}, + {file = "pydantic_core-2.18.2-cp38-none-win_amd64.whl", hash = "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b"}, + {file = "pydantic_core-2.18.2-cp39-none-win32.whl", hash = "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e"}, + {file = "pydantic_core-2.18.2-cp39-none-win_amd64.whl", hash = "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374"}, + {file = "pydantic_core-2.18.2.tar.gz", hash = "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pydocstyle" @@ -2999,13 +3063,13 @@ files = [ [[package]] name = "starlette" -version = "0.26.1" +version = "0.27.0" description = "The little ASGI library that shines." optional = false python-versions = ">=3.7" files = [ - {file = "starlette-0.26.1-py3-none-any.whl", hash = "sha256:e87fce5d7cbdde34b76f0ac69013fd9d190d581d80681493016666e6f96c6d5e"}, - {file = "starlette-0.26.1.tar.gz", hash = "sha256:41da799057ea8620e4667a3e69a5b1923ebd32b1819c8fa75634bbe8d8bea9bd"}, + {file = "starlette-0.27.0-py3-none-any.whl", hash = "sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91"}, + {file = "starlette-0.27.0.tar.gz", hash = "sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75"}, ] [package.dependencies] @@ -3017,17 +3081,17 @@ full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyam [[package]] name = "statesman" -version = "1.0.4" +version = "1.0.5" description = "A modern state machine library." optional = false -python-versions = ">=3.8,<3.13" +python-versions = "<3.13,>=3.8" files = [ - {file = "statesman-1.0.4-py3-none-any.whl", hash = "sha256:4bec4c3cf55f2ac1e2c42947c14331ef3f83caef53b55da52372e82e8013b1a7"}, - {file = "statesman-1.0.4.tar.gz", hash = "sha256:c28dcba6518dafe5cccc703081c187aae436a0e6c4a3924728e986bc23c2d19e"}, + {file = "statesman-1.0.5-py3-none-any.whl", hash = "sha256:6d2cd8dfc71c17f405eea94f27eb3ee56736e252fcea69fdd057eda80f970612"}, + {file = "statesman-1.0.5.tar.gz", hash = "sha256:d3947a2a7281e23bc229008ac12dbc6f1116633f04babc75c5f573d94502e5eb"}, ] [package.dependencies] -pydantic = ">=1.7.1,<2.0.0" +pydantic = ">=2.4,<3.0" [[package]] name = "stevedore" @@ -3557,4 +3621,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.13" -content-hash = "ec688b5c272027364d5ff443d508da462194daa07bd78a7cae4f0c87c81667be" +content-hash = "defd0c42ea06fd564359cf9113dea4e7ec515cc01eb3b7076745a9093c6572b7" diff --git a/pyproject.toml b/pyproject.toml index 7e9f8281..2170fa77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ include = ["README.md", "CHANGELOG.md"] [tool.poetry.dependencies] python = ">=3.9,<3.13" -pydantic = "^1.9.0" +pydantic = "^2.4" loguru = "^0.6.0" httpx = "^0.23.0" python-dotenv = "^1.0.0" @@ -74,7 +74,7 @@ flake8-annotations-complexity = "^0.0.7" flake8-annotations = "^3.0.0" flake8-markdown = "^0.4.0" flake8-bandit = "^4.1.1" -fastapi = "^0.95.0" +fastapi = "^0.100" uvicorn = "^0.21.1" pytest-profiling = "^1.7.0" pytest-sugar = "^0.9.6" @@ -85,7 +85,7 @@ pytest-xdist = "^3.2.0" pytest-vscodedebug = "^0.1.0" pytest-html = "^4.0.0rc0" bandit = "^1.7.0" -statesman = "^1.0.4" +statesman = "^1.0.5" types-PyYAML = "^6.0.4" types-setuptools = "^63.4.1" types-python-dateutil = "^2.8.2" @@ -94,7 +94,7 @@ types-pytz = "^2022.7.1" types-toml = "^0.10.2" types-aiofiles = "^23.1.0" types-tabulate = "^0.9.0" -black = "^23.1.0" +black = "^24.4" coverage = "^7.2.2" pytest-timeout = "^2.1.0" @@ -114,6 +114,9 @@ wait = "servo.connectors.wait:WaitConnector" requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" +[tool.black] +line_length = 120 + [tool.isort] profile = "black" line_length = 120 From 5584d341afb78aa303e6d2671965118a2ff4447e Mon Sep 17 00:00:00 2001 From: Linkous Sharp Date: Wed, 8 May 2024 09:53:40 -0500 Subject: [PATCH 2/8] Save black config change for later PR --- pyproject.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2170fa77..68ed0d26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,9 +114,6 @@ wait = "servo.connectors.wait:WaitConnector" requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" -[tool.black] -line_length = 120 - [tool.isort] profile = "black" line_length = 120 From 0a696d481649cb0f7f0fb4b69c0c389eccf3aa88 Mon Sep 17 00:00:00 2001 From: Linkous Sharp Date: Mon, 13 May 2024 16:07:51 -0500 Subject: [PATCH 3/8] Refactor code base for pydantic v2, fix import and some test errors --- poetry.lock | 45 +++- pyproject.toml | 3 +- servo/__init__.py | 4 +- servo/api.py | 63 ++--- servo/assembly.py | 14 +- servo/checks.py | 65 +++--- servo/configuration.py | 217 ++++++++---------- servo/connector.py | 4 +- servo/connectors/kube_metrics.py | 10 +- servo/connectors/kubernetes.py | 161 ++++++------- servo/connectors/kubernetes_helpers/base.py | 6 +- .../kubernetes_helpers/base_workload.py | 6 +- servo/connectors/opsani_dev.py | 17 +- servo/connectors/prometheus.py | 65 +++--- servo/connectors/vegeta.py | 42 ++-- servo/events.py | 55 ++--- servo/fast_fail.py | 14 +- servo/pubsub.py | 46 ++-- servo/runner.py | 22 +- servo/servo.py | 37 ++- servo/telemetry.py | 4 +- servo/types/api.py | 23 +- servo/types/core.py | 65 ++++-- servo/types/settings.py | 73 +++--- servo/types/slo.py | 38 +-- servo/utilities/associations.py | 9 +- servo/utilities/pydantic.py | 27 ++- tests/api_test.py | 4 +- tests/checks_test.py | 99 +++----- tests/connectors/kubernetes_test.py | 18 +- tests/connectors/opsani_dev_test.py | 12 +- tests/integration/pubsub_test.py | 6 +- tests/kubernetes_test.py | 6 +- tests/pubsub_test.py | 6 +- tests/servo_test.py | 14 +- tests/types/core_test.py | 27 +-- tests/types/settings_test.py | 2 +- tests/utilities/inspect_test.py | 63 ++--- 38 files changed, 677 insertions(+), 715 deletions(-) diff --git a/poetry.lock b/poetry.lock index 8ac0b31a..fb9a12c1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -134,23 +134,24 @@ files = [ [[package]] name = "anyio" -version = "3.6.2" +version = "3.7.1" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false -python-versions = ">=3.6.2" +python-versions = ">=3.7" files = [ - {file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"}, - {file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"}, + {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, + {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, ] [package.dependencies] +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" [package.extras] -doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"] -trio = ["trio (>=0.16,<0.22)"] +doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] +test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (<0.22)"] [[package]] name = "asttokens" @@ -872,17 +873,18 @@ tests = ["asttokens", "littleutils", "pytest", "rich"] [[package]] name = "fastapi" -version = "0.100.1" +version = "0.103.2" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.7" files = [ - {file = "fastapi-0.100.1-py3-none-any.whl", hash = "sha256:ec6dd52bfc4eff3063cfcd0713b43c87640fefb2687bbbe3d8a08d94049cdf32"}, - {file = "fastapi-0.100.1.tar.gz", hash = "sha256:522700d7a469e4a973d92321ab93312448fbe20fca9c8da97effc7e7bc56df23"}, + {file = "fastapi-0.103.2-py3-none-any.whl", hash = "sha256:3270de872f0fe9ec809d4bd3d4d890c6d5cc7b9611d721d6438f9dacc8c4ef2e"}, + {file = "fastapi-0.103.2.tar.gz", hash = "sha256:75a11f6bfb8fc4d2bec0bd710c2d5f2829659c0e8c0afd5560fdda6ce25ec653"}, ] [package.dependencies] -pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<3.0.0" +anyio = ">=3.7.1,<4.0.0" +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" starlette = ">=0.27.0,<0.28.0" typing-extensions = ">=4.5.0" @@ -2298,6 +2300,25 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydantic-settings" +version = "2.2.1" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_settings-2.2.1-py3-none-any.whl", hash = "sha256:0235391d26db4d2190cb9b31051c4b46882d28a51533f97440867f012d4da091"}, + {file = "pydantic_settings-2.2.1.tar.gz", hash = "sha256:00b9f6a5e95553590434c0fa01ead0b216c3e10bc54ae02e37f359948643c5ed"}, +] + +[package.dependencies] +pydantic = ">=2.3.0" +python-dotenv = ">=0.21.0" + +[package.extras] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + [[package]] name = "pydocstyle" version = "6.3.0" @@ -3621,4 +3642,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.13" -content-hash = "defd0c42ea06fd564359cf9113dea4e7ec515cc01eb3b7076745a9093c6572b7" +content-hash = "447d6a0cb182b03fe36b850b715e94b5344b4c392e74ddebfcbf0339ec398884" diff --git a/pyproject.toml b/pyproject.toml index 68ed0d26..324bacda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ python-dateutil = "^2.8.2" Authlib = "^1.1.0" watchfiles = "^0.18.1" python-ulid = "^2.4.0.post0" +pydantic-settings = "^2.2.1" [tool.poetry.dev-dependencies] pytest = "^7.4.1" @@ -74,7 +75,7 @@ flake8-annotations-complexity = "^0.0.7" flake8-annotations = "^3.0.0" flake8-markdown = "^0.4.0" flake8-bandit = "^4.1.1" -fastapi = "^0.100" +fastapi = "^0.103" uvicorn = "^0.21.1" pytest-profiling = "^1.7.0" pytest-sugar = "^0.9.6" diff --git a/servo/__init__.py b/servo/__init__.py index e8c03b2d..6cb56992 100644 --- a/servo/__init__.py +++ b/servo/__init__.py @@ -61,5 +61,5 @@ def __get_version() -> Optional[str]: from .utilities import * # Resolve forward references -servo.events.EventResult.update_forward_refs() -servo.events.EventHandler.update_forward_refs() +servo.events.EventResult.model_rebuild() +servo.events.EventHandler.model_rebuild() diff --git a/servo/api.py b/servo/api.py index 057952b9..e8785a98 100644 --- a/servo/api.py +++ b/servo/api.py @@ -17,7 +17,7 @@ import copy import enum import time -from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING +from typing import Annotated, Any, Dict, List, Optional, Union, TYPE_CHECKING from authlib.integrations.httpx_client import AsyncOAuth2Client import curlify2 @@ -103,26 +103,27 @@ def response_event(self) -> Events: class Request(pydantic.BaseModel): event: Union[Events, str] # TODO: Needs to be rethought -- used adhoc in some cases - param: Optional[Dict[str, Any]] # TODO: Switch to a union of supported types + param: Optional[Dict[str, Any]] = None # TODO: Switch to a union of supported types servo_uid: Union[str, None] = None - class Config: - json_encoders = { - Events: lambda v: str(v), - } + @pydantic.field_serializer("event") + def event_str(self, event: Events | str) -> str: + if isinstance(event, Events): + return str(event) + return event class Status(pydantic.BaseModel): status: Statuses message: Optional[str] = None - other_messages: Optional[ - list[str] - ] = None # other lower priority error in exception group + other_messages: Optional[list[str]] = ( + None # other lower priority error in exception group + ) reason: Optional[str] = None state: Optional[Dict[str, Any]] = None descriptor: Optional[Dict[str, Any]] = None - metrics: Union[dict[str, Any], None] = None - annotations: Union[dict[str, str], None] = None + metrics: Union[Dict[str, Any], None] = None + annotations: Union[Dict[str, str], None] = None command_uid: Union[str, None] = pydantic.Field(default=None, alias="cmd_uid") @classmethod @@ -171,8 +172,7 @@ def dict( ) -> DictStrAny: return super().dict(exclude_unset=exclude_unset, by_alias=by_alias, **kwargs) - class Config: - allow_population_by_field_name = True + model_config = pydantic.ConfigDict(populate_by_name=True) class SleepResponse(pydantic.BaseModel): @@ -182,12 +182,25 @@ class SleepResponse(pydantic.BaseModel): # SleepResponse '{"cmd": "SLEEP", "param": {"duration": 60, "data": {"reason": "no active optimization pipeline"}}}' +def metric_name(v: servo.Metric | str) -> str: + if isinstance(v, servo.Metric): + return v.name + + return v + + # Instructions from servo on what to measure class MeasureParams(pydantic.BaseModel): - metrics: List[str] + metrics: List[ + Annotated[ + str, + pydantic.Field(validate_default=True), + pydantic.BeforeValidator(metric_name), + ] + ] control: servo.types.Control - @pydantic.validator("metrics", always=True, pre=True) + @pydantic.field_validator("metrics", mode="before") @classmethod def coerce_metrics(cls, value) -> List[str]: if isinstance(value, dict): @@ -195,25 +208,17 @@ def coerce_metrics(cls, value) -> List[str]: return value - @pydantic.validator("metrics", each_item=True, pre=True) - def _map_metrics(cls, v) -> str: - if isinstance(v, servo.Metric): - return v.name - - return v - class CommandResponse(pydantic.BaseModel): command: Commands = pydantic.Field(alias="cmd") command_uid: Union[str, None] = pydantic.Field(alias="cmd_uid") - param: Optional[ - Union[MeasureParams, Dict[str, Any]] - ] # TODO: Switch to a union of supported types, remove isinstance check from ServoRunner.measure when done + param: Optional[Union[MeasureParams, Dict[str, Any]]] = ( + None # TODO: Switch to a union of supported types, remove isinstance check from ServoRunner.measure when done + ) - class Config: - json_encoders = { - Commands: lambda v: v.value, - } + @pydantic.field_serializer("command") + def cmd_str(self, cmd: Commands) -> str: + return cmd.value def descriptor_to_adjustments(descriptor: dict) -> List[servo.types.Adjustment]: diff --git a/servo/assembly.py b/servo/assembly.py index d0c4f749..4a41f4c9 100644 --- a/servo/assembly.py +++ b/servo/assembly.py @@ -69,7 +69,7 @@ class Assembly(pydantic.BaseModel): of the schema family of methods. See the method docstrings for specific details. """ - config_file: Optional[pathlib.Path] + config_file: Optional[pathlib.Path] = None servos: List[servo.servo.Servo] _context_token: Optional[contextvars.Token] = pydantic.PrivateAttr(None) @@ -278,9 +278,9 @@ def _create_config_model_from_routes( require_fields: bool = True, ) -> Type[servo.configuration.BaseServoConfiguration]: # Create Pydantic fields for each active route - connector_versions: Dict[ - Type[servo.connector.BaseConnector], str - ] = {} # use dict for uniquing and ordering + connector_versions: Dict[Type[servo.connector.BaseConnector], str] = ( + {} + ) # use dict for uniquing and ordering setting_fields: Dict[ str, Tuple[Type[servo.configuration.BaseConfiguration], Any] ] = {} @@ -294,9 +294,9 @@ def _create_config_model_from_routes( f"{connector_class.full_name} Settings (named {name})" ) setting_fields[name] = (config_model, default_value) - connector_versions[ - connector_class - ] = f"{connector_class.full_name} v{connector_class.version}" + connector_versions[connector_class] = ( + f"{connector_class.full_name} v{connector_class.version}" + ) # Create our model servo_config_model = pydantic.create_model( diff --git a/servo/checks.py b/servo/checks.py index 1dd2583e..74c128e4 100644 --- a/servo/checks.py +++ b/servo/checks.py @@ -22,6 +22,7 @@ import textwrap import types from typing import ( + Annotated, Any, Awaitable, Callable, @@ -54,6 +55,7 @@ import servo.types import servo.utilities from servo.types import Duration, ErrorSeverity +from pydantic import ConfigDict __all__ = [ "BaseChecks", @@ -78,12 +80,19 @@ CHECK_HANDLER_SIGNATURE = inspect.Signature(return_annotation=CheckHandlerResult) -# https://stackoverflow.com/a/67408276 -class Tag(pydantic.ConstrainedStr): - strip_whitespace = True - min_length = 1 - max_length = 32 - regex = re.compile("^([0-9a-z\\.-])*$") +Tag = Annotated[ + str, + pydantic.StringConstraints( + strip_whitespace=True, min_length=1, max_length=32, pattern=r"^([0-9a-z\\.-])*$" + ), +] + +# # https://stackoverflow.com/a/67408276 +# class Tag(pydantic.ConstrainedStr): +# strip_whitespace = True +# min_length = 1 +# max_length = 32 +# regex = re.compile("^([0-9a-z\\.-])*$") class CheckError(RuntimeError): @@ -116,11 +125,11 @@ class Check(pydantic.BaseModel, servo.logging.Mixin): """An arbitrary descriptive name of the condition being checked. """ - id: pydantic.StrictStr = None + id: pydantic.StrictStr = pydantic.Field(None, validate_default=True) """A short identifier for the check. Generated automatically if unset. """ - description: Optional[pydantic.StrictStr] + description: Optional[pydantic.StrictStr] = None """An optional detailed description about the condition being checked. """ @@ -128,7 +137,7 @@ class Check(pydantic.BaseModel, servo.logging.Mixin): """The relative importance of the check determining failure handling. """ - tags: Optional[set[Tag]] + tags: Optional[set[Tag]] = None """ An optional set of tags for filtering checks. @@ -136,12 +145,12 @@ class Check(pydantic.BaseModel, servo.logging.Mixin): only lowercase alphanumeric characters, hyphens '-', and periods '.'. """ - success: Optional[bool] + success: Optional[bool] = None """ Indicates if the condition being checked was met or not. """ - message: Optional[pydantic.StrictStr] + message: Optional[pydantic.StrictStr] = None """ An optional message describing the outcome of the check. @@ -152,7 +161,7 @@ class Check(pydantic.BaseModel, servo.logging.Mixin): hint: Optional[pydantic.StrictStr] = None remedy: Optional[Union[Callable[[], None], Awaitable[None]]] = None - exception: Optional[Exception] + exception: Optional[Exception] = None """ An optional exception encountered while running the check. @@ -161,15 +170,19 @@ class Check(pydantic.BaseModel, servo.logging.Mixin): can be presented to the user. """ - created_at: datetime.datetime = None + @pydantic.field_serializer("exception") + def exception_repr(self, exc: Exception) -> str: + return repr(exc) + + created_at: datetime.datetime = pydantic.Field(None, validate_default=True) """When the check was created (set automatically). """ - run_at: Optional[datetime.datetime] + run_at: Optional[datetime.datetime] = None """An optional timestamp indicating when the check was run. """ - runtime: Optional[Duration] + runtime: Optional[Duration] = None """An optional duration indicating how long it took for the check to run. """ @@ -239,12 +252,12 @@ def warning(self) -> bool: """Return a boolean value that indicates if the check is of warning severity.""" return self.severity == ErrorSeverity.warning - @pydantic.validator("created_at", pre=True, always=True) + @pydantic.field_validator("created_at", mode="before") @classmethod def _set_created_at_now(cls, v): return v or datetime.datetime.now() - @pydantic.validator("id", pre=True, always=True) + @pydantic.field_validator("id", mode="before") @classmethod def _generated_id(cls, v, values): return ( @@ -257,12 +270,7 @@ def _generated_id(cls, v, values): def __hash__(self): return hash((self.id,)) - class Config: - validate_assignment = True - arbitrary_types_allowed = True - json_encoders = { - Exception: lambda v: repr(v), - } + model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True) CheckRunner = TypeVar("CheckRunner", Callable[..., Check], Coroutine[None, None, Check]) @@ -471,8 +479,7 @@ def _matches_str_attr( f'unexpected value of type "{attr.__class__.__name__}": {attr}' ) - class Config: - arbitrary_types_allowed = True + model_config = ConfigDict(arbitrary_types_allowed=True) class BaseChecks(pydantic.BaseModel, servo.logging.Mixin): @@ -739,9 +746,7 @@ async def _expand_multichecks(self) -> List[types.MethodType]: return checks - class Config: - arbitrary_types_allowed = True - extra = pydantic.Extra.allow + model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow") class CheckHelpers(pydantic.BaseModel, servo.logging.Mixin): @@ -803,7 +808,9 @@ async def coro() -> None: servo.logger.info("💡 Attempting to apply remedy...") except asyncio.TimeoutError: - servo.logger.warning("💡 Remedy attempt timed out after 10s") + servo.logger.warning( + "💡 Remedy attempt timed out after 10s" + ) if checks_config.check_halting: break diff --git a/servo/configuration.py b/servo/configuration.py index ca4cb8df..0b71d5d2 100644 --- a/servo/configuration.py +++ b/servo/configuration.py @@ -23,15 +23,19 @@ import pathlib import re from typing import Any, Callable, Dict, List, Optional, Type, Union -from typing_extensions import TypeAlias +import pydantic.json +import pydantic_core +from typing_extensions import Annotated, TypeAlias import backoff import pydantic +import pydantic_settings import yaml import servo.logging import servo.types from servo import types +from pydantic import Field, StringConstraints, ConfigDict __all__ = [ "AbstractBaseConfiguration", @@ -44,7 +48,7 @@ ] -ORGANIZATION_REGEX = r"(?!-)([A-Za-z0-9-.]{5,50})" +ORGANIZATION_REGEX = r"([A-Za-z0-9-.]{5,50})" # Organization regex constraint to enforce that: # * Cannot contain a forward slash (/) # * Cannot solely consist of a single period (.) or double periods (..) @@ -63,7 +67,7 @@ class SidecarConnectionFile(pydantic.BaseModel): TenantId: str -class AppdynamicsOptimizer(pydantic.BaseSettings): +class AppdynamicsOptimizer(pydantic_settings.BaseSettings): optimizer_id: str tenant_id: Optional[str] = None base_url: Optional[pydantic.AnyHttpUrl] = None @@ -117,10 +121,10 @@ def load_connection_file(self) -> None: self.base_url = validated_content.Endpoint.rstrip("/") self.tenant_id = validated_content.TenantId - @pydantic.validator("base_url") - def _rstrip_slash(cls, url: str) -> str: + @pydantic.field_validator("base_url") + def _rstrip_slash(cls, url: pydantic_core.Url) -> str: if url: - return url.rstrip("/") + return str(url).rstrip("/") return url @property @@ -131,24 +135,17 @@ def id(self) -> str: def name(self) -> str: return f"{self.optimizer_id}" - class Config: - case_sensitive = True - extra = pydantic.Extra.forbid - validate_assignment = True - fields = { - "optimizer_id": {"env": "APPD_OPTIMIZER_ID"}, - "tenant_id": {"env": "APPD_TENANT_ID"}, - "client_id": {"env": "APPD_CLIENT_ID"}, - "client_secret": {"env": "APPD_CLIENT_SECRET"}, - "base_url": {"env": "APPD_BASE_URL"}, - "url": {"env": "APPD_URL"}, - "token_url": {"env": "APPD_TOKEN_URL"}, - "connection_file": {"env": "APPD_CONNECTION_FILE"}, - "token": {"env": "APPD_TOKEN"}, - } + # TODO[pydantic]: The following keys were removed: `fields`. + # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-config for more information. + model_config = pydantic_settings.SettingsConfigDict( + case_sensitive=True, + extra="forbid", + validate_assignment=True, + env_prefix="appd_", + ) -class OpsaniOptimizer(pydantic.BaseSettings): +class OpsaniOptimizer(pydantic_settings.BaseSettings): """ An Optimizer models an Opsani optimization engines that the Servo can connect to in order to access the Opsani machine learning technology for optimizing system infrastructure @@ -164,19 +161,20 @@ class OpsaniOptimizer(pydantic.BaseSettings): and automated testing to bind the servo to a fixed URL. """ - id: pydantic.constr(regex=OPTIMIZER_ID_REGEX) + id: Annotated[str, StringConstraints(pattern=OPTIMIZER_ID_REGEX)] token: pydantic.SecretStr base_url: pydantic.AnyHttpUrl = "https://api.opsani.com" url: Optional[pydantic.AnyHttpUrl] = None _organization: str _name: str - def __init__(self, **kwargs) -> None: + def __init__(self, *args, **kwargs) -> None: + # kwargs if not kwargs.get("token") and ( token_file := os.environ.get("OPSANI_TOKEN_FILE") ): kwargs["token"] = pathlib.Path(token_file).read_text().strip() - super().__init__(**kwargs) + super().__init__(*args, **kwargs) organization, name = self.id.split("/") self._organization = organization @@ -184,9 +182,13 @@ def __init__(self, **kwargs) -> None: if not self.url: self.url = self.default_url - @pydantic.validator("base_url") - def _rstrip_slash(cls, url: str) -> str: - return url.rstrip("/") + @pydantic.field_validator("base_url") + def _rstrip_slash(cls, url: pydantic_core.Url) -> str: + return str(url).rstrip("/") + + @pydantic.field_serializer("token") + def token_value(self, tok: pydantic.SecretStr, _) -> str: + return tok.get_secret_value() @property def organization(self) -> str: @@ -213,20 +215,9 @@ def name(self) -> str: def default_url(self) -> str: return f"{self.base_url}/accounts/{self.organization}/applications/{self.name}/" - class Config: - case_sensitive = True - extra = pydantic.Extra.forbid - underscore_attrs_are_private = True - validate_assignment = True - fields = { - "id": {"env": "OPSANI_OPTIMIZER"}, - "token": {"env": "OPSANI_TOKEN"}, - "base_url": {"env": "OPSANI_BASE_URL"}, - "url": {"env": "OPSANI_URL"}, - } - json_encoders = { - pydantic.SecretStr: lambda v: v.get_secret_value() if v else None, - } + model_config = pydantic_settings.SettingsConfigDict( + env_prefix="OPSANI_", extra="forbid", validate_assignment=True + ) OptimizerTypes: TypeAlias = Union[AppdynamicsOptimizer, OpsaniOptimizer] @@ -235,7 +226,7 @@ class Config: DEFAULT_TITLE = "Base Connector Configuration Schema" -class AbstractBaseConfiguration(pydantic.BaseSettings, servo.logging.Mixin): +class AbstractBaseConfiguration(pydantic_settings.BaseSettings, servo.logging.Mixin): """ AbstractBaseConfiguration is the root of the servo configuration class hierarchy. It does not define any concrete configuration model fields but provides a number @@ -291,21 +282,22 @@ def __init_subclass__(cls, **kwargs): # Schema title base_name = cls.__name__.replace("Configuration", "") - if cls.__config__.title == DEFAULT_TITLE: - cls.__config__.title = f"{base_name} Connector Configuration Schema" + if cls.model_config.get("title", None) == DEFAULT_TITLE: + cls.model_config["title"] = f"{base_name} Connector Configuration Schema" # Default prefix - prefix = cls.__config__.env_prefix + prefix = cls.model_config.get("env_prefix", "") if prefix == "": prefix = re.sub(r"(? Dict[Type[Any], Callable[..., Any]]: - """ - Returns a dict mapping servo types to callable JSON encoders for use in Pydantic Config classes - when `json_encoders` need to be customized. Encoders provided in the encoders argument - are merged into the returned dict and take precedence over the defaults. - """ - from servo.types import DEFAULT_JSON_ENCODERS - - return {**DEFAULT_JSON_ENCODERS, **encoders} - - class Config(servo.types.BaseModelConfig): - case_sensitive = True - extra = pydantic.Extra.forbid - title = DEFAULT_TITLE + model_config = pydantic_settings.SettingsConfigDict( + **servo.types.BASE_MODEL_CONFIG, + case_sensitive=True, + extra="forbid", + title=DEFAULT_TITLE, + ) class BaseConfiguration(AbstractBaseConfiguration): @@ -378,13 +359,14 @@ class BaseConfiguration(AbstractBaseConfiguration): ) +# Handled by pydantic by default TODO remove when verified # Uppercase handling for non-subclassed settings models. Should be pushed into Pydantic as a PR -env_names = BaseConfiguration.__fields__["description"].field_info.extra.get( - "env_names", set() -) -BaseConfiguration.__fields__["description"].field_info.extra["env_names"] = set( - map(str.upper, env_names) -) +# env_names = BaseConfiguration.__fields__["description"].field_info.extra.get( +# "env_names", set() +# ) +# BaseConfiguration.__fields__["description"].field_info.extra["env_names"] = set( +# map(str.upper, env_names) +# ) class BackoffSettings(AbstractBaseConfiguration): @@ -444,7 +426,7 @@ def __init__( super().__init__(**kwargs) -ProxyKey = pydantic.constr(regex=r"^(https?|all)://") +ProxyKey = Annotated[str, StringConstraints(pattern=r"^(https?|all)://")] class BackoffContexts(enum.StrEnum): @@ -454,30 +436,19 @@ class BackoffContexts(enum.StrEnum): connect = "connect" -class BackoffConfigurations(pydantic.BaseModel): +class BackoffConfigurations(pydantic.RootModel): """A mapping of named backoff configurations.""" - __root__: Dict[str, BackoffSettings] - - @pydantic.root_validator(pre=True) - def _nest_unrooted_values(cls, values: Any) -> Any: - # NOTE: To parse via parse_obj, we need our values rooted under __root__ - if isinstance(values, dict): - if len(values) != 1 or ( - len(values) == 1 and values.get("__root__", None) is None - ): - return {"__root__": values} - - return values + root: Dict[str, BackoffSettings] def __iter__(self): - return iter(self.__root__) + return iter(self.root) def __getitem__(self, context: str) -> BackoffSettings: - return self.__root__[context] + return self.root[context] def get(self, context: str, default: Any = None) -> BackoffSettings: - return self.__root__.get(context, default) + return self.root.get(context, default) def max_time( self, context: str = BackoffContexts.default @@ -499,12 +470,10 @@ class CommonConfiguration(AbstractBaseConfiguration): """ backoff: BackoffConfigurations = pydantic.Field( - default_factory=lambda: BackoffConfigurations( - __root__={ - BackoffContexts.default: {"max_time": "10m", "max_tries": None}, - BackoffContexts.connect: {"max_time": "1h", "max_tries": None}, - } - ) + { + BackoffContexts.default: {"max_time": "10m", "max_tries": None}, + BackoffContexts.connect: {"max_time": "1h", "max_tries": None}, + } ) """A mapping of named operations to settings for the backoff library, which provides backoff and retry capabilities to the servo. @@ -537,7 +506,7 @@ class CommonConfiguration(AbstractBaseConfiguration): See https://www.python-httpx.org/advanced/#ssl-certificates """ - @pydantic.validator("timeouts", pre=True) + @pydantic.field_validator("timeouts", mode="before") def parse_timeouts(cls, v): if isinstance(v, (str, int, float)): return Timeouts(v) @@ -547,8 +516,9 @@ def parse_timeouts(cls, v): def generate(cls, **kwargs) -> Optional["CommonConfiguration"]: return None - class Config(servo.types.BaseModelConfig): - validate_assignment = True + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.model_config["validate_assignment"] = True class ChecksConfiguration(AbstractBaseConfiguration): @@ -557,17 +527,21 @@ class ChecksConfiguration(AbstractBaseConfiguration): """ connectors: Optional[list[str]] = pydantic.Field( + None, description="Connectors to check", ) name: Optional[list[str]] = pydantic.Field( + None, description="Filter by name", ) id: Optional[list[str]] = pydantic.Field( + None, description="Filter by ID", ) tag: Optional[list[str]] = pydantic.Field( + None, description="Filter by tag", ) @@ -605,8 +579,9 @@ class ChecksConfiguration(AbstractBaseConfiguration): def generate(cls, **kwargs) -> Optional["ChecksConfiguration"]: return None - class Config(servo.types.BaseModelConfig): - validate_assignment = True + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.model_config["validate_assignment"] = True class BaseServoConfiguration(AbstractBaseConfiguration, abc.ABC): @@ -623,9 +598,9 @@ class BaseServoConfiguration(AbstractBaseConfiguration, abc.ABC): name: Optional[str] = None description: Optional[str] = None - servo_uid: Union[str, None] = pydantic.Field(default=None, env="SERVO_UID") + servo_uid: Union[str, None] = pydantic.Field(default=None, alias="SERVO_UID") optimizer: OptimizerTypes = {} - connectors: Optional[Union[List[str], Dict[str, str]]] = pydantic.Field( + connectors: list[str] | dict[str, str] | None = pydantic.Field( None, description=( "An optional, explicit configuration of the active connectors.\n" @@ -696,7 +671,7 @@ def generate( return cls(**kwargs) - @pydantic.validator("connectors", pre=True) + @pydantic.field_validator("connectors", mode="before") @classmethod def validate_connectors( cls, connectors @@ -704,7 +679,7 @@ def validate_connectors( if isinstance(connectors, str): # NOTE: Special case. When we are invoked with a string it is typically an env var try: - decoded_value = BaseServoConfiguration.__config__.json_loads(connectors) # type: ignore + decoded_value = json.loads(connectors) except ValueError as e: raise ValueError(f'error parsing JSON for "{connectors}"') from e @@ -728,23 +703,24 @@ def validate_connectors( return connectors - class Config(types.BaseModelConfig): - extra = pydantic.Extra.forbid - title = "Abstract Servo Configuration Schema" - env_prefix = "SERVO_" + def __init__(self, *args, **kwargs): + self.model_config["extra"] = "forbid" + self.model_config["title"] = "Abstract Servo Configuration Schema" + self.model_config["env_prefix"] = "SERVO_" + super().__init__(*args, **kwargs) -class FastFailConfiguration(pydantic.BaseSettings): +class FastFailConfiguration(pydantic_settings.BaseSettings): """Configuration providing support for fast fail behavior which returns early from long running connector operations when SLO violations are observed""" - disabled: pydantic.conint(ge=0, le=1, multiple_of=1) = 0 + disabled: Annotated[int, Field(ge=0, le=1, multiple_of=1)] = 0 """Toggle fast-fail behavior on or off""" period: servo.types.Duration = "60s" """How often to check the SLO metrics""" - span: servo.types.Duration = None + span: servo.types.Duration = pydantic.Field(None, validate_default=True) """The span or window of time that SLO metrics are gathered for""" skip: servo.types.Duration = 0 @@ -752,11 +728,14 @@ class FastFailConfiguration(pydantic.BaseSettings): treat_zero_as_missing: bool = False """Whether or not to treat zero values as missing per certain metric systems""" + model_config = ConfigDict( + case_sensitive=True, + extra="forbid", + validate_assignment=True, + env_prefix="SERVO_", + ) - class Config: - extra = pydantic.Extra.forbid - - @pydantic.validator("span", pre=True, always=True) + @pydantic.field_validator("span", mode="before") def span_defaults_to_period(cls, v, *, values, **kwargs): if v is None: return values["period"] diff --git a/servo/connector.py b/servo/connector.py index 4e7a223d..65b0f13e 100644 --- a/servo/connector.py +++ b/servo/connector.py @@ -157,7 +157,7 @@ def optimizer( ## # Validators - @pydantic.root_validator(pre=True) + @pydantic.model_validator(mode="before") @classmethod def _validate_metadata(cls, v): assert cls.name is not None, "name must be provided" @@ -178,7 +178,7 @@ def _validate_metadata(cls, v): ) return v - @pydantic.validator("name") + @pydantic.field_validator("name") @classmethod def _validate_name(cls, v): assert bool( diff --git a/servo/connectors/kube_metrics.py b/servo/connectors/kube_metrics.py index 09aa17f0..ccac2a0c 100644 --- a/servo/connectors/kube_metrics.py +++ b/servo/connectors/kube_metrics.py @@ -99,14 +99,14 @@ class SupportedKubeMetrics(str, Enum): class KubeMetricsConfiguration(servo.BaseConfiguration): - namespace: Annotated[ - str, DNSSubdomainName(description="Namespace of the target resource") - ] + namespace: DNSSubdomainName = pydantic.Field( + ..., description="Namespace of the target resource" + ) name: str = pydantic.Field(description="Name of the target resource") kind: str = pydantic.Field( default="Deployment", description="Kind of the target resource", - regex=r"^([Dd]eployment|[Ss]tateful[Ss]et)$", + pattern=r"^([Dd]eployment|[Ss]tateful[Ss]et)$", ) container: Optional[str] = pydantic.Field( default=None, description="Name of the target resource container" @@ -127,7 +127,7 @@ class KubeMetricsConfiguration(servo.BaseConfiguration): description="Name of the kubeconfig context to use." ) - @pydantic.validator("metrics_to_collect") + @pydantic.field_validator("metrics_to_collect") def config_metrics_must_be_supported(cls, value: List[str]) -> List[str]: supported_metrics_set = {m.value for m in SupportedKubeMetrics} unsupported_metrics = [m for m in value if m not in supported_metrics_set] diff --git a/servo/connectors/kubernetes.py b/servo/connectors/kubernetes.py index 3e186f59..e0624ae4 100644 --- a/servo/connectors/kubernetes.py +++ b/servo/connectors/kubernetes.py @@ -35,6 +35,7 @@ Dict, Iterable, List, + Literal, Optional, Tuple, Type, @@ -56,6 +57,7 @@ V1PodTemplateSpec, V1StatefulSet, ) +import pydantic_core import servo from servo.telemetry import ONE_MiB @@ -70,6 +72,7 @@ StatefulSetHelper, find_container, ) +from pydantic import ConfigDict class Core(decimal.Decimal): @@ -85,8 +88,17 @@ class Core(decimal.Decimal): """ @classmethod - def __get_validators__(cls) -> pydantic.types.CallableGenerator: - yield cls.parse + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler + ) -> pydantic_core.CoreSchema: + return pydantic_core.core_schema.no_info_after_validator_function( + cls.parse, handler(decimal.Decimal) + ) + + # # TODO[pydantic]: We couldn't refactor `__get_validators__`, please create the `__get_pydantic_core_schema__` manually. + # # Check https://docs.pydantic.dev/latest/migration/#defining-custom-types for more information. + # def __get_validators__(cls) -> pydantic.types.CallableGenerator: + # yield cls.parse @classmethod def parse(cls, v: pydantic.types.StrIntFloat) -> "Core": @@ -189,14 +201,14 @@ class CPU(servo.CPU): ResourceRequirement.request, ResourceRequirement.limit, ], - min_items=1, + min_length=1, ) set: list[ResourceRequirement] = pydantic.Field( default=[ ResourceRequirement.request, ResourceRequirement.limit, ], - min_items=1, + min_length=1, ) def __opsani_repr__(self) -> dict: @@ -279,14 +291,14 @@ class Memory(servo.Memory): ResourceRequirement.request, ResourceRequirement.limit, ], - min_items=1, + min_length=1, ) set: list[ResourceRequirement] = pydantic.Field( default=[ ResourceRequirement.request, ResourceRequirement.limit, ], - min_items=1, + min_length=1, ) def __opsani_repr__(self) -> dict: @@ -461,8 +473,7 @@ def __hash__(self): ) ) - class Config: - arbitrary_types_allowed = True + model_config = ConfigDict(arbitrary_types_allowed=True) class SaturationOptimization(BaseOptimization): @@ -471,11 +482,13 @@ class SaturationOptimization(BaseOptimization): workload and its associated containers. """ - workload_helper: Optional[Union[Type[DeploymentHelper], Type[StatefulSetHelper]]] + workload_helper: Optional[ + Union[Type[DeploymentHelper], Type[StatefulSetHelper]] + ] = None workload_config: Optional[ Union["DeploymentConfiguration", "StatefulSetConfiguration"] - ] - workload: Optional[Union[V1Deployment, V1StatefulSet]] + ] = None + workload: Optional[Union[V1Deployment, V1StatefulSet]] = None container_config: "ContainerConfiguration" container: V1Container @@ -675,9 +688,9 @@ def adjust( self.adjustments.append(adjustment) setting_name, value = _normalize_adjustment(adjustment) self.logger.info(f"adjusting {setting_name} to {value}") - env_setting: Optional[ - servo.EnvironmentSetting - ] = None # Declare type since type not compatible with := + env_setting: Optional[servo.EnvironmentSetting] = ( + None # Declare type since type not compatible with := + ) if setting_name in ("cpu", "memory"): # NOTE: use copy + update to apply values that may be outside of the range @@ -804,8 +817,8 @@ class CanaryOptimization(BaseOptimization): main_container: V1Container # State for tuning resources - tuning_pod: Optional[V1Pod] - tuning_container: Optional[V1Container] + tuning_pod: Optional[V1Pod] = None + tuning_container: Optional[V1Container] = None _tuning_pod_template_spec: Optional[V1PodTemplateSpec] = pydantic.PrivateAttr() @@ -892,9 +905,9 @@ def adjust( self.adjustments.append(adjustment) setting_name, value = _normalize_adjustment(adjustment) self.logger.info(f"adjusting {setting_name} to {value}") - env_setting: Optional[ - servo.EnvironmentSetting - ] = None # Declare type since type not compatible with := + env_setting: Optional[servo.EnvironmentSetting] = ( + None # Declare type since type not compatible with := + ) if setting_name in ("cpu", "memory"): # NOTE: use copy + update to apply values that may be outside of the range @@ -1005,9 +1018,9 @@ async def _configure_tuning_pod_template_spec(self) -> None: if pod_template_spec.metadata.annotations is None: pod_template_spec.metadata.annotations = {} - pod_template_spec.metadata.annotations[ - "opsani.com/opsani_tuning_for" - ] = self.name + pod_template_spec.metadata.annotations["opsani.com/opsani_tuning_for"] = ( + self.name + ) if pod_template_spec.metadata.labels is None: pod_template_spec.metadata.labels = {} pod_template_spec.metadata.labels["opsani_role"] = "tuning" @@ -1445,9 +1458,7 @@ async def raise_for_status(self) -> None: include_container_logs=self.workload_config.container_logs_in_error_status, ) - class Config: - arbitrary_types_allowed = True - extra = pydantic.Extra.forbid + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") class KubernetesOptimizations(pydantic.BaseModel, servo.logging.Mixin): @@ -1668,36 +1679,37 @@ async def is_ready(self): else: return True - class Config: - arbitrary_types_allowed = True - + model_config = ConfigDict(arbitrary_types_allowed=True) -def DNSSubdomainName(description: str | None = None) -> Any: - """DNSSubdomainName returns a pydantic.Field that models a Kubernetes DNS Subdomain Name used as the name for most - resource types. Its only parameter allows specifying a custom description for the field when the model schema is dumped. - - Valid DNS Subdomain Names conform to [RFC 1123](https://tools.ietf.org/html/rfc1123) and must: - * contain no more than 253 characters - * contain only lowercase alphanumeric characters, '-' or '.' - * start with an alphanumeric character - * end with an alphanumeric character - See https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names - """ - return pydantic.Field( +DNS_SUBDOMAIN_NAME_REGEX = r"^[0-9a-zA-Z]([0-9a-zA-Z\\.-])*[0-9A-Za-z]$" +DNSSubdomainName = Annotated[ + str, + pydantic.StringConstraints( strip_whitespace=True, min_length=1, max_length=253, - regex="^[0-9a-zA-Z]([0-9a-zA-Z\\.-])*[0-9A-Za-z]$", - description=description, - ) + pattern=DNS_SUBDOMAIN_NAME_REGEX, + ), +] +DNSSubdomainName.__doc__ = """DNSSubdomainName returns a pydantic.Field that models a Kubernetes DNS Subdomain Name used as the name for most +resource types. Its only parameter allows specifying a custom description for the field when the model schema is dumped. +Valid DNS Subdomain Names conform to [RFC 1123](https://tools.ietf.org/html/rfc1123) and must: + * contain no more than 253 characters + * contain only lowercase alphanumeric characters, '-' or '.' + * start with an alphanumeric character + * end with an alphanumeric character + +See https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names +""" -DNSLabelNameField = pydantic.Field( + +DNSLabelNameField = pydantic.StringConstraints( strip_whitespace=True, min_length=1, max_length=63, - regex="^[0-9a-zA-Z]([0-9a-zA-Z-])*[0-9A-Za-z]$", + pattern=r"^[0-9a-zA-Z]([0-9a-zA-Z-])*[0-9A-Za-z]$", ) DNSLabelName = Annotated[str, DNSLabelNameField] DNSLabelName.__doc__ = """DNSLabelName models a Kubernetes DNS Label Name identified used to name some resource types. @@ -1712,10 +1724,10 @@ def DNSSubdomainName(description: str | None = None) -> Any: """ -ContainerTagNameField = pydantic.Field( +ContainerTagNameField = pydantic.StringConstraints( min_length=1, max_length=128, - regex="^[0-9a-zA-Z]([0-9a-zA-Z_\\.\\-/:@])*$", + pattern="^[0-9a-zA-Z]([0-9a-zA-Z_\\.\\-/:@])*$", strip_whitespace=True, ) ContainerTagName = Annotated[str, ContainerTagNameField] @@ -1752,35 +1764,34 @@ class OptimizationStrategy(enum.StrEnum): OptimizationStrategy is an enumeration of the possible ways to perform optimization on a Kubernetes Deployment. """ - default = "default" + default: Literal["default"] = "default" """The default strategy directly applies adjustments to the target Deployment and its containers. """ - canary = "canary" + canary: Literal["canary"] = "canary" """The canary strategy creates a servo managed standalone tuning Pod based on the target Deployment and makes adjustments to it instead of the Deployment itself. """ class BaseOptimizationStrategyConfiguration(pydantic.BaseModel): - type: OptimizationStrategy = pydantic.Field(..., const=True) + type: OptimizationStrategy def __eq__(self, other) -> bool: if isinstance(other, OptimizationStrategy): return self.type == other return super().__eq__(other) - class Config: - extra = pydantic.Extra.forbid + model_config = ConfigDict(extra="forbid") class DefaultOptimizationStrategyConfiguration(BaseOptimizationStrategyConfiguration): - type = pydantic.Field(OptimizationStrategy.default, const=True) + type: OptimizationStrategy = pydantic.Field(OptimizationStrategy.default) class CanaryOptimizationStrategyConfiguration(BaseOptimizationStrategyConfiguration): - type = pydantic.Field(OptimizationStrategy.canary, const=True) - alias: Optional[ContainerTagName] + type: OptimizationStrategy = pydantic.Field(OptimizationStrategy.canary) + alias: Optional[ContainerTagName] = None class FailureMode(enum.StrEnum): @@ -1856,14 +1867,10 @@ class BaseKubernetesConfiguration(servo.BaseConfiguration): context: Optional[str] = pydantic.Field( description="Name of the kubeconfig context to use." ) - namespace: Optional[ - Annotated[ - str, - DNSSubdomainName( - "Kubernetes namespace where the target deployments are running." - ), - ] - ] + namespace: Optional[DNSSubdomainName] = pydantic.Field( + ..., + description="Kubernetes namespace where the target deployments are running.", + ) settlement: Optional[servo.Duration] = pydantic.Field( description="Duration to observe the application after an adjust to ensure the deployment is stable. May be overridden by optimizer supplied `control.adjust.settlement` value." ) @@ -1882,7 +1889,7 @@ class BaseKubernetesConfiguration(servo.BaseConfiguration): description="Disable to prevent a canary strategy with tuning pod adjustments", ) - @pydantic.validator("on_failure") + @pydantic.field_validator("on_failure") def validate_failure_mode(cls, v): if v == FailureMode.destroy: servo.logger.warning( @@ -1904,14 +1911,14 @@ class DeploymentConfiguration(BaseKubernetesConfiguration): The DeploymentConfiguration class models the configuration of an optimizable Kubernetes Deployment. """ - name: Annotated[str, DNSSubdomainName()] + name: DNSSubdomainName containers: List[ContainerConfiguration] strategy: StrategyTypes = OptimizationStrategy.default replicas: servo.Replicas class StatefulSetConfiguration(DeploymentConfiguration): - @pydantic.validator("strategy") + @pydantic.field_validator("strategy") def validate_strategy(cls, v): if v == OptimizationStrategy.canary: raise NotImplementedError( @@ -1921,7 +1928,7 @@ def validate_strategy(cls, v): class KubernetesConfiguration(BaseKubernetesConfiguration): - namespace: Annotated[str, DNSSubdomainName()] = "default" + namespace: DNSSubdomainName = "default" timeout: servo.Duration = "5m" permissions: List[PermissionSet] = pydantic.Field( STANDARD_PERMISSIONS, @@ -1944,7 +1951,7 @@ def workloads( ) -> list[Union[StatefulSetConfiguration, DeploymentConfiguration]]: return (self.deployments or []) + (self.stateful_sets or []) - @pydantic.root_validator + @pydantic.model_validator(mode="before") def check_workload(cls, values): if (not values.get("deployments")) and ( not values.get("rollouts") and (not values.get("stateful_sets")) @@ -2043,9 +2050,9 @@ async def load_kubeconfig(self) -> None: ) elif os.getenv("KUBERNETES_SERVICE_HOST"): if os.getenv("NO_PROXY"): - os.environ[ - "NO_PROXY" - ] = f'{os.environ["NO_PROXY"]},{os.environ["KUBERNETES_SERVICE_HOST"]}' + os.environ["NO_PROXY"] = ( + f'{os.environ["NO_PROXY"]},{os.environ["KUBERNETES_SERVICE_HOST"]}' + ) else: os.environ["NO_PROXY"] = os.environ["KUBERNETES_SERVICE_HOST"] kubernetes_asyncio.config.load_incluster_config() @@ -2055,9 +2062,9 @@ async def load_kubeconfig(self) -> None: ) -KubernetesOptimizations.update_forward_refs() -SaturationOptimization.update_forward_refs() -CanaryOptimization.update_forward_refs() +KubernetesOptimizations.model_rebuild() +SaturationOptimization.model_rebuild() +CanaryOptimization.model_rebuild() class KubernetesChecks(servo.BaseChecks): @@ -2251,9 +2258,9 @@ async def attach(self, servo_: servo.Servo) -> None: async with kubernetes_asyncio.client.api_client.ApiClient() as api: v1 = kubernetes_asyncio.client.VersionApi(api) version_obj = await v1.get_code() - self.telemetry[ - f"{self.name}.version" - ] = f"{version_obj.major}.{version_obj.minor}" + self.telemetry[f"{self.name}.version"] = ( + f"{version_obj.major}.{version_obj.minor}" + ) self.telemetry[f"{self.name}.platform"] = version_obj.platform @servo.on_event() diff --git a/servo/connectors/kubernetes_helpers/base.py b/servo/connectors/kubernetes_helpers/base.py index 468f83a1..81d955d4 100644 --- a/servo/connectors/kubernetes_helpers/base.py +++ b/servo/connectors/kubernetes_helpers/base.py @@ -24,13 +24,11 @@ class BaseKubernetesHelper(abc.ABC): @classmethod @abc.abstractmethod - async def watch_args(cls, api_object: object) -> AsyncIterator[dict[str, Any]]: - ... + async def watch_args(cls, api_object: object) -> AsyncIterator[dict[str, Any]]: ... @classmethod @abc.abstractmethod - def is_ready(cls, api_object: object, event_type: Optional[str] = None) -> bool: - ... + def is_ready(cls, api_object: object, event_type: Optional[str] = None) -> bool: ... @classmethod async def wait_until_deleted(cls, api_object: object) -> None: diff --git a/servo/connectors/kubernetes_helpers/base_workload.py b/servo/connectors/kubernetes_helpers/base_workload.py index ef960c37..cffef656 100644 --- a/servo/connectors/kubernetes_helpers/base_workload.py +++ b/servo/connectors/kubernetes_helpers/base_workload.py @@ -34,15 +34,13 @@ class BaseKubernetesWorkloadHelper(BaseKubernetesHelper): @classmethod @abc.abstractmethod - def check_conditions(cls, workload: Union[V1Deployment, V1StatefulSet]) -> None: - ... + def check_conditions(cls, workload: Union[V1Deployment, V1StatefulSet]) -> None: ... @classmethod @abc.abstractmethod async def get_latest_pods( cls, workload: Union[V1Deployment, V1StatefulSet] - ) -> list[V1Pod]: - ... + ) -> list[V1Pod]: ... @classmethod def is_ready( diff --git a/servo/connectors/opsani_dev.py b/servo/connectors/opsani_dev.py index b3113644..02e93e9a 100644 --- a/servo/connectors/opsani_dev.py +++ b/servo/connectors/opsani_dev.py @@ -21,6 +21,7 @@ import kubernetes_asyncio import kubernetes_asyncio.client import pydantic +import pydantic_settings import servo import servo.types @@ -94,7 +95,7 @@ class OpsaniDevConfiguration(servo.BaseConfiguration): ) # alias to maintain backward compatibility workload_kind: str = pydantic.Field( default="Deployment", - regex=r"^([Dd]eployment)$", + pattern=r"^([Dd]eployment)$", ) container: str service: str @@ -117,8 +118,12 @@ class OpsaniDevConfiguration(servo.BaseConfiguration): description="Disable to prevent a canary strategy", ) - class Config(servo.AbstractBaseConfiguration.Config): - allow_population_by_field_name = True + def __init__(self, *args, **kwargs): + self.model_config = pydantic_settings.SettingsConfigDict( + **servo.AbstractBaseConfiguration.model_config, + allow_population_by_field_name=True, + ) + super().__init__() @classmethod def generate(cls, **kwargs) -> "OpsaniDevConfiguration": @@ -781,9 +786,9 @@ async def check_controller_labels(self) -> None: ), f"{self.config.workload_kind} '{controller.metadata.name}' does not have any labels" # Add optimizer label to the static values required_labels = ENVOY_SIDECAR_LABELS.copy() - required_labels[ - "servo.opsani.com/optimizer" - ] = servo.connectors.kubernetes.dns_labelize(self.optimizer.id) + required_labels["servo.opsani.com/optimizer"] = ( + servo.connectors.kubernetes.dns_labelize(self.optimizer.id) + ) # NOTE: Check for exact labels as this isn't configurable delta = dict(set(required_labels.items()) - set(labels.items())) diff --git a/servo/connectors/prometheus.py b/servo/connectors/prometheus.py index 489e091f..ba9403f9 100644 --- a/servo/connectors/prometheus.py +++ b/servo/connectors/prometheus.py @@ -36,6 +36,7 @@ import httpx import pydantic +import pydantic_core import pytz import servo @@ -125,7 +126,7 @@ class ActiveTarget(pydantic.BaseModel): url: str = pydantic.Field(..., alias="scrapeUrl") global_url: str = pydantic.Field(..., alias="globalUrl") health: Literal["up", "down", "unknown"] - labels: Optional[Dict[str, str]] + labels: Optional[Dict[str, str]] = None discovered_labels: Optional[Dict[str, str]] = pydantic.Field( ..., alias="discoveredLabels" ) @@ -208,9 +209,11 @@ class TargetsRequest(BaseRequest): `active`, `dropped`, or `any`. """ - endpoint: str = pydantic.Field("/targets", const=True) + endpoint: Literal["/targets"] = "/targets" state: Optional[TargetsStateFilter] = None - param_attrs: Tuple[str] = pydantic.Field(("state",), const=True) + param_attrs: Tuple[str] = pydantic.Field( + ("state",), + ) class QueryRequest(BaseRequest, abc.ABC): @@ -222,7 +225,7 @@ class QueryRequest(BaseRequest, abc.ABC): """ query: str - timeout: Optional[servo.Duration] + timeout: Optional[servo.Duration] = None class InstantQuery(QueryRequest): @@ -234,8 +237,8 @@ class InstantQuery(QueryRequest): server current time when the query is evaluated. """ - endpoint: str = pydantic.Field("/query", const=True) - param_attrs: Tuple[str] = pydantic.Field(("query", "time", "timeout"), const=True) + endpoint: Literal["/query"] + param_attrs: Tuple[str] = pydantic.Field(("query", "time", "timeout")) time: Optional[datetime.datetime] = None @@ -251,15 +254,15 @@ class RangeQuery(QueryRequest): minutes across the queried time range, determining the number of data points returned. """ - endpoint: str = pydantic.Field("/query_range", const=True) + endpoint: Literal["/query_range"] param_attrs: Tuple[str] = pydantic.Field( - ("query", "start", "end", "step", "timeout"), const=True + ("query", "start", "end", "step", "timeout") ) start: datetime.datetime end: datetime.datetime - step: servo.Duration + step: servo.Duration = pydantic.Field(..., validate_default=True) - @pydantic.validator("step", pre=True, always=True) + @pydantic.field_validator("step", mode="before") @classmethod def _default_step_from_metric(cls, step, values) -> str: if step is None: @@ -268,7 +271,7 @@ def _default_step_from_metric(cls, step, values) -> str: return step - @pydantic.validator("end") + @pydantic.field_validator("end") @classmethod def _validate_range(cls, end, values) -> dict: assert end > values["start"], "start time must be earlier than end time" @@ -314,12 +317,10 @@ class BaseVector(abc.ABC, pydantic.BaseModel): metric: Dict[str, str] @abc.abstractmethod - def __len__(self) -> int: - ... + def __len__(self) -> int: ... @abc.abstractmethod - def __iter__(self) -> Scalar: - ... + def __iter__(self) -> Scalar: ... class InstantVector(BaseVector): @@ -474,10 +475,10 @@ class BaseResponse(pydantic.BaseModel, abc.ABC): request: BaseRequest status: Status data: Data - error: Optional[Error] - warnings: Optional[List[str]] + error: Optional[Error] = None + warnings: Optional[List[str]] = None - @pydantic.root_validator(pre=True) + @pydantic.model_validator(mode="before") def _parse_error(cls, values: Dict[str, Any]) -> Dict[str, Any]: if error := dict( filter(lambda item: item[0].startswith("error"), values.items()) @@ -570,8 +571,8 @@ def _time_series_from_vector(self, vector: BaseVector) -> servo.TimeSeries: ) -def _rstrip_slash(cls, base_url): - return base_url.rstrip("/") +def _rstrip_slash(cls, base_url: pydantic_core.Url): + return str(base_url).rstrip("/") class Client(pydantic.BaseModel): @@ -588,9 +589,7 @@ class Client(pydantic.BaseModel): """ base_url: pydantic.AnyHttpUrl - _normalize_base_url = pydantic.validator("base_url", allow_reuse=True)( - _rstrip_slash - ) + _normalize_base_url = pydantic.field_validator("base_url")(_rstrip_slash) @property def url(self) -> str: @@ -750,9 +749,7 @@ class PrometheusConfiguration(servo.BaseConfiguration): """ base_url: pydantic.AnyHttpUrl = DEFAULT_BASE_URL - _normalize_base_url = pydantic.validator("base_url", allow_reuse=True)( - _rstrip_slash - ) + _normalize_base_url = pydantic.field_validator("base_url")(_rstrip_slash) """The base URL for accessing the Prometheus metrics API. The URL must point to the root of the Prometheus deployment. Resource paths @@ -1111,13 +1108,17 @@ def targets( [ target.pool, target.health, - f"{target.url} ({target.global_url})" - if target.url != target.global_url - else target.url, + ( + f"{target.url} ({target.global_url})" + if target.url != target.global_url + else target.url + ), "\n".join(labels), - f"{target.last_scraped_at:%Y-%m-%d %H:%M:%S} ({servo.cli.timeago(target.last_scraped_at, pytz.utc.localize(datetime.datetime.now()))} in {target.last_scrape_duration})" - if target.last_scraped_at - else "-", + ( + f"{target.last_scraped_at:%Y-%m-%d %H:%M:%S} ({servo.cli.timeago(target.last_scraped_at, pytz.utc.localize(datetime.datetime.now()))} in {target.last_scrape_duration})" + if target.last_scraped_at + else "-" + ), target.last_error or "-", ] ) diff --git a/servo/connectors/vegeta.py b/servo/connectors/vegeta.py index d5bea570..e32dae4e 100644 --- a/servo/connectors/vegeta.py +++ b/servo/connectors/vegeta.py @@ -26,6 +26,7 @@ import pydantic import servo +from pydantic import ConfigDict METRICS = [ servo.Metric("throughput", servo.Unit.requests_per_minute), @@ -59,7 +60,7 @@ class Latencies(pydantic.BaseModel): max: int min: int - @pydantic.validator("*") + @pydantic.field_validator("*") @classmethod def convert_nanoseconds_to_milliseconds(cls, latency): # Convert Nanonsecond -> Millisecond @@ -84,16 +85,16 @@ class VegetaReport(pydantic.BaseModel): rate: float throughput: float success: float - error_rate: float = None + error_rate: float = pydantic.Field(None, validate_default=True) status_codes: Dict[str, int] errors: List[str] - @pydantic.validator("throughput") + @pydantic.field_validator("throughput") @classmethod def convert_throughput_to_rpm(cls, throughput: float) -> float: return throughput * 60 - @pydantic.validator("error_rate", always=True, pre=True) + @pydantic.field_validator("error_rate", mode="before") @classmethod def calculate_error_rate_from_success(cls, v, values: Dict[str, Any]) -> float: success_rate = values["success"] @@ -114,6 +115,11 @@ class VegetaConfiguration(servo.BaseConfiguration): TargetFormat.http, description="Specifies the format of the targets input. Valid values are http and json. Refer to the Vegeta docs for details.", ) + + @pydantic.field_serializer("format") + def format_value(self, fmt: TargetFormat) -> str: + return fmt.value() + target: Optional[str] = pydantic.Field( description="Specifies a single formatted Vegeta target to load. See the format option to learn about available target formats. This option is exclusive of the targets option and will provide a target to Vegeta via stdin." ) @@ -161,7 +167,7 @@ def duration(self) -> Optional[servo.Duration]: else: return None - @pydantic.root_validator(pre=True) + @pydantic.model_validator(mode="before") @classmethod def validate_target(cls, values: Dict[str, Any]) -> Dict[str, Any]: target, targets = servo.values_for_keys(values, ("target", "targets")) @@ -181,25 +187,20 @@ def target_json_schema() -> Dict[str, Any]: schema_path = pathlib.Path(__file__).parent / "vegeta_target_schema.json" return json.load(open(schema_path)) - @pydantic.validator("target", "targets") + @pydantic.field_validator("target", "targets") @classmethod - def validate_target_format( - cls, - value: Union[str, pydantic.FilePath], - field: pydantic.Field, - values: Dict[str, Any], - ) -> str: + def validate_target_format(cls, value: str, info: pydantic.ValidationInfo) -> str: if value is None: return value - format: TargetFormat = values.get("format") + format: TargetFormat = info.data.get("format") with contextlib.ExitStack() as stack: - if field.name == "target": + if info.field_name == "target": value_stream = io.StringIO(value) - elif field.name == "targets": + elif info.field_name == "targets": value_stream = stack.enter_context(open(value)) else: - raise ValueError(f"unknown field '{field.name}'") + raise ValueError(f"unknown field '{info.field_name}'") if format == TargetFormat.http: # Scan through the targets and run basic heuristics @@ -232,7 +233,7 @@ def validate_target_format( try: data = json.load(value_stream) except json.JSONDecodeError as e: - raise ValueError(f"{field.name} contains invalid JSON") from e + raise ValueError(f"{info.field_name} contains invalid JSON") from e # Validate the target data with JSON Schema try: @@ -244,7 +245,7 @@ def validate_target_format( return value - @pydantic.validator("rate") + @pydantic.field_validator("rate") @classmethod def validate_rate(cls, v: Union[int, str]) -> str: assert isinstance( @@ -280,11 +281,6 @@ def generate(cls, **kwargs) -> "VegetaConfiguration": **kwargs, ) - class Config: - json_encoders = servo.BaseConfiguration.json_encoders( - {TargetFormat: lambda t: t.value()} - ) - class VegetaChecks(servo.BaseChecks): config: VegetaConfiguration diff --git a/servo/events.py b/servo/events.py index 92e6c341..60860c4b 100644 --- a/servo/events.py +++ b/servo/events.py @@ -41,11 +41,13 @@ import pydantic import pydantic.typing +import pydantic_core import servo.errors import servo.pubsub import servo.utilities.inspect import servo.utilities.strings +from pydantic import ConfigDict __all__ = [ "Event", @@ -154,8 +156,7 @@ def dict( exclude_none=exclude_none, ) - class Config: - arbitrary_types_allowed = True + model_config = ConfigDict(arbitrary_types_allowed=True) EventCallable = TypeVar("EventCallable", bound=Callable[..., Any]) @@ -204,7 +205,7 @@ def __str__(self): class EventContext(pydantic.BaseModel): event: Event preposition: Preposition - created_at: datetime.datetime = None + created_at: datetime.datetime = pydantic.Field(None, validate_default=True) @classmethod # Usable as a validator def from_str(cls, event_str) -> Optional["EventContext"]: @@ -227,7 +228,7 @@ def from_str(cls, event_str) -> Optional["EventContext"]: return EventContext(preposition=Preposition.from_str(preposition), event=event) - @pydantic.validator("created_at", pre=True, always=True) + @pydantic.field_validator("created_at", mode="before") @classmethod def set_created_at_now(cls, v): return v or datetime.datetime.now() @@ -318,10 +319,10 @@ class EventResult(pydantic.BaseModel): preposition: Preposition handler: EventHandler connector: "Mixin" - created_at: datetime.datetime = None - value: Any + created_at: datetime.datetime = pydantic.Field(None, validate_default=True) + value: Any = None - @pydantic.validator("created_at", pre=True, always=True) + @pydantic.field_validator("created_at", mode="before") @classmethod def set_created_at_now(cls, v): return v or datetime.datetime.now() @@ -387,7 +388,9 @@ async def fn(_) -> AsyncGenerator[None, None, None]: try: lines = inspect.getsourcelines(signature) last = lines[0][-1] - if not last.strip() in ("pass", "..."): + if last.strip() in ("pass", "...") or last.endswith(": ...\n"): + pass + else: raise ValueError( "function body of event declaration must be an async generator or a stub using `...` or `pass` keywords" ) @@ -566,9 +569,11 @@ def decorator(fn: EventCallable) -> EventCallable: localns=handler_localns, ), name=name, - callable_description="event handler" - if preposition == Preposition.on - else "before event handler", + callable_description=( + "event handler" + if preposition == Preposition.on + else "before event handler" + ), ) elif preposition == Preposition.after: after_handler_signature = inspect.Signature.from_callable(__after_handler) @@ -608,7 +613,8 @@ def __after_handler(self, results: List[EventResult]) -> None: _is_base_class_defined = False -class Metaclass(pydantic.main.ModelMetaclass): +# https://github.com/pydantic/pydantic/issues/5124#issuecomment-1449653294 +class Metaclass(type(pydantic.BaseModel)): def __new__(mcs, name, bases, namespace, **kwargs): # Decorate the class with an event registry, inheriting from our parent connectors event_handlers: List[EventHandler] = [] @@ -652,30 +658,17 @@ def __init__( **kwargs, ) - # NOTE: Connector references are held off the model so - # that Pydantic doesn't see additional attributes - __connectors__ = __connectors__ if __connectors__ is not None else [self] - _connector_event_bus[self] = __connectors__ - - @classmethod - def __get_validators__(cls: Mixin) -> pydantic.typing.CallableGenerator: - yield cls.validate - - @classmethod - def validate(cls: Mixin, value: Any) -> Mixin: - if not isinstance(value, Mixin): - raise TypeError( - f"field (type {type(value)}) must be instance of events.Mixin" - ) - # Ideally, the name property would be part of an abstract base but pydantic doesn't play nice with abc # https://github.com/samuelcolvin/pydantic/discussions/2410 - if (not hasattr(value, "name")) or not isinstance(value.name, str): + if (not hasattr(self, "name")) or not isinstance(self.name, str): raise TypeError( - f"events.Mixin inheritors must define a name property of type str (found {type(getattr(value, 'name', None))})" + f"events.Mixin inheritors must define a name property of type str (found {type(getattr(self, 'name', None))})" ) - return value + # NOTE: Connector references are held off the model so + # that Pydantic doesn't see additional attributes + __connectors__ = __connectors__ if __connectors__ is not None else [self] + _connector_event_bus[self] = __connectors__ @classmethod def responds_to_event(cls, event: Union[Event, str]) -> bool: diff --git a/servo/fast_fail.py b/servo/fast_fail.py index 953e30b8..f0b2fd16 100644 --- a/servo/fast_fail.py +++ b/servo/fast_fail.py @@ -42,10 +42,10 @@ class SloOutcomeStatus(enum.StrEnum): class SloOutcome(pydantic.BaseModel): status: SloOutcomeStatus - metric_readings: Optional[List[servo.types.Reading]] - threshold_readings: Optional[List[servo.types.Reading]] - metric_value: Optional[decimal.Decimal] - threshold_value: Optional[decimal.Decimal] + metric_readings: Optional[List[servo.types.Reading]] = None + threshold_readings: Optional[List[servo.types.Reading]] = None + metric_value: Optional[decimal.Decimal] = None + threshold_value: Optional[decimal.Decimal] = None checked_at: datetime.datetime def to_message(self, condition: servo.types.SloCondition): @@ -200,9 +200,9 @@ def check_readings( servo.logger.debug(f"SLO results: {devtools.pformat(self._results)}") # Log the latest results - last_results_buckets: Dict[ - SloOutcomeStatus, List[str] - ] = collections.defaultdict(list) + last_results_buckets: Dict[SloOutcomeStatus, List[str]] = ( + collections.defaultdict(list) + ) for condition, results_list in self._results.items(): last_result = results_list[-1] last_results_buckets[last_result.status].append(str(condition)) diff --git a/servo/pubsub.py b/servo/pubsub.py index fda50bb8..f6d41690 100644 --- a/servo/pubsub.py +++ b/servo/pubsub.py @@ -52,6 +52,8 @@ import yaml as yaml_ import servo.types +from pydantic import Field, StringConstraints, ConfigDict +from typing_extensions import Annotated __all__ = [ "BaseSubscription", @@ -185,12 +187,15 @@ def yaml(self) -> Any: return yaml_.load(self.content, Loader=yaml_.FullLoader) -ChannelName = pydantic.constr( - strip_whitespace=True, - min_length=1, - max_length=253, - regex="^[0-9a-zA-Z]([0-9a-zA-Z\\.\\-_])*[0-9A-Za-z]$", -) +ChannelName = Annotated[ + str, + StringConstraints( + strip_whitespace=True, + min_length=1, + max_length=253, + pattern="^[0-9a-zA-Z]([0-9a-zA-Z\\.\\-_])*[0-9A-Za-z]$", + ), +] class _ExchangeChildModel(pydantic.BaseModel): @@ -738,7 +743,7 @@ class Subscription(BaseSubscription): selector: Selector - @pydantic.validator("selector", pre=True) + @pydantic.field_validator("selector", mode="before") def _expand_selector_regex(cls, v: str) -> Union[str, Pattern]: if isinstance(v, str) and v.startswith("/") and v.endswith("/"): return re.compile(v[1:-1]) @@ -860,7 +865,7 @@ async def _my_callback(message: Message, channel: Channel) -> None: """ subscription: Subscription - callback: Optional[Callback] + callback: Optional[Callback] = None _event: asyncio.Event = pydantic.PrivateAttr(default_factory=asyncio.Event) _iterators: List[_Iterator] = pydantic.PrivateAttr([]) @@ -952,8 +957,7 @@ def __eq__(self, other) -> bool: return False - class Config: - arbitrary_types_allowed = True + model_config = ConfigDict(arbitrary_types_allowed=True) class Publisher(_ExchangeChildModel): @@ -966,7 +970,7 @@ class Publisher(_ExchangeChildModel): channels: The Channels that the Publisher publishes Messages to. """ - channels: pydantic.conlist(Channel, min_items=1) + channels: Annotated[List[Channel], Field(min_length=1)] async def __call__( self, message: Message, *channels: List[Union[Channel, str]] @@ -1056,8 +1060,7 @@ async def __call__(self, message: Message, channel: Channel) -> Optional[Message else: return self.callback(message, channel) - class Config: - arbitrary_types_allowed = True + model_config = ConfigDict(arbitrary_types_allowed=True) SplitterCallback = Callable[["Splitter", Message, Channel], Awaitable[None]] @@ -1095,7 +1098,7 @@ async def _split_message(splitter: Splitter, message: Message, channel: Channel) """ callback: SplitterCallback - channels: pydantic.conlist(Channel, min_items=1) + channels: Annotated[List[Channel], Field(min_length=1)] def __init__( self, callback: SplitterCallback, *channels: List[Channel], **kwargs @@ -1155,7 +1158,7 @@ async def _aggregate_text(aggregator: Aggregator, message: Message, channel: Cha ``` """ - from_channels: pydantic.conlist(Channel, min_items=2) + from_channels: Annotated[List[Channel], Field(min_length=2)] to_channel: Channel callback: AggregatorCallback every: Optional[servo.types.Duration] = None @@ -1276,8 +1279,9 @@ def __repr_args__(self) -> pydantic.ReprArgs: ), ] - class Config: - allow_mutation = False + # TODO[pydantic]: The following keys were removed: `allow_mutation`. + # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-config for more information. + model_config = ConfigDict(frozen=False) class _PublisherMethod: @@ -1646,10 +1650,10 @@ def _current_iterator() -> Optional[AsyncIterator]: return servo.pubsub._current_iterator_var.get() -Splitter.update_forward_refs() -Aggregator.update_forward_refs() -Channel.update_forward_refs() -_Iterator.update_forward_refs() +Splitter.model_rebuild() +Aggregator.model_rebuild() +Channel.model_rebuild() +_Iterator.model_rebuild() def _error_watcher(task: asyncio.Task) -> None: diff --git a/servo/runner.py b/servo/runner.py index 02ed023d..1e5111f3 100644 --- a/servo/runner.py +++ b/servo/runner.py @@ -31,25 +31,25 @@ import servo import servo.api -import servo.telemetry import servo.configuration +import servo.logging +import servo.telemetry import servo.utilities.key_paths import servo.utilities.strings -from servo.servo import _set_current_servo, set_current_command_uid +from servo.servo import _set_current_servo, set_current_command_uid, Servo from servo.types import Adjustment, Control, Description, Duration, Measurement +from pydantic import ConfigDict class ServoRunner(pydantic.BaseModel, servo.logging.Mixin): interactive: bool = False _assembly_runner: AssemblyRunner = pydantic.PrivateAttr(None) - _servo: servo.Servo = pydantic.PrivateAttr(None) + _servo: Servo = pydantic.PrivateAttr(None) _running: bool = pydantic.PrivateAttr(False) _file_watcher_task: Optional[asyncio.Task] = pydantic.PrivateAttr(None) _main_loop_task: Optional[asyncio.Task] = pydantic.PrivateAttr(None) _task_group: Optional[asyncio.TaskGroup] = pydantic.PrivateAttr(None) - - class Config: - arbitrary_types_allowed = True + model_config = ConfigDict(arbitrary_types_allowed=True) def __init__( self, servo_: servo, _assembly_runner: AssemblyRunner = None, **kwargs @@ -137,9 +137,9 @@ async def adjust( return aggregate_description async def exec_command(self) -> servo.api.Status: - cmd_response: Union[ - servo.api.CommandResponse, servo.api.Status - ] = await self.servo.post_event(servo.api.Events.whats_next, None) + cmd_response: Union[servo.api.CommandResponse, servo.api.Status] = ( + await self.servo.post_event(servo.api.Events.whats_next, None) + ) self.logger.trace(devtools.pformat(cmd_response)) self.logger.info(f"What's Next? => {cmd_response.command}") set_current_command_uid(cmd_response.command_uid) @@ -404,9 +404,7 @@ class AssemblyRunner(pydantic.BaseModel, servo.logging.Mixin): _root_task: asyncio.Task | None = pydantic.PrivateAttr(None) _task_group: asyncio.TaskGroup | None = pydantic.PrivateAttr(None) _runners_task_group: asyncio.TaskGroup | None = pydantic.PrivateAttr(None) - - class Config: - arbitrary_types_allowed = True + model_config = ConfigDict(arbitrary_types_allowed=True) def __init__(self, assembly: servo.Assembly, **kwargs) -> None: super().__init__(assembly=assembly, **kwargs) diff --git a/servo/servo.py b/servo/servo.py index 27d47e23..5643be17 100644 --- a/servo/servo.py +++ b/servo/servo.py @@ -118,29 +118,23 @@ class _EventDefinitions(Protocol): # Lifecycle events @servo.events.event(Events.attach) - async def attach(self, servo_: Servo) -> None: - ... + async def attach(self, servo_: Servo) -> None: ... @servo.events.event(Events.detach) - async def detach(self, servo_: Servo) -> None: - ... + async def detach(self, servo_: Servo) -> None: ... @servo.events.event(Events.startup) - async def startup(self) -> None: - ... + async def startup(self) -> None: ... @servo.events.event(Events.shutdown) - async def shutdown(self) -> None: - ... + async def shutdown(self) -> None: ... # Informational events @servo.events.event(Events.metrics) - async def metrics(self) -> list[servo.types.Metric]: - ... + async def metrics(self) -> list[servo.types.Metric]: ... @servo.events.event(Events.components) - async def components(self) -> list[servo.types.Component]: - ... + async def components(self) -> list[servo.types.Component]: ... # Operational events @servo.events.event(Events.measure) @@ -149,8 +143,7 @@ async def measure( *, metrics: list[str] = None, control: servo.types.Control = servo.types.Control(), - ) -> servo.types.Measurement: - ... + ) -> servo.types.Measurement: ... @servo.events.event(Events.check) async def check( @@ -159,26 +152,22 @@ async def check( halt_on: Optional[ servo.types.ErrorSeverity ] = servo.types.ErrorSeverity.critical, - ) -> list[servo.checks.Check]: - ... + ) -> list[servo.checks.Check]: ... @servo.events.event(Events.describe) async def describe( self, control: servo.types.Control = servo.types.Control() - ) -> servo.types.Description: - ... + ) -> servo.types.Description: ... @servo.events.event(Events.adjust) async def adjust( self, adjustments: list[servo.types.Adjustment], control: servo.types.Control = servo.types.Control(), - ) -> servo.types.Description: - ... + ) -> servo.types.Description: ... @servo.events.event(Events.promote) - async def promote(self) -> None: - ... + async def promote(self) -> None: ... @servo.connector.metadata( @@ -261,7 +250,7 @@ def __init__( connector._global_config = self.config.settings connector._optimizer = self.config.optimizer - @pydantic.root_validator() + @pydantic.model_validator(mode="before") def _initialize_name(cls, values: dict[str, Any]) -> dict[str, Any]: if values["name"] == "servo" and values.get("config"): values["name"] = values["config"].name or getattr( @@ -556,7 +545,7 @@ def progress_request( time_remaining: Optional[ Union[servo.types.Numeric, servo.types.Duration] ] = None, - **kwargs + **kwargs, # logs: Optional[list[str]] = None, # _servo: Optional[Servo] = None, # connector: Optional[servo.connector.BaseConnector] = None, diff --git a/servo/telemetry.py b/servo/telemetry.py index d3675d63..adb7e916 100644 --- a/servo/telemetry.py +++ b/servo/telemetry.py @@ -51,8 +51,8 @@ class DiagnosticStates(enum.StrEnum): class Diagnostics(pydantic.BaseModel): - configmap: Optional[dict[str, Any]] - logs: Optional[dict[str, Any]] + configmap: Optional[dict[str, Any]] = None + logs: Optional[dict[str, Any]] = None class Telemetry(pydantic.BaseModel): diff --git a/servo/types/api.py b/servo/types/api.py index 4def6550..0ef0c857 100644 --- a/servo/types/api.py +++ b/servo/types/api.py @@ -65,9 +65,8 @@ def __opsani_repr__(self) -> dict[str, dict[Any, Any]]: class UserData(BaseModel): slo: Optional[SloInput] = None - class Config(BaseModel.Config): - # Support connector level experimentation without needing to update core servox code - extra = pydantic.Extra.allow + def __init__(self): + self.model_config["extra"] = "allow" class Control(BaseModel): @@ -75,16 +74,16 @@ class Control(BaseModel): aspects of the operation to be performed. """ - duration: Duration = cast(Duration, 1) + duration: Duration = pydantic.Field(1, validate_default=True) """How long the operation should execute. """ - delay: Duration = cast(Duration, 0) + delay: Duration = pydantic.Field(0, validate_default=True) """How long to wait beyond the duration in order to ensure that the metrics for the target interval have been aggregated and are available for reading. """ - warmup: Duration = cast(Duration, 0) + warmup: Duration = pydantic.Field(0, validate_default=True) """How long to wait before starting the operation in order to allow the application to reach a steady state (e.g., filling read through caches, loading class files into memory, just-in-time compilation being appliied to critical @@ -112,7 +111,7 @@ class files into memory, just-in-time compilation being appliied to critical """Optional mode control. """ - @pydantic.root_validator(pre=True) + @pydantic.model_validator(mode="before") def validate_past_and_delay(cls, values): if "past" in values: # NOTE: past is an alias for delay in the API @@ -125,7 +124,7 @@ def validate_past_and_delay(cls, values): return values - @pydantic.validator("duration", "warmup", "delay", always=True, pre=True) + @pydantic.field_validator("duration", "warmup", "delay", mode="before") @classmethod def validate_durations(cls, value) -> Duration: return value or Duration(0) @@ -207,7 +206,7 @@ class Measurement(BaseModel): Measurements are sized and sequenced collections of readings. """ - readings: Readings = [] + readings: Readings = pydantic.Field([], validate_default=True) """A list of readings taken of target metrics during the measurement operation. @@ -216,7 +215,7 @@ class Measurement(BaseModel): """ annotations: dict[str, str] = {} - @pydantic.validator("readings", always=True, pre=True) + @pydantic.field_validator("readings", mode="before") def validate_readings_type(cls, value) -> Readings: if value: reading_type = None @@ -230,7 +229,7 @@ def validate_readings_type(cls, value) -> Readings: return value - @pydantic.validator("readings", always=True, pre=True) + @pydantic.field_validator("readings", mode="before") def validate_time_series_dimensionality(cls, value) -> Readings: from servo.logging import logger @@ -314,4 +313,4 @@ def __str__(self) -> str: return f"{self.component_name}.{self.setting_name}={self.value}" -Control.update_forward_refs() +Control.model_rebuild() diff --git a/servo/types/core.py b/servo/types/core.py index 022c068b..bad06769 100644 --- a/servo/types/core.py +++ b/servo/types/core.py @@ -41,6 +41,7 @@ import orjson import pydantic +import pydantic.v1.datetime_parse import pydantic.error_wrappers import pygments.lexers import semver @@ -82,21 +83,13 @@ def default_handler(obj) -> Any: raise err -DEFAULT_JSON_ENCODERS = { - pydantic.SecretStr: lambda v: v.get_secret_value() if v else None, - pydantic.AnyHttpUrl: str, +BASE_MODEL_CONFIG = { + "validate_assignment": True, } - - -class BaseModelConfig: - """The `BaseModelConfig` class provides a common set of Pydantic model - configuration shared across the library. - """ - - json_encoders = DEFAULT_JSON_ENCODERS - json_loads = orjson.loads - json_dumps = _orjson_dumps - validate_assignment = True +BASE_MODEL_CONFIG.__doc__ = """ +The `BaseModelConfig` class provides a common set of Pydantic model +configuration shared across the library. +""" class BaseModel(pydantic.BaseModel): @@ -104,8 +97,7 @@ class BaseModel(pydantic.BaseModel): types utilized throughout the library. """ - class Config(BaseModelConfig): - validate_all = True + model_config: pydantic.ConfigDict = {**BASE_MODEL_CONFIG, "validate_default": True} class License(enum.Enum): @@ -179,6 +171,20 @@ def __str__(self) -> str: # Describing time durations in various forms is very common DurationDescriptor = Union[datetime.timedelta, str, Numeric] +# DELETE ME +from typing import Any +from pydantic_core import CoreSchema, core_schema +from pydantic import GetCoreSchemaHandler, TypeAdapter +from typing_extensions import Annotated + +from pydantic import ( + BaseModel, + GetCoreSchemaHandler, + GetJsonSchemaHandler, + ValidationError, +) +from pydantic.json_schema import JsonSchemaValue + class Duration(datetime.timedelta): """ @@ -224,18 +230,30 @@ def __init__( # Add a type signature so we don't get warning from linters. Implementation is not used (see __new__) ... + # @classmethod + # def __get_validators__(cls): + # yield cls.validate + @classmethod - def __get_validators__(cls): - yield cls.validate + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + return core_schema.no_info_before_validator_function( + cls.validate, handler(datetime.timedelta) + ) @classmethod - def __modify_schema__(cls, field_schema: dict[Any, Any]) -> None: - field_schema.update( + def __get_pydantic_json_schema__( + cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + _core_schema.update( type="string", format="duration", pattern="([\\d\\.]+y)?([\\d\\.]+mm)?(([\\d\\.]+w)?[\\d\\.]+d)?([\\d\\.]+h)?([\\d\\.]+m)?([\\d\\.]+s)?([\\d\\.]+ms)?([\\d\\.]+us)?([\\d\\.]+ns)?", examples=["300ms", "5m", "2h45m", "72h3m0.5s"], ) + # Use the same schema that would be used for `timedelta` + return handler(core_schema.timedelta_schema()) @classmethod def validate(cls, value) -> "Duration": @@ -243,7 +261,7 @@ def validate(cls, value) -> "Duration": return cls(value) # Parse into a timedelta with Pydantic parser - td = pydantic.datetime_parse.parse_duration(value) + td = pydantic.v1.datetime_parse.parse_duration(value) microseconds: float = td / datetime.timedelta(microseconds=1) return cls(microseconds=microseconds) @@ -275,7 +293,7 @@ def human_readable(self) -> str: class BaseProgress(abc.ABC, BaseModel): - started_at: Optional[datetime.datetime] + started_at: Optional[datetime.datetime] = None """The time that progress tracking was started.""" def start(self) -> None: @@ -360,8 +378,7 @@ async def __anext__(self) -> Optional[int]: async def __aenter__(self): return self - async def __aexit__(self, exc_type, exc_value, traceback): - ... + async def __aexit__(self, exc_type, exc_value, traceback): ... def __float__(self) -> float: return self.progress diff --git a/servo/types/settings.py b/servo/types/settings.py index c7703f5c..9c00aefd 100644 --- a/servo/types/settings.py +++ b/servo/types/settings.py @@ -18,8 +18,10 @@ import functools from inspect import isclass import pydantic +import pydantic_core import pydantic.fields from typing import ( + List, Annotated, Any, Callable, @@ -34,6 +36,8 @@ ) from .core import BaseModel, HumanReadable, Numeric, Unit +from pydantic import Field, ConfigDict +from typing_extensions import Annotated class Setting(BaseModel, abc.ABC): @@ -136,9 +140,7 @@ def human_readable(cls, value: Any) -> str: return str(value) - class Config: - validate_all = True - validate_assignment = True + model_config = ConfigDict(validate_default=True, validate_assignment=True) # Helper methods for working with lists of settings @@ -161,10 +163,9 @@ class EnumSetting(Setting): type: Literal["enum"] = pydantic.Field( "enum", - const=True, description="Identifies the setting as an enumeration setting.", ) - values: pydantic.conlist(Union[str, Numeric], min_items=1) = pydantic.Field( + values: Annotated[List[Union[str, Numeric]], Field(min_length=1)] = pydantic.Field( ..., description="A list of the available options for the value of the setting." ) value: Optional[Union[str, Numeric]] = pydantic.Field( @@ -172,7 +173,7 @@ class EnumSetting(Setting): description="The value of the setting as set by the servo during a measurement or set by the optimizer during an adjustment. When set, must a value in the `values` attribute.", ) - @pydantic.root_validator(skip_on_failure=True) + @pydantic.model_validator(mode="before") @classmethod def _validate_value_in_values(cls, values: dict[str, Any]) -> dict[str, Any]: value, options = values["value"], values["values"] @@ -212,7 +213,7 @@ class RangeSetting(Setting): """ type: Literal["range"] = pydantic.Field( - "range", const=True, description="Identifies the setting as a range setting." + "range", description="Identifies the setting as a range setting." ) min: Numeric = pydantic.Field( ..., @@ -233,7 +234,7 @@ class RangeSetting(Setting): def summary(self) -> str: return f"{self.__class__.__name__}(range=[{self.human_readable(self.min)}..{self.human_readable(self.max)}], step={self.human_readable(self.step)}, unit={self.unit})" - @pydantic.root_validator(skip_on_failure=True) + @pydantic.model_validator(mode="before") @classmethod def _attributes_must_be_of_same_type(cls, values: dict[str, Any]) -> dict[str, Any]: range_types: dict[TypeVar, list[str]] = {} @@ -261,7 +262,7 @@ def _attributes_must_be_of_same_type(cls, values: dict[str, Any]) -> dict[str, A return values - @pydantic.root_validator(skip_on_failure=True) + @pydantic.model_validator(mode="before") @classmethod def _value_must_fall_in_range(cls, values) -> Numeric: value, min, max = values["value"], values["min"], values["max"] @@ -274,7 +275,7 @@ def _value_must_fall_in_range(cls, values) -> Numeric: return values - @pydantic.validator("max") + @pydantic.field_validator("max") @classmethod def _max_must_define_valid_range(cls, max_: Numeric, values) -> Numeric: if not "min" in values: @@ -291,7 +292,7 @@ def _max_must_define_valid_range(cls, max_: Numeric, values) -> Numeric: return max_ - @pydantic.root_validator(skip_on_failure=True) + @pydantic.model_validator(mode="before") @classmethod def _min_and_max_must_be_step_aligned( cls, values: dict[str, Any] @@ -395,8 +396,8 @@ def __init__(self, *args, **kwargs): else: super().__init__(unit=Unit.cores, *args, **kwargs) - name: str = pydantic.Field( - "cpu", const=True, description="Identifies the setting as a CPU setting." + name: Literal["cpu"] = pydantic.Field( + "cpu", description="Identifies the setting as a CPU setting." ) min: float = pydantic.Field( ..., gt=0, description="The inclusive minimum number of vCPUs or cores to run." @@ -430,11 +431,11 @@ def __init__(self, *args, **kwargs): else: super().__init__(unit=Unit.gibibytes, *args, **kwargs) - name: str = pydantic.Field( - "mem", const=True, description="Identifies the setting as a Memory setting." + name: Literal["mem"] = pydantic.Field( + "mem", description="Identifies the setting as a Memory setting." ) - @pydantic.validator("min") + @pydantic.field_validator("min") @classmethod def ensure_min_greater_than_zero(cls, value: Numeric) -> Numeric: if value == 0: @@ -455,9 +456,8 @@ class Replicas(RangeSetting): type derived thereof. """ - name: str = pydantic.Field( + name: Literal["replicas"] = pydantic.Field( "replicas", - const=True, description="Identifies the setting as a replicas setting.", ) min: pydantic.StrictInt = pydantic.Field( @@ -494,9 +494,8 @@ class InstanceType(EnumSetting): type derived thereof. """ - name: str = pydantic.Field( + name: Literal["inst_type"] = pydantic.Field( "inst_type", - const=True, description="Identifies the setting as an instance type enum setting.", ) unit: InstanceTypeUnits = pydantic.Field( @@ -516,7 +515,7 @@ def _suggest_step_aligned_values( in_repr = lambda x: x # declare numeric and textual representations - parser = functools.partial(pydantic.parse_obj_as, value.__class__) + parser = functools.partial(pydantic.TypeAdapter.validate_python, value.__class__) value_dec, step_dec = decimal.Decimal(str(float(value))), decimal.Decimal( str(float(step)) ) @@ -583,12 +582,17 @@ def variable_name(self) -> str: class NumericType(Setting): + @classmethod - def __get_validators__(cls) -> Generator[Callable[..., Any], None, None]: - yield cls.validate + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler + ) -> pydantic_core.CoreSchema: + return pydantic_core.core_schema.no_info_after_validator_function( + cls.validate, handler(Setting) + ) @classmethod - def validate(cls, value, field: pydantic.fields.ModelField = None): + def validate(cls, value): if isclass(value) and issubclass(value, (int, float)): return value @@ -600,8 +604,13 @@ def validate(cls, value, field: pydantic.fields.ModelField = None): raise ValueError(f"Unrecognized numeric type {repr(value)}") @classmethod - def __modify_schema__(cls, field_schema: dict[str, Any]): - field_schema.update(anyOf=["int", "float"]) + def __get_pydantic_json_schema__( + cls, + _core_schema: pydantic_core.core_schema.CoreSchema, + handler: pydantic.GetJsonSchemaHandler, + ) -> pydantic.json_schema.JsonSchemaValue: + _core_schema.update(anyOf=["int", "float"]) + return handler(Setting) class EnvironmentRangeSetting(RangeSetting, EnvironmentSetting): @@ -615,7 +624,7 @@ class EnvironmentRangeSetting(RangeSetting, EnvironmentSetting): None, description="The optional value of the setting as reported by the servo" ) - @pydantic.validator("value_type", pre=True) + @pydantic.field_validator("value_type", mode="before") def _set_value_type_to_type(cls, value: Any) -> Union[Type[int], Type[float]]: if value == "int": return int @@ -623,7 +632,7 @@ def _set_value_type_to_type(cls, value: Any) -> Union[Type[int], Type[float]]: return float return value - @pydantic.root_validator + @pydantic.model_validator(mode="before") def _cast_value_to_value_type(cls, values: dict[Any, Any]) -> dict[Any, Any]: if (value := values.get("value")) is not None and ( value_type := values.get("value_type") @@ -637,8 +646,8 @@ class EnvironmentEnumSetting(EnumSetting, EnvironmentSetting): # https://github.com/samuelcolvin/pydantic/issues/3714 -class EnvironmentSettingList(pydantic.BaseModel): - __root__: list[ +class EnvironmentSettingList(pydantic.RootModel): + root: list[ Annotated[ Union[EnvironmentRangeSetting, EnvironmentEnumSetting], pydantic.Field(discriminator="type"), @@ -647,10 +656,10 @@ class EnvironmentSettingList(pydantic.BaseModel): # above https://pydantic-docs.helpmanual.io/usage/models/#faux-immutability def __iter__(self): - return iter(self.__root__) + return iter(self.root) def __getitem__(self, item): - return self.__root__[item] + return self.root[item] # TODO: revert to this annotation when the above is resolved diff --git a/servo/types/slo.py b/servo/types/slo.py index 8be441b3..ae1b2fb5 100644 --- a/servo/types/slo.py +++ b/servo/types/slo.py @@ -18,14 +18,12 @@ import decimal import enum import pydantic -from typing import cast, Optional +from typing import Annotated, cast, Optional from .core import BaseModel, Numeric -class TriggerConstraints(pydantic.ConstrainedInt): - ge = 1 - multiple_of = 1 +TriggerConstraints = Annotated[int, pydantic.Field(ge=1, multiple_of=1)] class SloKeep(enum.StrEnum): @@ -40,12 +38,18 @@ class SloCondition(BaseModel): threshold_multiplier: decimal.Decimal = decimal.Decimal(1) keep: SloKeep = SloKeep.below trigger_count: TriggerConstraints = cast(TriggerConstraints, 1) - trigger_window: TriggerConstraints = cast(TriggerConstraints, None) - threshold: Optional[decimal.Decimal] - threshold_metric: Optional[str] + trigger_window: TriggerConstraints | None = pydantic.Field( + None, validate_default=True + ) + threshold: Optional[decimal.Decimal] = pydantic.Field(None, validate_default=True) + threshold_metric: Optional[str] = None slo_threshold_minimum: float = 0.25 - @pydantic.root_validator + def __init__(self, *args, **kwargs): + self.model_config["extra"] = "forbid" + super().__init__(*args, **kwargs) + + @pydantic.model_validator(mode="before") @classmethod def _check_threshold_values(cls, values): if ( @@ -63,7 +67,7 @@ def _check_threshold_values(cls, values): return values - @pydantic.root_validator(pre=True) + @pydantic.model_validator(mode="before") @classmethod def _check_duplicated_minimum(cls, values): if ( @@ -80,14 +84,14 @@ def _check_duplicated_minimum(cls, values): return values - @pydantic.validator("trigger_window", pre=True, always=True) + @pydantic.field_validator("trigger_window", mode="before") @classmethod def _trigger_window_defaults_to_trigger_count(cls, v, *, values, **kwargs): if v is None: return values["trigger_count"] return v - @pydantic.root_validator(skip_on_failure=True) + @pydantic.model_validator(mode="before") @classmethod def _trigger_count_cannot_be_greater_than_window(cls, values) -> Numeric: trigger_window, trigger_count = ( @@ -117,14 +121,15 @@ def __str__(self) -> str: def __hash__(self) -> int: return hash(str(self)) - class Config(BaseModel.Config): - extra = pydantic.Extra.forbid - class SloInput(BaseModel): conditions: list[SloCondition] - @pydantic.validator("conditions") + def __init__(self, *args, **kwargs): + self.model_config["extra"] = "forbid" + super().__init__(*args, **kwargs) + + @pydantic.field_validator("conditions") def _conditions_are_unique(cls, value: list[SloCondition]): condition_counts = collections.defaultdict(int) for cond in value: @@ -136,6 +141,3 @@ def _conditions_are_unique(cls, value: list[SloCondition]): f"Slo conditions must be unique. Redundant conditions found: {', '.join(map(lambda nu: nu[0] , non_unique))}" ) return value - - class Config(BaseModel.Config): - extra = pydantic.Extra.forbid diff --git a/servo/utilities/associations.py b/servo/utilities/associations.py index 132528d9..72e481fc 100644 --- a/servo/utilities/associations.py +++ b/servo/utilities/associations.py @@ -76,11 +76,8 @@ def _associations(self) -> dict[str, Any]: class Associative(Protocol): # pragma: no cover """A protocol that describes objects that support associations.""" - def _set_association(self, name: str, obj: Any) -> None: - ... + def _set_association(self, name: str, obj: Any) -> None: ... - def _get_association(self, name: str, default: Any = ...) -> Any: - ... + def _get_association(self, name: str, default: Any = ...) -> Any: ... - def _associations(self) -> dict[str, Any]: - ... + def _associations(self) -> dict[str, Any]: ... diff --git a/servo/utilities/pydantic.py b/servo/utilities/pydantic.py index af433a79..6f54b6fc 100644 --- a/servo/utilities/pydantic.py +++ b/servo/utilities/pydantic.py @@ -46,16 +46,25 @@ def append_pydantic_validator( @contextlib.contextmanager -def extra( - obj: pydantic.BaseModel, extra: pydantic.Extra = pydantic.Extra.allow +def model_config_override( + obj: pydantic.BaseModel, values: dict ) -> Generator[pydantic.BaseModel, None, None]: - """Temporarily override the value of the `extra` setting on a Pydantic model.""" - original = obj.__config__.extra - obj.__config__.extra = extra + """Temporarily override the values on a Pydantic model config.""" + original = obj.model_config.copy() + obj.model_config.update(**values) try: yield obj finally: - obj.__config__.extra = original + obj.model_config = original + + +@contextlib.contextmanager +def extra( + obj: pydantic.BaseModel, extra: str = "allow" +) -> Generator[pydantic.BaseModel, None, None]: + """Temporarily override the value of the `extra` setting on a Pydantic model.""" + with model_config_override(obj, {"extra": extra}): + yield obj @contextlib.contextmanager @@ -63,9 +72,5 @@ def allow_mutation( obj: pydantic.BaseModel, allow_mutation: bool = True ) -> Generator[pydantic.BaseModel, None, None]: """Temporarily override the value of the `allow_mutation` setting on a Pydantic model.""" - original = obj.__config__.allow_mutation - obj.__config__.allow_mutation = allow_mutation - try: + with model_config_override(obj, {"allow_mutation": allow_mutation}): yield obj - finally: - obj.__config__.allow_mutation = original diff --git a/tests/api_test.py b/tests/api_test.py index 625a6157..f2f6b8bc 100644 --- a/tests/api_test.py +++ b/tests/api_test.py @@ -148,7 +148,7 @@ def _check_adjust_parse(obj: CommandResponse): def test_parse_command_response_including_units_control(payload, validator) -> None: from typing import Union - from pydantic import parse_obj_as + from pydantic import TypeAdapter - obj = parse_obj_as(Union[CommandResponse, Status], payload) + obj = TypeAdapter.validate_python(Union[CommandResponse, Status], payload) validator(obj) diff --git a/tests/checks_test.py b/tests/checks_test.py index 168479ba..c8737945 100644 --- a/tests/checks_test.py +++ b/tests/checks_test.py @@ -183,12 +183,10 @@ def check_connectivity(self) -> CheckHandlerResult: return True @check("Verify permissions") - def check_permissions(self) -> None: - ... + def check_permissions(self) -> None: ... @check("Ensure adequate resources") - def check_resources(self) -> None: - ... + def check_resources(self) -> None: ... @pytest.mark.parametrize( @@ -260,29 +258,21 @@ def check_test() -> CheckHandlerResult: class ValidHandlerSignatures: - def check_none(self) -> None: - ... + def check_none(self) -> None: ... - def check_str(self) -> str: - ... + def check_str(self) -> str: ... - def check_bool(self) -> bool: - ... + def check_bool(self) -> bool: ... - def check_tuple(self) -> Tuple[bool, str]: - ... + def check_tuple(self) -> Tuple[bool, str]: ... - def check_union(self) -> Union[str, bool]: - ... + def check_union(self) -> Union[str, bool]: ... - def check_optional(self) -> Optional[str]: - ... + def check_optional(self) -> Optional[str]: ... - def check_union_of_tuple(self) -> Union[str, Tuple[bool, str]]: - ... + def check_union_of_tuple(self) -> Union[str, Tuple[bool, str]]: ... - def check_optional_tuple(self) -> Optional[Tuple[bool, str]]: - ... + def check_optional_tuple(self) -> Optional[Tuple[bool, str]]: ... @pytest.mark.parametrize( @@ -295,23 +285,17 @@ def test_valid_signatures(method) -> None: class InvalidHandlerSignatures: - def check_int(self) -> int: - ... + def check_int(self) -> int: ... - def check_list(self) -> List[Check]: - ... + def check_list(self) -> List[Check]: ... - def check_invalid_tuple(self) -> Tuple[int, str]: - ... + def check_invalid_tuple(self) -> Tuple[int, str]: ... - def check_invalid_optional(self) -> Optional[float]: - ... + def check_invalid_optional(self) -> Optional[float]: ... - def check_invalid_union(self) -> Union[bool, str, float]: - ... + def check_invalid_union(self) -> Union[bool, str, float]: ... - def check_invalid_union_with_tuple(self) -> Union[bool, Tuple[str, float]]: - ... + def check_invalid_union_with_tuple(self) -> Union[bool, Tuple[str, float]]: ... @pytest.mark.parametrize( @@ -330,8 +314,7 @@ def test_decorating_invalid_signatures() -> None: with pytest.raises(TypeError) as e: @check("Test decorator") - def check_test() -> int: - ... + def check_test() -> int: ... assert e assert str(e.value) == ( @@ -343,8 +326,7 @@ def check_test() -> int: @pytest.mark.freeze_time("2020-08-25", auto_tick_seconds=15) def test_check_timer() -> None: @check("Check timer") - def check_test() -> None: - ... + def check_test() -> None: ... check_ = check_test() assert check_ @@ -356,8 +338,7 @@ def check_test() -> None: @pytest.mark.freeze_time("2020-08-25", auto_tick_seconds=15) async def test_decorate_async() -> None: @check("Check async") - async def check_test() -> None: - ... + async def check_test() -> None: ... check_ = await check_test() assert check_ @@ -425,28 +406,22 @@ async def test_run_check_by_id() -> None: class FilterableChecks(BaseChecks): @check("name-only") - def check_one(self) -> None: - ... + def check_one(self) -> None: ... @check("name-and-id", id="explicit-id") - def check_two(self) -> None: - ... + def check_two(self) -> None: ... @check("name-and-tags", tags=["one", "two"]) - def check_three(self) -> None: - ... + def check_three(self) -> None: ... @check("name-and-identicial-tags", tags=["one", "two"]) - def check_four(self) -> None: - ... + def check_four(self) -> None: ... @check("name-and-exclusive-tags", tags=["three", "four"]) - def check_five(self) -> None: - ... + def check_five(self) -> None: ... @check("name-and-intersecting-tags", tags=["one", "four"]) - def check_six(self) -> None: - ... + def check_six(self) -> None: ... @pytest.mark.parametrize( @@ -493,12 +468,10 @@ async def test_filtering(name, id, tags, expected_ids) -> None: class RequirementChecks(BaseChecks): @check("required-1", severity=ErrorSeverity.critical) - def check_one(self) -> None: - ... + def check_one(self) -> None: ... @check("not-required-1") - def check_two(self) -> None: - ... + def check_two(self) -> None: ... @check("not-required-2") def check_three(self) -> None: @@ -509,12 +482,10 @@ def check_four(self) -> None: raise RuntimeError("fail check") @require("required-3") - def check_five(self) -> None: - ... + def check_five(self) -> None: ... @check("not-required-3") - def check_six(self) -> None: - ... + def check_six(self) -> None: ... @pytest.mark.parametrize( @@ -602,15 +573,13 @@ async def test_running_requirements(name, halt_on, expected_results) -> None: class MixedChecks(BaseChecks): @check("one") - def check_one(self) -> None: - ... + def check_one(self) -> None: ... def check_two(self) -> Check: return Check(name="two", success=True) @check("three") - def check_three(self) -> None: - ... + def check_three(self) -> None: ... def check_four(self) -> Check: return Check(name="four", success=True) @@ -805,8 +774,7 @@ def test_multicheck_invalid_args() -> None: class BadArgs(BaseChecks): @multicheck("Check something") - def check_invalid(self, foo: int) -> int: - ... + def check_invalid(self, foo: int) -> int: ... assert e is not None assert ( @@ -859,8 +827,7 @@ async def test_invalid_multichecks() -> None: async def test_handles_method_attrs() -> None: class Other: - def test(self): - ... + def test(self): ... class MethodAttrsCheck(BaseChecks): other: Callable[..., None] diff --git a/tests/connectors/kubernetes_test.py b/tests/connectors/kubernetes_test.py index cf389cae..a150554c 100644 --- a/tests/connectors/kubernetes_test.py +++ b/tests/connectors/kubernetes_test.py @@ -40,6 +40,7 @@ ContainerTagNameField, DefaultOptimizationStrategyConfiguration, DeploymentConfiguration, + DNS_SUBDOMAIN_NAME_REGEX, DNSLabelName, DNSLabelNameField, DNSSubdomainName, @@ -71,7 +72,7 @@ class TestDNSSubdomainName: @pytest.fixture def model(self) -> Type[BaseModel]: class Model(BaseModel): - name: Annotated[str, DNSSubdomainName()] + name: DNSSubdomainName return Model @@ -123,10 +124,10 @@ def test_can_only_contain_alphanumerics_hyphens_and_dots(self, model) -> None: assert e assert { "loc": ("name",), - "msg": f'string does not match regex "{DNSSubdomainName().regex}"', + "msg": f'string does not match regex "{DNS_SUBDOMAIN_NAME_REGEX}"', "type": "value_error.str.regex", "ctx": { - "pattern": DNSSubdomainName().regex, + "pattern": DNS_SUBDOMAIN_NAME_REGEX, }, } in e.value.errors() @@ -140,10 +141,10 @@ def test_must_start_with_alphanumeric_character(self, model) -> None: assert e assert { "loc": ("name",), - "msg": f'string does not match regex "{DNSSubdomainName().regex}"', + "msg": f'string does not match regex "{DNS_SUBDOMAIN_NAME_REGEX}"', "type": "value_error.str.regex", "ctx": { - "pattern": DNSSubdomainName().regex, + "pattern": DNS_SUBDOMAIN_NAME_REGEX, }, } in e.value.errors() @@ -157,10 +158,10 @@ def test_must_end_with_alphanumeric_character(self, model) -> None: assert e assert { "loc": ("name",), - "msg": f'string does not match regex "{DNSSubdomainName().regex}"', + "msg": f'string does not match regex "{DNS_SUBDOMAIN_NAME_REGEX}"', "type": "value_error.str.regex", "ctx": { - "pattern": DNSSubdomainName().regex, + "pattern": DNS_SUBDOMAIN_NAME_REGEX, }, } in e.value.errors() @@ -452,8 +453,7 @@ class TestContainerConfiguration: class TestDeploymentConfiguration: - def test_inheritance_of_default_namespace(self) -> None: - ... + def test_inheritance_of_default_namespace(self) -> None: ... def test_strategy_enum(self) -> None: config = DeploymentConfiguration( diff --git a/tests/connectors/opsani_dev_test.py b/tests/connectors/opsani_dev_test.py index 19da3577..c50dc832 100644 --- a/tests/connectors/opsani_dev_test.py +++ b/tests/connectors/opsani_dev_test.py @@ -532,9 +532,9 @@ async def test_no_tuning_process( ) servo.logger.info("waiting for Prometheus to scrape our Pods") - async def wait_for_targets_to_be_scraped() -> List[ - servo.connectors.prometheus.ActiveTarget - ]: + async def wait_for_targets_to_be_scraped() -> ( + List[servo.connectors.prometheus.ActiveTarget] + ): servo.logger.info(f"Waiting for Prometheus scrape Pod targets...") # NOTE: Prometheus is on a 5s scrape interval scraped_since = pytz.utc.localize(datetime.datetime.now()) @@ -1012,9 +1012,9 @@ async def test_process( ) servo.logger.info("waiting for Prometheus to scrape our Pods") - async def wait_for_targets_to_be_scraped() -> List[ - servo.connectors.prometheus.ActiveTarget - ]: + async def wait_for_targets_to_be_scraped() -> ( + List[servo.connectors.prometheus.ActiveTarget] + ): servo.logger.info(f"Waiting for Prometheus scrape Pod targets...") # NOTE: Prometheus is on a 5s scrape interval scraped_since = pytz.utc.localize(datetime.datetime.now()) diff --git a/tests/integration/pubsub_test.py b/tests/integration/pubsub_test.py index f084da4a..221c73c1 100644 --- a/tests/integration/pubsub_test.py +++ b/tests/integration/pubsub_test.py @@ -50,8 +50,6 @@ async def _subscribe_to_vegeta() -> None: task.cancel() assert len(reports) > 5 - async def test_subscribe_via_connector(self, connector) -> None: - ... + async def test_subscribe_via_connector(self, connector) -> None: ... - async def test_subscribe_via_servo(self, connector) -> None: - ... + async def test_subscribe_via_servo(self, connector) -> None: ... diff --git a/tests/kubernetes_test.py b/tests/kubernetes_test.py index 05416ac2..6f1fd4af 100644 --- a/tests/kubernetes_test.py +++ b/tests/kubernetes_test.py @@ -119,12 +119,10 @@ def test_deploy_servo_fiberhttp_vegeta_adjust() -> None: # Integration test k8s describe, adjust -def test_generate_outputs_human_readable_config() -> None: - ... +def test_generate_outputs_human_readable_config() -> None: ... -def test_supports_nil_container_name() -> None: - ... +def test_supports_nil_container_name() -> None: ... @pytest.mark.applymanifests("manifests", files=["fiber-http.yaml"]) diff --git a/tests/pubsub_test.py b/tests/pubsub_test.py index e8b1e865..114fa251 100644 --- a/tests/pubsub_test.py +++ b/tests/pubsub_test.py @@ -1044,8 +1044,7 @@ async def _aggregate_metrics( aggregator: servo.pubsub.Aggregator, message: servo.pubsub.Message, channel: servo.pubsub.Channel, - ) -> None: - ... + ) -> None: ... aggregator = servo.pubsub.Aggregator( from_channels=[prometheus_metrics, cloudwatch_metrics], @@ -1237,8 +1236,7 @@ async def test_create_subscriber(self, exchange: servo.pubsub.Exchange) -> None: async def test_create_subscriber_with_dependency( self, exchange: servo.pubsub.Exchange ) -> None: - async def _dependency() -> None: - ... + async def _dependency() -> None: ... exchange.start() subscriber = exchange.create_subscriber("whatever", until_done=_dependency()) diff --git a/tests/servo_test.py b/tests/servo_test.py index bc4ba90b..62b263ca 100644 --- a/tests/servo_test.py +++ b/tests/servo_test.py @@ -9,6 +9,7 @@ from devtools import debug import httpx +import pydantic import pytest import respx import yaml @@ -98,9 +99,7 @@ def handle_startup(self) -> None: def handle_shutdown(self) -> None: pass - class Config: - # NOTE: Necessary to utilize mocking - extra = Extra.allow + model_config = pydantic.ConfigDict(extra="allow") class SecondTestServoConnector(BaseConnector): @@ -450,8 +449,7 @@ async def test_dispatching_event_that_doesnt_exist(mocker, servo: Servo) -> None # Test event handlers -async def test_event(): - ... +async def test_event(): ... def test_creating_event_programmatically(random_string: str) -> None: @@ -1758,9 +1756,11 @@ def test_connectors_allows_dict_with_explicit_map_to_default_name(self): ) assert s.connectors == {"vegeta": "VegetaConnector"} - def test_connectors_allows_dict_with_explicit_map_to_default_class(self): + def test_connectors_allows_dict_with_explicit_map_to_default_class( + self, optimizer: OpsaniOptimizer + ): s = BaseServoConfiguration( - connectors={"vegeta": VegetaConnector}, + connectors={"vegeta": VegetaConnector}, optimizer=optimizer ) assert s.connectors == {"vegeta": "VegetaConnector"} diff --git a/tests/types/core_test.py b/tests/types/core_test.py index 07fe653d..ed2485b8 100644 --- a/tests/types/core_test.py +++ b/tests/types/core_test.py @@ -166,33 +166,24 @@ async def test_timeout(self, progress: EventProgress) -> None: assert progress.finished assert not progress.completed - async def test_grace_time(self) -> None: - ... + async def test_grace_time(self) -> None: ... - async def test_start_when_already_started(self) -> None: - ... + async def test_start_when_already_started(self) -> None: ... - async def test_started(self) -> None: - ... + async def test_started(self) -> None: ... - async def test_elapsed_is_none_when_not_started(self) -> None: - ... + async def test_elapsed_is_none_when_not_started(self) -> None: ... - async def test_elapsed_is_duration_when_started(self) -> None: - ... + async def test_elapsed_is_duration_when_started(self) -> None: ... - async def test_goes_to_100_if_gracetime_is_none(self) -> None: - ... + async def test_goes_to_100_if_gracetime_is_none(self) -> None: ... # TODO: Should this just start the count instead? - async def test_goes_to_50_if_gracetime_is_not_none(self) -> None: - ... + async def test_goes_to_50_if_gracetime_is_not_none(self) -> None: ... - async def test_reset_during_gracetime_sets_progress_back_to_zero(self) -> None: - ... + async def test_reset_during_gracetime_sets_progress_back_to_zero(self) -> None: ... - async def test_gracetime_expires_sets_progress_to_finished(self) -> None: - ... + async def test_gracetime_expires_sets_progress_to_finished(self) -> None: ... class TestTimeSeries: diff --git a/tests/types/settings_test.py b/tests/types/settings_test.py index 5e141dfc..da2f4f90 100644 --- a/tests/types/settings_test.py +++ b/tests/types/settings_test.py @@ -174,7 +174,7 @@ def test_validate_values_list_is_not_empty(self) -> None: assert error assert "1 validation error for EnumSetting" in str(error.value) assert error.value.errors()[0]["loc"] == ("values",) - assert error.value.errors()[0]["type"] == "value_error.list.min_items" + assert error.value.errors()[0]["type"] == "value_error.list.min_length" assert ( error.value.errors()[0]["msg"] == "ensure this value has at least 1 items" ) diff --git a/tests/utilities/inspect_test.py b/tests/utilities/inspect_test.py index 6e825402..b8b8af88 100644 --- a/tests/utilities/inspect_test.py +++ b/tests/utilities/inspect_test.py @@ -9,27 +9,21 @@ class OneClass: - def one(self) -> None: - ... + def one(self) -> None: ... - def two(self) -> None: - ... + def two(self) -> None: ... - def three(self) -> None: - ... + def three(self) -> None: ... class TwoClass(OneClass): - def four(self) -> None: - ... + def four(self) -> None: ... - def five(self) -> None: - ... + def five(self) -> None: ... class ThreeClass(TwoClass): - def six(self) -> None: - ... + def six(self) -> None: ... @pytest.mark.parametrize( @@ -69,8 +63,7 @@ def test_get_instance_methods_returns_bound_methods_if_possible() -> None: def test_get_instance_methods_returns_finds_dynamic_instance_methods() -> None: - def seven() -> None: - ... + def seven() -> None: ... instance = ThreeClass() instance.seven = types.MethodType(seven, instance) @@ -106,11 +99,9 @@ class FourClass(ThreeClass): def test_resolution_none() -> None: - def test_type() -> None: - ... + def test_type() -> None: ... - def test_str() -> "None": - ... + def test_str() -> "None": ... res_type, res_str = servo.utilities.inspect.resolve_type_annotations( inspect.Signature.from_callable(test_type).return_annotation, @@ -120,11 +111,9 @@ def test_str() -> "None": def test_resolution_none() -> None: - def test_type() -> None: - ... + def test_type() -> None: ... - def test_str() -> "None": - ... + def test_str() -> "None": ... res_type, res_str = servo.utilities.inspect.resolve_type_annotations( inspect.Signature.from_callable(test_type).return_annotation, @@ -139,23 +128,17 @@ def test_aliased_types() -> None: from servo import types from servo.types import Duration - def test_type_path() -> servo.types.Duration: - ... + def test_type_path() -> servo.types.Duration: ... - def test_type_abbr() -> types.Duration: - ... + def test_type_abbr() -> types.Duration: ... - def test_type() -> Duration: - ... + def test_type() -> Duration: ... - def test_str_path() -> "servo.types.Duration": - ... + def test_str_path() -> "servo.types.Duration": ... - def test_str_abbr() -> "types.Duration": - ... + def test_str_abbr() -> "types.Duration": ... - def test_str() -> "Duration": - ... + def test_str() -> "Duration": ... resolved = servo.utilities.inspect.resolve_type_annotations( inspect.Signature.from_callable(test_type_path).return_annotation, @@ -187,17 +170,13 @@ def test_equal_callable_descriptors() -> None: import servo import servo.types - def test_one() -> typing.Dict: - ... + def test_one() -> typing.Dict: ... - def test_two() -> typing.Dict[str, Any]: - ... + def test_two() -> typing.Dict[str, Any]: ... - def test_three() -> typing.Dict[str, int]: - ... + def test_three() -> typing.Dict[str, int]: ... - def test_four() -> typing.Dict[float, str]: - ... + def test_four() -> typing.Dict[float, str]: ... sig1 = inspect.Signature.from_callable(test_one) sig2 = inspect.Signature.from_callable(test_two) From d9c5cb9f416280e97b58dc0796c86d9a8a81cf64 Mon Sep 17 00:00:00 2001 From: Linkous Sharp Date: Tue, 14 May 2024 09:56:58 -0500 Subject: [PATCH 4/8] Fix setting docstring on variable error --- servo/types/core.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/servo/types/core.py b/servo/types/core.py index bad06769..3efd16d5 100644 --- a/servo/types/core.py +++ b/servo/types/core.py @@ -26,6 +26,7 @@ import operator from typing import ( Any, + Annotated, AsyncIterator, Awaitable, Callable, @@ -83,13 +84,16 @@ def default_handler(obj) -> Any: raise err -BASE_MODEL_CONFIG = { - "validate_assignment": True, -} -BASE_MODEL_CONFIG.__doc__ = """ +BaseModelConfigDict = Annotated[ + pydantic.ConfigDict, + """ The `BaseModelConfig` class provides a common set of Pydantic model configuration shared across the library. -""" +""", +] +BASE_MODEL_CONFIG: BaseModelConfigDict = { + "validate_assignment": True, +} class BaseModel(pydantic.BaseModel): From a67506770e81bb7a00262285a6ee3d652fecc759 Mon Sep 17 00:00:00 2001 From: Linkous Sharp Date: Fri, 24 May 2024 13:00:35 -0500 Subject: [PATCH 5/8] Unit tests of servo_test.py now passing --- servo/api.py | 4 +- servo/assembly.py | 29 +- servo/configuration.py | 68 +- servo/connector.py | 19 +- servo/connectors/kubernetes.py | 11 +- servo/connectors/opsani_dev.py | 7 +- servo/connectors/vegeta.py | 33 +- servo/events.py | 4 +- servo/pubsub.py | 9 +- servo/servo.py | 34 +- servo/telemetry.py | 2 +- servo/types/api.py | 5 +- servo/types/core.py | 20 +- servo/types/settings.py | 8 +- servo/types/slo.py | 10 +- servo/utilities/pydantic.py | 8 +- tests/api_test.py | 2 +- tests/conftest.py | 6 + tests/connector_test.py | 27 + tests/connectors/kubernetes_test.py | 2 +- tests/connectors/prometheus_test.py | 50 +- tests/helpers.py | 1 + tests/servo_test.py | 1605 ++++++++++++--------------- 23 files changed, 902 insertions(+), 1062 deletions(-) diff --git a/servo/api.py b/servo/api.py index e8785a98..048bf5fa 100644 --- a/servo/api.py +++ b/servo/api.py @@ -295,7 +295,7 @@ def get_api_client_for_optimizer( if "Bearer" not in auth_header_value: auth_header_value = f"Bearer {auth_header_value}" return httpx.AsyncClient( - base_url=optimizer.url, + base_url=str(optimizer.url), headers={ "Authorization": auth_header_value, "User-Agent": user_agent(), @@ -307,7 +307,7 @@ def get_api_client_for_optimizer( ) elif isinstance(optimizer, servo.configuration.AppdynamicsOptimizer): api_client = AsyncOAuth2Client( - base_url=optimizer.url, + base_url=str(optimizer.url), headers={ "User-Agent": user_agent(), "Content-Type": "application/json", diff --git a/servo/assembly.py b/servo/assembly.py index 4a41f4c9..8e386adf 100644 --- a/servo/assembly.py +++ b/servo/assembly.py @@ -24,6 +24,7 @@ import pydantic import pydantic.json +import pydantic_settings import yaml import servo.configuration @@ -115,7 +116,7 @@ async def assemble( # TODO: Needs to be public / have a better name # TODO: We need to index the env vars here for multi-servo servo_config_model, routes = _create_config_model(config=config, env=env) - servo_config = servo_config_model.parse_obj(config) + servo_config = servo_config_model.model_validate(config) telemetry = servo.telemetry.Telemetry() @@ -290,28 +291,32 @@ def _create_config_model_from_routes( for name, connector_class in routes.items(): config_model = _derive_config_model_for_route(name, connector_class) - config_model.__config__.title = ( + config_model.model_config["title"] = ( f"{connector_class.full_name} Settings (named {name})" ) - setting_fields[name] = (config_model, default_value) + setting_fields[name] = (Optional[config_model], default_value) connector_versions[connector_class] = ( f"{connector_class.full_name} v{connector_class.version}" ) # Create our model - servo_config_model = pydantic.create_model( - "ServoConfiguration", - __base__=servo.configuration.BaseServoConfiguration, - **setting_fields, + servo_config_model: servo.configuration.BaseServoConfiguration = ( + pydantic.create_model( + "ServoConfiguration", + __base__=servo.configuration.BaseServoConfiguration, + **setting_fields, + ) ) connectors_series = servo.utilities.join_to_series( list(connector_versions.values()) ) - servo_config_model.__config__.title = "Servo Configuration Schema" - servo_config_model.__config__.schema_extra = { + servo_config_model.model_config["title"] = "Servo Configuration Schema" + servo_config_model.model_config["json_schema_extra"] = { "description": f"Schema for configuration of Servo v{servo.Servo.version} with {connectors_series}" } + servo_config_model.model_config["env_nested_delimiter"] = "_" + servo_config_model.model_config["extra"] = "ignore" return servo_config_model @@ -337,6 +342,7 @@ def _create_config_model( routes = servo.connector._routes_for_connectors_descriptor(connectors_value) require_fields = True + print(f"require_fields {require_fields}") servo_config_model = _create_config_model_from_routes( routes, require_fields=require_fields ) @@ -413,9 +419,4 @@ def _derive_config_model_for_route( ) __config_models_cache__.append(cache_entry) - # Traverse across all the fields and update the env vars - for field_name, field in config_model.__fields__.items(): - field.field_info.extra.pop("env", None) - field.field_info.extra["env_names"] = {f"SERVO_{name}_{field_name}".upper()} - return config_model diff --git a/servo/configuration.py b/servo/configuration.py index 0b71d5d2..3335fa84 100644 --- a/servo/configuration.py +++ b/servo/configuration.py @@ -23,6 +23,8 @@ import pathlib import re from typing import Any, Callable, Dict, List, Optional, Type, Union +import typing +import httpx import pydantic.json import pydantic_core from typing_extensions import Annotated, TypeAlias @@ -138,7 +140,6 @@ def name(self) -> str: # TODO[pydantic]: The following keys were removed: `fields`. # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-config for more information. model_config = pydantic_settings.SettingsConfigDict( - case_sensitive=True, extra="forbid", validate_assignment=True, env_prefix="appd_", @@ -161,7 +162,9 @@ class OpsaniOptimizer(pydantic_settings.BaseSettings): and automated testing to bind the servo to a fixed URL. """ - id: Annotated[str, StringConstraints(pattern=OPTIMIZER_ID_REGEX)] + id: Annotated[str, StringConstraints(pattern=OPTIMIZER_ID_REGEX)] = pydantic.Field( + alias="opsani_optimizer" + ) token: pydantic.SecretStr base_url: pydantic.AnyHttpUrl = "https://api.opsani.com" url: Optional[pydantic.AnyHttpUrl] = None @@ -174,6 +177,10 @@ def __init__(self, *args, **kwargs) -> None: token_file := os.environ.get("OPSANI_TOKEN_FILE") ): kwargs["token"] = pathlib.Path(token_file).read_text().strip() + + # alias will supress attempts to init with acutal property names. support this manually + if (id_arg := kwargs.pop("id", None)) is not None: + kwargs["opsani_optimizer"] = id_arg super().__init__(*args, **kwargs) organization, name = self.id.split("/") @@ -286,9 +293,9 @@ def __init_subclass__(cls, **kwargs): cls.model_config["title"] = f"{base_name} Connector Configuration Schema" # Default prefix - prefix = cls.model_config.get("env_prefix", "") - if prefix == "": - prefix = re.sub(r"(? Optional[int]: return (self.get(context, None) or self.get(BackoffContexts.default)).max_tries +def ensure_valid_url_and_str(v: Any): + if isinstance(v, str): + return str(pydantic.AnyHttpUrl(v)) + + return v + + +# Ensure URL is valid but keep url as string for downstream libraries +ValidProxy = typing.Annotated[ + Union[str, httpx.Proxy], + pydantic.functional_validators.AfterValidator(ensure_valid_url_and_str), +] + + class CommonConfiguration(AbstractBaseConfiguration): """CommonConfiguration models configuration for the Servo connector and establishes default settings for shared services such as networking and logging. @@ -481,7 +502,7 @@ class CommonConfiguration(AbstractBaseConfiguration): See https://github.com/litl/backoff """ - proxies: Union[None, ProxyKey, Dict[ProxyKey, Optional[pydantic.AnyHttpUrl]]] = None + proxies: Union[None, ProxyKey, Dict[ProxyKey, Optional[ValidProxy]]] = None """Proxy configuration for the HTTPX library, which provides HTTP networking capabilities to the servo. @@ -516,10 +537,6 @@ def parse_timeouts(cls, v): def generate(cls, **kwargs) -> Optional["CommonConfiguration"]: return None - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.model_config["validate_assignment"] = True - class ChecksConfiguration(AbstractBaseConfiguration): """ChecksConfiguration models configuration for behavior of the checks flow, such as @@ -579,12 +596,10 @@ class ChecksConfiguration(AbstractBaseConfiguration): def generate(cls, **kwargs) -> Optional["ChecksConfiguration"]: return None - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.model_config["validate_assignment"] = True - -class BaseServoConfiguration(AbstractBaseConfiguration, abc.ABC): +class BaseServoConfiguration( + AbstractBaseConfiguration, abc.ABC, pydantic_settings.BaseSettings +): """ Abstract base class for Servo instances. @@ -635,14 +650,6 @@ class BaseServoConfiguration(AbstractBaseConfiguration, abc.ABC): description="Configuration of Checks behavior", ) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # If optimizer hasn't failed validation then it was set by environment variables. - # Explicitly assign it so that its included in pydantic's __fields_set__ - # Ideally we could just set include=True on the Field but that doesn't seem to override exclude_unset - self.optimizer = self.optimizer - @classmethod def generate( cls: Type["BaseServoConfiguration"], **kwargs @@ -703,11 +710,12 @@ def validate_connectors( return connectors - def __init__(self, *args, **kwargs): - self.model_config["extra"] = "forbid" - self.model_config["title"] = "Abstract Servo Configuration Schema" - self.model_config["env_prefix"] = "SERVO_" - super().__init__(*args, **kwargs) + model_config = pydantic_settings.SettingsConfigDict( + extra="forbid", + validate_assignment=False, + title="Abstract Servo Configuration Schema", + env_prefix="servo_", + ) class FastFailConfiguration(pydantic_settings.BaseSettings): diff --git a/servo/connector.py b/servo/connector.py index 65b0f13e..21f24ecd 100644 --- a/servo/connector.py +++ b/servo/connector.py @@ -88,9 +88,12 @@ class BaseConnector( ## # Connector metadata + _name: ClassVar[str] = None + """Name of the connector, as derived from the class name. + """ name: str = None - """Name of the connector, by default derived from the class name. + """Name of the connector as set by the route, by default derived from the class name. """ full_name: ClassVar[str] = None @@ -160,7 +163,7 @@ def optimizer( @pydantic.model_validator(mode="before") @classmethod def _validate_metadata(cls, v): - assert cls.name is not None, "name must be provided" + assert cls._name is not None, "name must be provided" assert cls.version is not None, "version must be provided" if isinstance(cls.version, str): # Attempt to parse @@ -178,7 +181,7 @@ def _validate_metadata(cls, v): ) return v - @pydantic.field_validator("name") + @pydantic.field_validator("name", check_fields=False) @classmethod def _validate_name(cls, v): assert bool( @@ -218,7 +221,7 @@ def __init_subclass__(cls: Type["BaseConnector"], **kwargs) -> None: # noqa: D1 _connector_subclasses.add(cls) - cls.name = cls.__name__.replace("Connector", "") + cls._name = cls.__name__.replace("Connector", "") cls.full_name = cls.__name__.replace("Connector", " Connector") cls.version = Version.parse("0.0.0") cls.__default_name__ = _name_for_connector_class(cls) @@ -230,6 +233,8 @@ def __init__( **kwargs, ) -> None: # noqa: D107 name = name if name is not None else self.__class__.__default_name__ + if name is None: + name = self.__class__._name super().__init__( *args, name=name, @@ -284,9 +289,9 @@ def decorator(cls): raise ValueError( f"Connector names given as tuples must contain exactly 2 elements: full name and alias" ) - cls.name, cls.__default_name__ = name + cls._name, cls.__default_name__ = name else: - cls.name = name + cls._name = name if description: cls.description = description if version: @@ -316,7 +321,7 @@ def decorator(cls): def _name_for_connector_class(cls: Type[BaseConnector]) -> Optional[str]: - for name in (cls.name, cls.__name__): + for name in (cls._name, cls.__name__): if not name: continue name = re.sub(r"Connector$", "", name) diff --git a/servo/connectors/kubernetes.py b/servo/connectors/kubernetes.py index e0624ae4..f4a4f611 100644 --- a/servo/connectors/kubernetes.py +++ b/servo/connectors/kubernetes.py @@ -1951,13 +1951,12 @@ def workloads( ) -> list[Union[StatefulSetConfiguration, DeploymentConfiguration]]: return (self.deployments or []) + (self.stateful_sets or []) - @pydantic.model_validator(mode="before") - def check_workload(cls, values): - if (not values.get("deployments")) and ( - not values.get("rollouts") and (not values.get("stateful_sets")) - ): + @pydantic.model_validator(mode="after") + def check_workload(self): + if self.deployments or self.stateful_sets: + return self + else: raise ValueError("No optimization target(s) were specified") - return values @classmethod def generate(cls, **kwargs) -> "KubernetesConfiguration": diff --git a/servo/connectors/opsani_dev.py b/servo/connectors/opsani_dev.py index 02e93e9a..0cd03f40 100644 --- a/servo/connectors/opsani_dev.py +++ b/servo/connectors/opsani_dev.py @@ -118,12 +118,7 @@ class OpsaniDevConfiguration(servo.BaseConfiguration): description="Disable to prevent a canary strategy", ) - def __init__(self, *args, **kwargs): - self.model_config = pydantic_settings.SettingsConfigDict( - **servo.AbstractBaseConfiguration.model_config, - allow_population_by_field_name=True, - ) - super().__init__() + model_config = pydantic_settings.SettingsConfigDict(populate_by_name=True) @classmethod def generate(cls, **kwargs) -> "OpsaniDevConfiguration": diff --git a/servo/connectors/vegeta.py b/servo/connectors/vegeta.py index e32dae4e..f9750a62 100644 --- a/servo/connectors/vegeta.py +++ b/servo/connectors/vegeta.py @@ -24,6 +24,7 @@ import devtools import jsonschema import pydantic +import pydantic_settings import servo from pydantic import ConfigDict @@ -121,10 +122,12 @@ def format_value(self, fmt: TargetFormat) -> str: return fmt.value() target: Optional[str] = pydantic.Field( - description="Specifies a single formatted Vegeta target to load. See the format option to learn about available target formats. This option is exclusive of the targets option and will provide a target to Vegeta via stdin." + None, + description="Specifies a single formatted Vegeta target to load. See the format option to learn about available target formats. This option is exclusive of the targets option and will provide a target to Vegeta via stdin.", ) targets: Optional[pydantic.FilePath] = pydantic.Field( - description="Specifies the file from which to read targets. See the format option to learn about available target formats. This option is exclusive of the target option and will provide targets to via through a file on disk." + None, + description="Specifies the file from which to read targets. See the format option to learn about available target formats. This option is exclusive of the target option and will provide targets to via through a file on disk.", ) connections: int = pydantic.Field( 10000, @@ -167,17 +170,27 @@ def duration(self) -> Optional[servo.Duration]: else: return None - @pydantic.model_validator(mode="before") - @classmethod - def validate_target(cls, values: Dict[str, Any]) -> Dict[str, Any]: - target, targets = servo.values_for_keys(values, ("target", "targets")) - if target is None and targets is None: + @pydantic.model_validator(mode="after") + def validate_target(self) -> Dict[str, Any]: + if self.target is None and self.targets is None: raise ValueError("target or targets must be configured") - if target and targets: + if self.target and self.targets: raise ValueError("target and targets cannot both be configured") - return values + return self + + # @pydantic.model_validator(mode="before") + # @classmethod + # def validate_target(cls, values: Dict[str, Any]) -> Dict[str, Any]: + # target, targets = servo.values_for_keys(values, ("target", "targets")) + # if target is None and targets is None: + # raise ValueError("target or targets must be configured") + + # if target and targets: + # raise ValueError("target and targets cannot both be configured") + + # return values @staticmethod def target_json_schema() -> Dict[str, Any]: @@ -245,7 +258,7 @@ def validate_target_format(cls, value: str, info: pydantic.ValidationInfo) -> st return value - @pydantic.field_validator("rate") + @pydantic.field_validator("rate", mode="before") @classmethod def validate_rate(cls, v: Union[int, str]) -> str: assert isinstance( diff --git a/servo/events.py b/servo/events.py index 60860c4b..d9b7027e 100644 --- a/servo/events.py +++ b/servo/events.py @@ -615,7 +615,7 @@ def __after_handler(self, results: List[EventResult]) -> None: # https://github.com/pydantic/pydantic/issues/5124#issuecomment-1449653294 class Metaclass(type(pydantic.BaseModel)): - def __new__(mcs, name, bases, namespace, **kwargs): + def __new__(mcs, cls_name, bases, namespace, *args, **kwargs): # Decorate the class with an event registry, inheriting from our parent connectors event_handlers: List[EventHandler] = [] @@ -628,7 +628,7 @@ def __new__(mcs, name, bases, namespace, **kwargs): **{n: v for n, v in namespace.items()}, } - cls = super().__new__(mcs, name, bases, new_namespace, **kwargs) + cls = super().__new__(mcs, cls_name, bases, new_namespace, *args, **kwargs) return cls diff --git a/servo/pubsub.py b/servo/pubsub.py index f6d41690..5d20e0fc 100644 --- a/servo/pubsub.py +++ b/servo/pubsub.py @@ -1446,10 +1446,11 @@ class Mixin(pydantic.BaseModel): pubsub_exchange: The pub/sub Exchange that the object belongs to. """ - __private_attributes__ = { - "_publishers_map": pydantic.PrivateAttr({}), - "_subscribers_map": pydantic.PrivateAttr({}), - } + _publishers_map: dict[str, tuple[Publisher, asyncio.Task]] = pydantic.PrivateAttr( + {} + ) + _subscribers_map: dict[str, Subscriber] = pydantic.PrivateAttr({}) + pubsub_exchange: Exchange = pydantic.Field(default_factory=Exchange) def __init__(self, *args, **kwargs) -> None: diff --git a/servo/servo.py b/servo/servo.py index 5643be17..7f314e60 100644 --- a/servo/servo.py +++ b/servo/servo.py @@ -252,10 +252,24 @@ def __init__( @pydantic.model_validator(mode="before") def _initialize_name(cls, values: dict[str, Any]) -> dict[str, Any]: - if values["name"] == "servo" and values.get("config"): - values["name"] = values["config"].name or getattr( - values["config"].optimizer, "id", "servo" - ) + if ( + values["name"] == "servo" + and (servo_config := values.get("config", None)) is not None + ): + if isinstance(servo_config, dict): + values["name"] = servo_config.get("name", None) or getattr( + servo_config["optimizer"], "id", "servo" + ) + elif issubclass( + servo_config.__class__, servo.configuration.BaseServoConfiguration + ): + values["name"] = values["config"].name or getattr( + values["config"].optimizer, "id", "servo" + ) + else: + raise pydantic.ValidationError( + f"Unable to derive name from config of type {servo_config.__class__}" + ) return values @@ -598,13 +612,13 @@ async def _post_event( event=event, param=param, servo_uid=self.config.servo_uid ) self.logger.trace( - f"POST event request: {devtools.pformat(event_request.json())}" + f"POST event request: {devtools.pformat(event_request.model_dump_json())}" ) try: try: response = await self._api_client.post( - "servo", data=event_request.json() + "servo", data=event_request.model_dump_json() ) except RuntimeError as e: if "the handler is closed" in str(e): @@ -615,7 +629,7 @@ async def _post_event( self.config.optimizer, self.config.settings ) response = await self._api_client.post( - "servo", data=event_request.json() + "servo", data=event_request.model_dump_json() ) else: raise @@ -627,9 +641,9 @@ async def _post_event( ) self.logger.trace(servo.api.redacted_to_curl(response.request)) - return pydantic.parse_obj_as( - Union[servo.api.CommandResponse, servo.api.Status], response_json - ) + return pydantic.TypeAdapter( + Union[servo.api.CommandResponse, servo.api.Status] + ).validate_python(response_json) except httpx.HTTPError as error: if hasattr(error, "response"): diff --git a/servo/telemetry.py b/servo/telemetry.py index adb7e916..debe108d 100644 --- a/servo/telemetry.py +++ b/servo/telemetry.py @@ -201,7 +201,7 @@ async def _diagnostics_api( ) self.logger.trace(servo.api.redacted_to_curl(response.request)) try: - return pydantic.parse_obj_as(output_model, response_json) + return pydantic.TypeAdapter(output_model).validate_python(response_json) except pydantic.ValidationError as error: # Should not raise due to improperly set diagnostic states self.logger.exception( diff --git a/servo/types/api.py b/servo/types/api.py index 0ef0c857..08c7c73e 100644 --- a/servo/types/api.py +++ b/servo/types/api.py @@ -19,6 +19,8 @@ import time from typing import Any, Optional, Union, cast +import pydantic_settings + from .core import BaseModel, DataPoint, Duration, Metric, Numeric, Readings, TimeSeries from .settings import Setting from .slo import SloInput @@ -65,8 +67,7 @@ def __opsani_repr__(self) -> dict[str, dict[Any, Any]]: class UserData(BaseModel): slo: Optional[SloInput] = None - def __init__(self): - self.model_config["extra"] = "allow" + model_config = pydantic_settings.SettingsConfigDict(extra="allow") class Control(BaseModel): diff --git a/servo/types/core.py b/servo/types/core.py index 3efd16d5..a0b5471d 100644 --- a/servo/types/core.py +++ b/servo/types/core.py @@ -84,24 +84,15 @@ def default_handler(obj) -> Any: raise err -BaseModelConfigDict = Annotated[ - pydantic.ConfigDict, - """ -The `BaseModelConfig` class provides a common set of Pydantic model -configuration shared across the library. -""", -] -BASE_MODEL_CONFIG: BaseModelConfigDict = { - "validate_assignment": True, -} - - class BaseModel(pydantic.BaseModel): """The `BaseModel` class is the base class implementation of Pydantic model types utilized throughout the library. """ - model_config: pydantic.ConfigDict = {**BASE_MODEL_CONFIG, "validate_default": True} + model_config: pydantic.ConfigDict = { + "validate_assignment": True, + "validate_default": True, + } class License(enum.Enum): @@ -178,8 +169,7 @@ def __str__(self) -> str: # DELETE ME from typing import Any from pydantic_core import CoreSchema, core_schema -from pydantic import GetCoreSchemaHandler, TypeAdapter -from typing_extensions import Annotated +from pydantic import GetCoreSchemaHandler from pydantic import ( BaseModel, diff --git a/servo/types/settings.py b/servo/types/settings.py index 9c00aefd..f908574d 100644 --- a/servo/types/settings.py +++ b/servo/types/settings.py @@ -609,8 +609,12 @@ def __get_pydantic_json_schema__( _core_schema: pydantic_core.core_schema.CoreSchema, handler: pydantic.GetJsonSchemaHandler, ) -> pydantic.json_schema.JsonSchemaValue: - _core_schema.update(anyOf=["int", "float"]) - return handler(Setting) + # _core_schema.update(anyOf=["int", "float"]) + # return handler(Setting) + json_schema = handler(_core_schema) + json_schema = handler.resolve_ref_schema(json_schema) + json_schema["anyOf"] = ["int", "float"] + return json_schema class EnvironmentRangeSetting(RangeSetting, EnvironmentSetting): diff --git a/servo/types/slo.py b/servo/types/slo.py index ae1b2fb5..ae68b680 100644 --- a/servo/types/slo.py +++ b/servo/types/slo.py @@ -20,6 +20,8 @@ import pydantic from typing import Annotated, cast, Optional +import pydantic_settings + from .core import BaseModel, Numeric @@ -45,9 +47,7 @@ class SloCondition(BaseModel): threshold_metric: Optional[str] = None slo_threshold_minimum: float = 0.25 - def __init__(self, *args, **kwargs): - self.model_config["extra"] = "forbid" - super().__init__(*args, **kwargs) + model_config = pydantic_settings.SettingsConfigDict(extra="forbid") @pydantic.model_validator(mode="before") @classmethod @@ -125,9 +125,7 @@ def __hash__(self) -> int: class SloInput(BaseModel): conditions: list[SloCondition] - def __init__(self, *args, **kwargs): - self.model_config["extra"] = "forbid" - super().__init__(*args, **kwargs) + model_config = pydantic_settings.SettingsConfigDict(extra="forbid") @pydantic.field_validator("conditions") def _conditions_are_unique(cls, value: list[SloCondition]): diff --git a/servo/utilities/pydantic.py b/servo/utilities/pydantic.py index 6f54b6fc..c9708bc6 100644 --- a/servo/utilities/pydantic.py +++ b/servo/utilities/pydantic.py @@ -50,12 +50,12 @@ def model_config_override( obj: pydantic.BaseModel, values: dict ) -> Generator[pydantic.BaseModel, None, None]: """Temporarily override the values on a Pydantic model config.""" - original = obj.model_config.copy() obj.model_config.update(**values) try: yield obj finally: - obj.model_config = original + for k in values.keys(): + obj.model_config.pop(k) @contextlib.contextmanager @@ -64,6 +64,10 @@ def extra( ) -> Generator[pydantic.BaseModel, None, None]: """Temporarily override the value of the `extra` setting on a Pydantic model.""" with model_config_override(obj, {"extra": extra}): + # dynamicly changing extra isn't actually supported. have to tap into undelrying implementation to set field + # normally set during BaseModel.model_construct + if obj.__pydantic_extra__ is None: + obj.__pydantic_extra__ = {} yield obj diff --git a/tests/api_test.py b/tests/api_test.py index f2f6b8bc..addc5e98 100644 --- a/tests/api_test.py +++ b/tests/api_test.py @@ -150,5 +150,5 @@ def test_parse_command_response_including_units_control(payload, validator) -> N from pydantic import TypeAdapter - obj = TypeAdapter.validate_python(Union[CommandResponse, Status], payload) + obj = TypeAdapter(Union[CommandResponse, Status]).validate_python(payload) validator(obj) diff --git a/tests/conftest.py b/tests/conftest.py index 1a648044..1b79e361 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -461,6 +461,12 @@ def _clean_environment(): return _clean_environment +@pytest.fixture() +def override_environment(request: pytest.FixtureRequest) -> None: + with tests.helpers.environment_overrides(request.param): + yield + + @pytest.fixture def random_string() -> str: """Return a random string of characters.""" diff --git a/tests/connector_test.py b/tests/connector_test.py index ad0e8e74..c9906f54 100644 --- a/tests/connector_test.py +++ b/tests/connector_test.py @@ -24,6 +24,33 @@ from tests.helpers import * +@pytest.fixture() +def optimizer_config() -> dict[str, str]: + return {"id": "dev.opsani.com/servox", "token": "1234556789"} + + +@pytest.fixture() +async def assembly( + servo_yaml: Path, optimizer_config: dict[str, str] +) -> servox.Assembly: + config = { + "optimizer": optimizer_config, + "connectors": ["first_test_servo", "second_test_servo"], + "first_test_servo": {}, + "second_test_servo": {}, + } + servo_yaml.write_text(yaml.dump(config)) + + # TODO: Can't pass in like this, needs to be fixed + assembly = await servox.Assembly.assemble(config_file=servo_yaml) + return assembly + + +@pytest.fixture() +def servo(assembly: servox.Assembly) -> servox.Servo: + return assembly.servos[0] + + class TestOptimizer: def test_organization_valid(self) -> None: optimizer = OpsaniOptimizer(id="example.com/my-app", token="123456") diff --git a/tests/connectors/kubernetes_test.py b/tests/connectors/kubernetes_test.py index a150554c..ff05f369 100644 --- a/tests/connectors/kubernetes_test.py +++ b/tests/connectors/kubernetes_test.py @@ -27,7 +27,7 @@ VersionInfo, ) from pydantic import BaseModel -from pydantic.error_wrappers import ValidationError +from pydantic import ValidationError import servo import servo.connectors.kubernetes diff --git a/tests/connectors/prometheus_test.py b/tests/connectors/prometheus_test.py index 2de0ddc4..aa073ab7 100644 --- a/tests/connectors/prometheus_test.py +++ b/tests/connectors/prometheus_test.py @@ -1056,8 +1056,9 @@ async def test_one_active_connector( class TestInstantVector: @pytest.fixture def vector(self) -> servo.connectors.prometheus.InstantVector: - return pydantic.parse_obj_as( - servo.connectors.prometheus.InstantVector, + return pydantic.TypeAdapter( + servo.connectors.prometheus.InstantVector + ).validate_python( { "metric": {}, "value": [ @@ -1095,8 +1096,9 @@ def test_iterate(self, vector) -> None: class TestRangeVector: @pytest.fixture def vector(self) -> servo.connectors.prometheus.RangeVector: - return pydantic.parse_obj_as( - servo.connectors.prometheus.RangeVector, + return pydantic.TypeAdapter( + servo.connectors.prometheus.RangeVector + ).validate_python( { "metric": { "__name__": "go_memstats_gc_sys_bytes", @@ -1172,7 +1174,7 @@ def test_iterate(self, vector) -> None: class TestResultPrimitives: def test_scalar(self) -> None: - output = pydantic.parse_obj_as( + output = pydantic.TypeAdapter.validate_python( servo.connectors.prometheus.Scalar, [1607989427.782, "1234"] ) assert output @@ -1184,7 +1186,7 @@ def test_scalar(self) -> None: ) def test_string(self) -> None: - output = pydantic.parse_obj_as( + output = pydantic.TypeAdapter.validate_python( servo.connectors.prometheus.String, [1607989427.782, "whatever"] ) assert output @@ -1196,7 +1198,7 @@ def test_string(self) -> None: ) def test_scalar_parses_as_string(self) -> None: - output = pydantic.parse_obj_as( + output = pydantic.TypeAdapter.validate_python( servo.connectors.prometheus.String, [1607989427.782, "1234.56"] ) assert output @@ -1211,7 +1213,7 @@ def test_string_does_not_parse_as_scalar(self) -> None: with pytest.raises( pydantic.ValidationError, match="value is not a valid float" ): - pydantic.parse_obj_as( + pydantic.TypeAdapter.validate_python( servo.connectors.prometheus.Scalar, [1607989427.782, "thug_life"] ) @@ -1243,13 +1245,17 @@ def obj(self): } def test_parse(self, obj) -> None: - data = pydantic.parse_obj_as(servo.connectors.prometheus.Data, obj) + data = pydantic.TypeAdapter( + servo.connectors.prometheus.Data + ).validate_python(obj) assert data assert data.result_type == servo.connectors.prometheus.ResultType.vector assert len(data) == 2 def test_iterate(self, obj) -> None: - data = pydantic.parse_obj_as(servo.connectors.prometheus.Data, obj) + data = pydantic.TypeAdapter( + servo.connectors.prometheus.Data + ).validate_python(obj) assert data for vector in data: assert isinstance(vector, servo.connectors.prometheus.InstantVector) @@ -1292,13 +1298,17 @@ def obj(self): } def test_parse(self, obj) -> None: - data = pydantic.parse_obj_as(servo.connectors.prometheus.Data, obj) + data = pydantic.TypeAdapter( + servo.connectors.prometheus.Data + ).validate_python(obj) assert data assert data.result_type == servo.connectors.prometheus.ResultType.matrix assert len(data) == 2 def test_iterate(self, obj) -> None: - data = pydantic.parse_obj_as(servo.connectors.prometheus.Data, obj) + data = pydantic.TypeAdapter( + servo.connectors.prometheus.Data + ).validate_python(obj) assert data values = [ @@ -1356,13 +1366,17 @@ def obj(self): return {"resultType": "scalar", "result": [1435781460.781, "1"]} def test_parse(self, obj) -> None: - data = pydantic.parse_obj_as(servo.connectors.prometheus.Data, obj) + data = pydantic.TypeAdapter( + servo.connectors.prometheus.Data + ).validate_python(obj) assert data assert data.result_type == servo.connectors.prometheus.ResultType.scalar assert len(data) == 1 def test_iterate(self, obj) -> None: - data = pydantic.parse_obj_as(servo.connectors.prometheus.Data, obj) + data = pydantic.TypeAdapter( + servo.connectors.prometheus.Data + ).validate_python(obj) assert data for scalar in data: assert scalar[0] == datetime.datetime( @@ -1376,13 +1390,17 @@ def obj(self): return {"resultType": "string", "result": [1607989427.782, "thug_life"]} def test_parse(self, obj) -> None: - data = pydantic.parse_obj_as(servo.connectors.prometheus.Data, obj) + data = pydantic.TypeAdapter( + servo.connectors.prometheus.Data + ).validate_python(obj) assert data assert data.result_type == servo.connectors.prometheus.ResultType.string assert len(data) == 1 def test_iterate(self, obj) -> None: - data = pydantic.parse_obj_as(servo.connectors.prometheus.Data, obj) + data = pydantic.TypeAdapter( + servo.connectors.prometheus.Data + ).validate_python(obj) assert data for string in data: assert string[0] == datetime.datetime( diff --git a/tests/helpers.py b/tests/helpers.py index b215af33..ac5af493 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -23,6 +23,7 @@ import fastapi import httpx import kubernetes_asyncio.client +import pytest import respx import uvicorn import yaml diff --git a/tests/servo_test.py b/tests/servo_test.py index 62b263ca..f052a929 100644 --- a/tests/servo_test.py +++ b/tests/servo_test.py @@ -1,21 +1,23 @@ import asyncio +import inspect import json import os import ssl from inspect import Signature from pathlib import Path -from typing import List +from typing import List, Tuple, Type, get_args from httpcore import Origin from devtools import debug import httpx import pydantic +import pydantic_settings import pytest import respx import yaml from pydantic import Extra, ValidationError -import servo as servox +import servo from servo import ( BaseServoConfiguration, Duration, @@ -32,7 +34,7 @@ Timeouts, ) from servo.connector import BaseConnector -from servo.connectors.vegeta import VegetaConnector +from servo.connectors.vegeta import VegetaConnector, VegetaConfiguration from servo.errors import * from servo.events import ( EventResult, @@ -84,11 +86,11 @@ def run_after_promotion(self, results: List[EventResult]) -> None: return "promoted!!" @on_event(Events.attach) - def handle_attach(self, servo_: servox.Servo) -> None: + def handle_attach(self, servo_: servo.Servo) -> None: self.attached = True @on_event(Events.detach) - def handle_detach(self, servo_: servox.Servo) -> None: + def handle_detach(self, servo_: servo.Servo) -> None: pass @on_event(Events.startup) @@ -133,80 +135,86 @@ async def assembly(servo_yaml: Path, optimizer_config: dict[str, str]) -> Assemb @pytest.fixture() -def servo(assembly: Assembly) -> Servo: +def test_servo(assembly: Assembly) -> Servo: return assembly.servos[0] def test_all_connector_types() -> None: - c = Assembly.construct().all_connector_types() + c = Assembly.model_construct().all_connector_types() assert FirstTestServoConnector in c -async def test_servo_routes(servo: Servo) -> None: - first_connector = servo.get_connector("first_test_servo") +async def test_servo_routes(test_servo: Servo) -> None: + first_connector = test_servo.get_connector("first_test_servo") assert first_connector.name == "first_test_servo" - assert first_connector.__class__.name == "FirstTestServo" - results = await servo.dispatch_event("this_is_an_event", include=[first_connector]) + assert first_connector.__class__._name == "FirstTestServo" + results = await test_servo.dispatch_event( + "this_is_an_event", include=[first_connector] + ) assert len(results) == 1 assert results[0].value == "this is the result" -def test_servo_routes_and_connectors_reference_same_objects(servo: Servo) -> None: - connector_ids = list(map(lambda c: id(c), servo.__connectors__)) +def test_servo_routes_and_connectors_reference_same_objects(test_servo: Servo) -> None: + connector_ids = list(map(lambda c: id(c), test_servo.__connectors__)) assert connector_ids - route_ids = list(map(lambda c: id(c), servo.connectors)) + route_ids = list(map(lambda c: id(c), test_servo.connectors)) assert route_ids - assert connector_ids == (route_ids + [id(servo)]) + assert connector_ids == (route_ids + [id(test_servo)]) # Verify each child has correct references - for conn in servo.__connectors__: + for conn in test_servo.__connectors__: subconnector_ids = list(map(lambda c: id(c), conn.__connectors__)) assert subconnector_ids == connector_ids -def test_servo_and_connectors_share_pubsub_exchange(servo: Servo) -> None: - exchange = servo.pubsub_exchange - for connector in servo.__connectors__: +def test_servo_and_connectors_share_pubsub_exchange(test_servo: Servo) -> None: + exchange = test_servo.pubsub_exchange + for connector in test_servo.__connectors__: assert connector.pubsub_exchange == exchange assert id(connector.pubsub_exchange) == id(exchange) -async def test_dispatch_event(servo: Servo) -> None: - results = await servo.dispatch_event("this_is_an_event") +async def test_dispatch_event(test_servo: Servo) -> None: + results = await test_servo.dispatch_event("this_is_an_event") assert len(results) == 2 assert results[0].value == "this is the result" -async def test_dispatch_event_first(servo: Servo) -> None: - result = await servo.dispatch_event("this_is_an_event", first=True) +async def test_dispatch_event_first(test_servo: Servo) -> None: + result = await test_servo.dispatch_event("this_is_an_event", first=True) assert isinstance(result, EventResult) assert result.value == "this is the result" -async def test_dispatch_event_include(servo: Servo) -> None: - first_connector = servo.connectors[0] +async def test_dispatch_event_include(test_servo: Servo) -> None: + first_connector = test_servo.connectors[0] assert first_connector.name == "first_test_servo" - results = await servo.dispatch_event("this_is_an_event", include=[first_connector]) + results = await test_servo.dispatch_event( + "this_is_an_event", include=[first_connector] + ) assert len(results) == 1 assert results[0].value == "this is the result" -async def test_dispatch_event_exclude(servo: Servo) -> None: - assert len(servo.connectors) == 2 - first_connector = servo.connectors[0] +async def test_dispatch_event_exclude(test_servo: Servo) -> None: + assert len(test_servo.connectors) == 2 + first_connector = test_servo.connectors[0] assert first_connector.name == "first_test_servo" - second_connector = servo.connectors[1] + second_connector = test_servo.connectors[1] assert second_connector.name == "second_test_servo" event_names = set(_events.keys()) assert "this_is_an_event" in event_names - results = await servo.dispatch_event("this_is_an_event", exclude=[first_connector]) + results = await test_servo.dispatch_event( + "this_is_an_event", exclude=[first_connector] + ) assert len(results) == 1 assert results[0].value == "this is a different result" assert results[0].connector == second_connector -def test_get_event_handlers_all(servo: Servo) -> None: - connector = servo.get_connector("first_test_servo") +def test_get_event_handlers_all(test_servo: Servo) -> None: + connector = test_servo.get_connector("first_test_servo") event_handlers = connector.get_event_handlers("promote") assert len(event_handlers) == 3 assert list(map(lambda h: f"{h.preposition}:{h.event}", event_handlers)) == [ @@ -219,7 +227,7 @@ def test_get_event_handlers_all(servo: Servo) -> None: from servo.events import get_event -async def test_add_event_handler_programmatically(mocker, servo: Servo) -> None: +async def test_add_event_handler_programmatically(mocker, test_servo: Servo) -> None: async def fn(self, results: List[EventResult]) -> None: print("Test!") @@ -228,39 +236,39 @@ async def fn(self, results: List[EventResult]) -> None: event, Preposition.after, fn ) spy = mocker.spy(event_handler, "handler") - await servo.dispatch_event("measure") + await test_servo.dispatch_event("measure") spy.assert_called_once() -async def test_before_event(mocker, servo: Servo) -> None: - connector = servo.get_connector("first_test_servo") +async def test_before_event(mocker, test_servo: Servo) -> None: + connector = test_servo.get_connector("first_test_servo") event_handler = connector.get_event_handlers("measure", Preposition.before)[0] spy = mocker.spy(event_handler, "handler") - await servo.dispatch_event("measure") + await test_servo.dispatch_event("measure") spy.assert_called_once() -async def test_after_event(mocker, servo: Servo) -> None: - connector = servo.get_connector("first_test_servo") +async def test_after_event(mocker, test_servo: Servo) -> None: + connector = test_servo.get_connector("first_test_servo") event_handler = connector.get_event_handlers("promote", Preposition.after)[0] spy = mocker.spy(event_handler, "handler") - await servo.dispatch_event("promote") + await test_servo.dispatch_event("promote") await asyncio.sleep(0.1) spy.assert_called_once() -async def test_on_event(mocker, servo: Servo) -> None: - connector = servo.get_connector("first_test_servo") +async def test_on_event(mocker, test_servo: Servo) -> None: + connector = test_servo.get_connector("first_test_servo") assert connector - assert servo.connectors + assert test_servo.connectors event_handler = connector.get_event_handlers("promote", Preposition.on)[0] spy = mocker.spy(event_handler, "handler") - await servo.dispatch_event("promote") + await test_servo.dispatch_event("promote") spy.assert_called_once() -async def test_cancellation_of_event_from_before_handler(mocker, servo: Servo): - connector = servo.get_connector("first_test_servo") +async def test_cancellation_of_event_from_before_handler(mocker, test_servo: Servo): + connector = test_servo.get_connector("first_test_servo") before_handler = connector.get_event_handlers("promote", Preposition.before)[0] on_handler = connector.get_event_handlers("promote", Preposition.on)[0] on_spy = mocker.spy(on_handler, "handler") @@ -274,7 +282,7 @@ async def test_cancellation_of_event_from_before_handler(mocker, servo: Servo): # Mock the before handler to throw a cancel exception mock = mocker.patch.object(before_handler, "handler") mock.side_effect = EventCancelledError("it burns when I pee", connector=connector) - results = await servo.dispatch_event("promote") + results = await test_servo.dispatch_event("promote") # Check that on and after callbacks were never called on_spy.assert_not_called() @@ -289,8 +297,8 @@ async def test_cancellation_of_event_from_before_handler(mocker, servo: Servo): ) -async def test_cannot_cancel_from_on_handlers_warning(mocker, servo: Servo): - connector = servo.get_connector("first_test_servo") +async def test_cannot_cancel_from_on_handlers_warning(mocker, test_servo: Servo): + connector = test_servo.get_connector("first_test_servo") event_handler = connector.get_event_handlers("promote", Preposition.on)[0] mock = mocker.patch.object(event_handler, "handler") @@ -298,7 +306,7 @@ async def test_cannot_cancel_from_on_handlers_warning(mocker, servo: Servo): messages = [] connector.logger.add(lambda m: messages.append(m), level=0) - await servo.dispatch_event("promote", return_exceptions=True) + await test_servo.dispatch_event("promote", return_exceptions=True) assert messages[0].record["level"].name == "WARNING" assert ( messages[0].record["message"] @@ -309,33 +317,35 @@ async def test_cannot_cancel_from_on_handlers_warning(mocker, servo: Servo): from servo.errors import EventCancelledError -async def test_cannot_cancel_from_on_handlers(mocker, servo: Servo): - connector = servo.get_connector("first_test_servo") +async def test_cannot_cancel_from_on_handlers(mocker, test_servo: Servo): + connector = test_servo.get_connector("first_test_servo") event_handler = connector.get_event_handlers("promote", Preposition.on)[0] mock = mocker.patch.object(event_handler, "handler") mock.side_effect = EventCancelledError() with pytest.raises(ExceptionGroup) as error: - await servo.dispatch_event("promote") + await test_servo.dispatch_event("promote") assert str(error.value.exceptions[0]) == "Cannot cancel an event from an on handler" -async def test_cannot_cancel_from_after_handlers_warning(mocker, servo: Servo): - connector = servo.get_connector("first_test_servo") +async def test_cannot_cancel_from_after_handlers_warning(mocker, test_servo: Servo): + connector = test_servo.get_connector("first_test_servo") event_handler = connector.get_event_handlers("promote", Preposition.after)[0] mock = mocker.patch.object(event_handler, "handler") mock.side_effect = EventCancelledError() with pytest.raises(ExceptionGroup) as error: - await servo.dispatch_event("promote") + await test_servo.dispatch_event("promote") assert ( str(error.value.exceptions[0]) == "Cannot cancel an event from an after handler" ) -async def test_after_handlers_are_not_called_on_failure_raises(mocker, servo: Servo): - connector = servo.get_connector("first_test_servo") +async def test_after_handlers_are_not_called_on_failure_raises( + mocker, test_servo: Servo +): + connector = test_servo.get_connector("first_test_servo") after_handler = connector.get_event_handlers("promote", Preposition.after)[0] spy = mocker.spy(after_handler, "handler") @@ -344,14 +354,14 @@ async def test_after_handlers_are_not_called_on_failure_raises(mocker, servo: Se mock = mocker.patch.object(on_handler, "handler") mock.side_effect = EventError() with pytest.raises(ExceptionGroup) as error: - await servo.dispatch_event("promote", return_exceptions=False) + await test_servo.dispatch_event("promote", return_exceptions=False) assert isinstance(error.value.exceptions[0], EventError) spy.assert_not_called() -async def test_after_handlers_are_called_on_failure(mocker, servo: Servo): - connector = servo.get_connector("first_test_servo") +async def test_after_handlers_are_called_on_failure(mocker, test_servo: Servo): + connector = test_servo.get_connector("first_test_servo") after_handler = connector.get_event_handlers("promote", Preposition.after)[0] spy = mocker.spy(after_handler, "handler") @@ -359,7 +369,7 @@ async def test_after_handlers_are_called_on_failure(mocker, servo: Servo): on_handler = connector.get_event_handlers("promote", Preposition.on)[0] mock = mocker.patch.object(on_handler, "handler") mock.side_effect = EventError() - results = await servo.dispatch_event("promote", return_exceptions=True) + results = await test_servo.dispatch_event("promote", return_exceptions=True) await asyncio.sleep(0.1) spy.assert_called_once() @@ -375,29 +385,31 @@ async def test_after_handlers_are_called_on_failure(mocker, servo: Servo): assert result.preposition == Preposition.on -async def test_dispatching_specific_prepositions(mocker, servo: Servo) -> None: - connector = servo.get_connector("first_test_servo") +async def test_dispatching_specific_prepositions(mocker, test_servo: Servo) -> None: + connector = test_servo.get_connector("first_test_servo") before_handler = connector.get_event_handlers("promote", Preposition.before)[0] before_spy = mocker.spy(before_handler, "handler") on_handler = connector.get_event_handlers("promote", Preposition.on)[0] on_spy = mocker.spy(on_handler, "handler") after_handler = connector.get_event_handlers("promote", Preposition.after)[0] after_spy = mocker.spy(after_handler, "handler") - await servo.dispatch_event("promote", _prepositions=Preposition.on) + await test_servo.dispatch_event("promote", _prepositions=Preposition.on) before_spy.assert_not_called() on_spy.assert_called_once() after_spy.assert_not_called() -async def test_dispatching_multiple_specific_prepositions(mocker, servo: Servo) -> None: - connector = servo.get_connector("first_test_servo") +async def test_dispatching_multiple_specific_prepositions( + mocker, test_servo: Servo +) -> None: + connector = test_servo.get_connector("first_test_servo") before_handler = connector.get_event_handlers("promote", Preposition.before)[0] before_spy = mocker.spy(before_handler, "handler") on_handler = connector.get_event_handlers("promote", Preposition.on)[0] on_spy = mocker.spy(on_handler, "handler") after_handler = connector.get_event_handlers("promote", Preposition.after)[0] after_spy = mocker.spy(after_handler, "handler") - await servo.dispatch_event( + await test_servo.dispatch_event( "promote", _prepositions=Preposition.on | Preposition.before ) before_spy.assert_called_once() @@ -406,42 +418,44 @@ async def test_dispatching_multiple_specific_prepositions(mocker, servo: Servo) @api_mock -async def test_startup_event(mocker, servo: Servo) -> None: - connector = servo.get_connector("first_test_servo") - await servo.startup() +async def test_startup_event(mocker, test_servo: Servo) -> None: + connector = test_servo.get_connector("first_test_servo") + await test_servo.startup() assert connector.started_up == True @api_mock -async def test_startup_starts_pubsub_exchange(mocker, servo: Servo) -> None: - servo.get_connector("first_test_servo") - assert not servo.pubsub_exchange.running - await servo.startup() - assert servo.pubsub_exchange.running - await servo.pubsub_exchange.shutdown() +async def test_startup_starts_pubsub_exchange(mocker, test_servo: Servo) -> None: + test_servo.get_connector("first_test_servo") + assert not test_servo.pubsub_exchange.running + await test_servo.startup() + assert test_servo.pubsub_exchange.running + await test_servo.pubsub_exchange.shutdown() @api_mock -async def test_shutdown_event(mocker, servo: Servo) -> None: - await servo.startup() - connector = servo.get_connector("first_test_servo") +async def test_shutdown_event(mocker, test_servo: Servo) -> None: + await test_servo.startup() + connector = test_servo.get_connector("first_test_servo") on_handler = connector.get_event_handlers("shutdown", Preposition.on)[0] on_spy = mocker.spy(on_handler, "handler") - await servo.shutdown() + await test_servo.shutdown() on_spy.assert_called() @api_mock -async def test_shutdown_event_stops_pubsub_exchange(mocker, servo: Servo) -> None: - await servo.startup() - assert servo.pubsub_exchange.running - await servo.shutdown() - assert not servo.pubsub_exchange.running +async def test_shutdown_event_stops_pubsub_exchange(test_servo: Servo) -> None: + await test_servo.startup() + assert test_servo.pubsub_exchange.running + await test_servo.shutdown() + assert not test_servo.pubsub_exchange.running -async def test_dispatching_event_that_doesnt_exist(mocker, servo: Servo) -> None: +async def test_dispatching_event_that_doesnt_exist(test_servo: Servo) -> None: with pytest.raises(KeyError) as error: - await servo.dispatch_event("this_is_not_an_event", _prepositions=Preposition.on) + await test_servo.dispatch_event( + "this_is_not_an_event", _prepositions=Preposition.on + ) assert str(error.value) == "'this_is_not_an_event'" @@ -663,12 +677,12 @@ async def test_assemble_assigns_optimizer_to_connectors( assert len(assembly.servos) == 1 assert len(assembly.servos[0].connectors) == 1 - servo = assembly.servos[0] + test_servo = assembly.servos[0] optimizer = OpsaniOptimizer(**optimizer_config) - assert servo.config.optimizer, "optimizer should not be null" - assert servo.config.optimizer == optimizer - connector = servo.connectors[0] + assert test_servo.config.optimizer, "optimizer should not be null" + assert test_servo.config.optimizer == optimizer + connector = test_servo.connectors[0] assert connector._optimizer == optimizer async def test_aliased_connectors_produce_schema( @@ -687,918 +701,582 @@ async def test_aliased_connectors_produce_schema( assembly = await Assembly.assemble(config_file=servo_yaml) DynamicServoSettings = assembly.servos[0].config.__class__ - schema = json.loads(DynamicServoSettings.schema_json()) + schema = DynamicServoSettings.model_json_schema() # Description on parent class can be squirrely - assert schema["properties"]["description"]["env_names"] == ["SERVO_DESCRIPTION"] assert schema == { - "title": "Servo Configuration Schema", - "description": "Schema for configuration of Servo v100.0.0 with Vegeta Connector v100.0.0", - "type": "object", - "properties": { - "name": { - "title": "Name", - "env_names": [ - "SERVO_NAME", - ], - "type": "string", - }, - "description": { - "title": "Description", - "env_names": [ - "SERVO_DESCRIPTION", - ], - "type": "string", - }, - "optimizer": { - "title": "Optimizer", - "env_names": [ - "SERVO_OPTIMIZER", - ], - "anyOf": [ - {"$ref": "#/definitions/AppdynamicsOptimizer"}, - {"$ref": "#/definitions/OpsaniOptimizer"}, - ], - "default": {}, - }, - "checks": { - "allOf": [{"$ref": "#/definitions/ChecksConfiguration"}], - "description": "Configuration of Checks behavior", - "env_names": ["SERVO_CHECKS"], - "title": "Checks", - }, - "connectors": { - "title": "Connectors", - "description": ( - "An optional, explicit configuration of the active connectors.\n" - "\n" - "Configurable as either an array of connector identifiers (names or class) or\n" - "a dictionary where the keys specify the key path to the connectors configuration\n" - "and the values identify the connector (by name or class name)." - ), - "examples": [ - [ - "kubernetes", - "prometheus", - ], - { - "staging_prom": "prometheus", - "gateway_prom": "prometheus", - }, - ], - "env_names": [ - "SERVO_CONNECTORS", - ], - "anyOf": [ - { - "type": "array", - "items": { - "type": "string", - }, - }, - { - "type": "object", - "additionalProperties": { - "type": "string", - }, - }, - ], - }, - "no_diagnostics": { - "default": True, - "description": "Do not poll the Opsani API for diagnostics", - "env_names": ["SERVO_NO_DIAGNOSTICS"], - "title": "No Diagnostics", - "type": "boolean", - }, - "settings": { - "title": "Settings", - "description": "Configuration of the Servo connector", - "env_names": [ - "SERVO_SETTINGS", - ], - "allOf": [ - { - "$ref": "#/definitions/CommonConfiguration", - }, - ], - }, - "other": { - "title": "Other", - "env_names": [ - "SERVO_OTHER", - ], - "allOf": [ - { - "$ref": "#/definitions/VegetaConfiguration__other", - }, - ], - }, - "servo_uid": { - "env": "SERVO_UID", - "env_names": ["SERVO_UID"], - "title": "Servo Uid", - "type": "string", - }, - "vegeta": { - "title": "Vegeta", - "env_names": [ - "SERVO_VEGETA", - ], - "allOf": [ - { - "$ref": "#/definitions/VegetaConfiguration", - }, - ], - }, - }, - "required": [ - "other", - "vegeta", - ], - "additionalProperties": False, - "definitions": { - "OpsaniOptimizer": { - "title": "OpsaniOptimizer", - "description": ( - "An Optimizer models an Opsani optimization engines that the Servo can connect to\n" - "in order to access the Opsani machine learning technology for optimizing system infrastructure\n" - "and application workloads.\n" - "\n" - "Attributes:\n" - " id: A friendly identifier formed by joining the `organization` and the `name` with a slash ch" - "aracter\n" - " of the form `example.com/my-app` or `another.com/app-2`.\n" - " token: An opaque access token for interacting with the Optimizer via HTTP Bearer Token authen" - "tication.\n" - " base_url: The base URL for accessing the Opsani API. This field is typically only useful to O" - "psani developers or in the context\n" - " of deployments with specific contractual, firewall, or security mandates that preclude ac" - "cess to the primary API.\n" - " url: An optional URL that overrides the computed URL for accessing the Opsani API. This o" - "ption is utilized during development\n" - " and automated testing to bind the servo to a fixed URL." - ), - "type": "object", - "properties": { - "id": { - "title": "Id", - "env": "OPSANI_OPTIMIZER", - "env_names": ["OPSANI_OPTIMIZER"], - "pattern": ( - "^(?!-)([A-Za-z0-9-.]{5,50})/[a-zA-Z\\_\\-\\.0-9]{1,64}$" - ), - "type": "string", - }, - "token": { - "title": "Token", - "env": "OPSANI_TOKEN", - "env_names": [ - "OPSANI_TOKEN", - ], - "type": "string", - "writeOnly": True, - "format": "password", - }, - "base_url": { - "title": "Base Url", - "default": "https://api.opsani.com", - "env": "OPSANI_BASE_URL", - "env_names": [ - "OPSANI_BASE_URL", - ], - "minLength": 1, - "maxLength": 65536, - "format": "uri", - "type": "string", - }, - "url": { - "env": "OPSANI_URL", - "env_names": ["OPSANI_URL"], - "format": "uri", - "maxLength": 65536, - "minLength": 1, - "title": "Url", - "type": "string", - }, - }, - "required": [ - "id", - "token", - ], - "additionalProperties": False, - }, + "$defs": { "AppdynamicsOptimizer": { "additionalProperties": False, - "description": "Base class for settings, allowing values to be overridden by environment variables.\n\nThis is useful in production for secrets you do not wish to save in code, it plays nicely with docker(-compose),\nHeroku and any 12 factor app design.", "properties": { + "optimizer_id": {"title": "Optimizer Id", "type": "string"}, + "tenant_id": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, + "title": "Tenant Id", + }, "base_url": { - "env": "APPD_BASE_URL", - "env_names": ["APPD_BASE_URL"], - "format": "uri", - "maxLength": 65536, - "minLength": 1, + "anyOf": [ + {"format": "uri", "minLength": 1, "type": "string"}, + {"type": "null"}, + ], + "default": None, "title": "Base Url", - "type": "string", }, "client_id": { - "env": "APPD_CLIENT_ID", - "env_names": ["APPD_CLIENT_ID"], + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, "title": "Client Id", - "type": "string", }, "client_secret": { - "env": "APPD_CLIENT_SECRET", - "env_names": ["APPD_CLIENT_SECRET"], - "format": "password", + "anyOf": [ + { + "format": "password", + "type": "string", + "writeOnly": True, + }, + {"type": "null"}, + ], + "default": None, "title": "Client Secret", - "type": "string", - "writeOnly": True, }, "connection_file": { - "env": "APPD_CONNECTION_FILE", - "env_names": ["APPD_CONNECTION_FILE"], + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, "title": "Connection File", - "type": "string", - }, - "tenant_id": { - "env": "APPD_TENANT_ID", - "env_names": ["APPD_TENANT_ID"], - "title": "Tenant Id", - "type": "string", }, "token": { - "env": "APPD_TOKEN", - "env_names": ["APPD_TOKEN"], - "format": "password", + "anyOf": [ + { + "format": "password", + "type": "string", + "writeOnly": True, + }, + {"type": "null"}, + ], + "default": None, "title": "Token", - "type": "string", - "writeOnly": True, - }, - "token_url": { - "env": "APPD_TOKEN_URL", - "env_names": ["APPD_TOKEN_URL"], - "format": "uri", - "maxLength": 65536, - "minLength": 1, - "title": "Token Url", - "type": "string", }, "url": { - "env": "APPD_URL", - "env_names": ["APPD_URL"], - "format": "uri", - "maxLength": 65536, - "minLength": 1, + "anyOf": [ + {"format": "uri", "minLength": 1, "type": "string"}, + {"type": "null"}, + ], + "default": None, "title": "Url", - "type": "string", }, - "optimizer_id": { - "env": "APPD_OPTIMIZER_ID", - "env_names": ["APPD_OPTIMIZER_ID"], - "title": "Optimizer Id", - "type": "string", + "token_url": { + "anyOf": [ + {"format": "uri", "minLength": 1, "type": "string"}, + {"type": "null"}, + ], + "default": None, + "title": "Token Url", }, }, "required": ["optimizer_id"], "title": "AppdynamicsOptimizer", "type": "object", }, - "BackoffSettings": { - "title": "BackoffSettings Connector Configuration Schema", - "description": ( - "BackoffSettings objects model configuration of backoff and retry policies.\n" - "\n" - "See https://github.com/litl/backoff" - ), + "BackoffConfigurations": { + "additionalProperties": {"$ref": "#/$defs/BackoffSettings"}, + "description": "A mapping of named backoff configurations.", + "title": "BackoffConfigurations", "type": "object", + }, + "BackoffSettings": { + "additionalProperties": False, + "description": "BackoffSettings objects model configuration of backoff and retry policies.\n\nSee https://github.com/litl/backoff", "properties": { "max_time": { - "title": "Max Time", - "env_names": [ - "BACKOFF_SETTINGS_MAX_TIME", - ], - "type": "string", - "format": "duration", - "pattern": ( - "([\\d\\.]+y)?([\\d\\.]+mm)?(([\\d\\.]+w)?[\\d\\.]+d)?([\\d\\.]+h)?([\\d\\.]+m)?([\\d\\.]+s)?([\\d\\.]+ms)" - "?([\\d\\.]+us)?([\\d\\.]+ns)?" - ), - "examples": [ - "300ms", - "5m", - "2h45m", - "72h3m0.5s", + "anyOf": [ + {"format": "duration", "type": "string"}, + {"type": "null"}, ], + "title": "Max Time", }, "max_tries": { + "anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Max Tries", - "env_names": [ - "BACKOFF_SETTINGS_MAX_TRIES", - ], - "type": "integer", }, }, - "additionalProperties": False, - }, - "BackoffConfigurations": { - "title": "BackoffConfigurations", - "description": "A mapping of named backoff configurations.", - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/BackoffSettings", - }, - }, - "Timeouts": { - "title": "Timeouts Connector Configuration Schema", - "description": ( - "Timeouts models the configuration of timeouts for the HTTPX library, which provides HTTP networki" - "ng capabilities to the\n" - "servo.\n" - "\n" - "See https://www.python-httpx.org/advanced/#timeout-configuration" - ), + "required": ["max_time", "max_tries"], + "title": "BackoffSettings Connector Configuration Schema", "type": "object", - "properties": { - "connect": { - "title": "Connect", - "env_names": [ - "TIMEOUTS_CONNECT", - ], - "type": "string", - "format": "duration", - "pattern": ( - "([\\d\\.]+y)?([\\d\\.]+mm)?(([\\d\\.]+w)?[\\d\\.]+d)?([\\d\\.]+h)?([\\d\\.]+m)?([\\d\\.]+s)?([\\d\\.]+ms)" - "?([\\d\\.]+us)?([\\d\\.]+ns)?" - ), - "examples": [ - "300ms", - "5m", - "2h45m", - "72h3m0.5s", - ], - }, - "read": { - "title": "Read", - "env_names": [ - "TIMEOUTS_READ", - ], - "type": "string", - "format": "duration", - "pattern": ( - "([\\d\\.]+y)?([\\d\\.]+mm)?(([\\d\\.]+w)?[\\d\\.]+d)?([\\d\\.]+h)?([\\d\\.]+m)?([\\d\\.]+s)?([\\d\\.]+ms)" - "?([\\d\\.]+us)?([\\d\\.]+ns)?" - ), - "examples": [ - "300ms", - "5m", - "2h45m", - "72h3m0.5s", - ], - }, - "write": { - "title": "Write", - "env_names": [ - "TIMEOUTS_WRITE", - ], - "type": "string", - "format": "duration", - "pattern": ( - "([\\d\\.]+y)?([\\d\\.]+mm)?(([\\d\\.]+w)?[\\d\\.]+d)?([\\d\\.]+h)?([\\d\\.]+m)?([\\d\\.]+s)?([\\d\\.]+ms)" - "?([\\d\\.]+us)?([\\d\\.]+ns)?" - ), - "examples": [ - "300ms", - "5m", - "2h45m", - "72h3m0.5s", - ], - }, - "pool": { - "title": "Pool", - "env_names": [ - "TIMEOUTS_POOL", - ], - "type": "string", - "format": "duration", - "pattern": ( - "([\\d\\.]+y)?([\\d\\.]+mm)?(([\\d\\.]+w)?[\\d\\.]+d)?([\\d\\.]+h)?([\\d\\.]+m)?([\\d\\.]+s)?([\\d\\.]+ms)" - "?([\\d\\.]+us)?([\\d\\.]+ns)?" - ), - "examples": [ - "300ms", - "5m", - "2h45m", - "72h3m0.5s", - ], - }, - }, - "additionalProperties": False, }, "ChecksConfiguration": { "additionalProperties": False, "description": "ChecksConfiguration models configuration for behavior of the checks flow, such as\nwhether to automatically apply remedies.", "properties": { - "check_halting": { - "default": False, - "description": "Halt to wait for each checks success", - "env_names": ["CHECKS_CHECK_HALTING"], - "title": "Check Halting", - "type": "boolean", - }, "connectors": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "default": None, "description": "Connectors to check", - "env_names": ["CHECKS_CONNECTORS"], - "items": {"type": "string"}, "title": "Connectors", - "type": "array", - }, - "delay": { - "default": "expo", - "description": "Delay duration. Requires --wait", - "env_names": ["CHECKS_DELAY"], - "title": "Delay", - "type": "string", }, - "halt_on": { - "allOf": [{"$ref": "#/definitions/ErrorSeverity"}], - "default": "critical", - "description": "Halt running on failure severity", - "env_names": ["CHECKS_HALT_ON"], + "name": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "default": None, + "description": "Filter by name", + "title": "Name", }, "id": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "default": None, "description": "Filter by ID", - "env_names": ["CHECKS_ID"], - "items": {"type": "string"}, "title": "Id", - "type": "array", }, - "name": { - "description": "Filter by name", - "env_names": ["CHECKS_NAME"], - "items": {"type": "string"}, - "title": "Name", - "type": "array", - }, - "progressive": { - "default": True, - "description": "Execute checks and emit output progressively", - "env_names": ["CHECKS_PROGRESSIVE"], - "title": "Progressive", - "type": "boolean", + "tag": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "default": None, + "description": "Filter by tag", + "title": "Tag", }, "quiet": { "default": False, "description": "Do not echo generated output to stdout", - "env_names": ["CHECKS_QUIET"], "title": "Quiet", "type": "boolean", }, - "remedy": { - "default": True, - "description": "Automatically apply remedies to failed checks if detected", - "env_names": ["CHECKS_REMEDY"], - "title": "Remedy", - "type": "boolean", - }, - "tag": { - "description": "Filter by tag", - "env_names": ["CHECKS_TAG"], - "items": {"type": "string"}, - "title": "Tag", - "type": "array", - }, "verbose": { "default": False, "description": "Display verbose output", - "env_names": ["CHECKS_VERBOSE"], "title": "Verbose", "type": "boolean", }, + "progressive": { + "default": True, + "description": "Execute checks and emit output progressively", + "title": "Progressive", + "type": "boolean", + }, "wait": { "default": "30m", "description": "Wait for checks to pass", - "env_names": ["CHECKS_WAIT"], "title": "Wait", "type": "string", }, + "delay": { + "default": "expo", + "description": "Delay duration. Requires --wait", + "title": "Delay", + "type": "string", + }, + "halt_on": { + "allOf": [{"$ref": "#/$defs/ErrorSeverity"}], + "default": "critical", + "description": "Halt running on failure severity", + }, + "remedy": { + "default": True, + "description": "Automatically apply remedies to failed checks if detected", + "title": "Remedy", + "type": "boolean", + }, + "check_halting": { + "default": False, + "description": "Halt to wait for each checks success", + "title": "Check Halting", + "type": "boolean", + }, }, "title": "Checks Connector Configuration Schema", "type": "object", }, "CommonConfiguration": { - "title": "Common Connector Configuration Schema", - "description": ( - "CommonConfiguration models configuration for the Servo connector and establishes default\n" - "settings for shared services such as networking and logging." - ), - "type": "object", + "additionalProperties": False, + "description": "CommonConfiguration models configuration for the Servo connector and establishes default\nsettings for shared services such as networking and logging.", "properties": { "backoff": { - "title": "Backoff", - "env_names": [ - "COMMON_BACKOFF", - ], - "allOf": [ + "allOf": [{"$ref": "#/$defs/BackoffConfigurations"}], + "default": { + "__default__": {"max_time": "10m", "max_tries": None}, + "connect": {"max_time": "1h", "max_tries": None}, + }, + }, + "proxies": { + "anyOf": [ + {"pattern": "^(https?|all)://", "type": "string"}, { - "$ref": "#/definitions/BackoffConfigurations", + "patternProperties": { + "^(https?|all)://": { + "anyOf": [ + {"type": "string"}, + {"type": "null"}, + ] + } + }, + "type": "object", }, + {"type": "null"}, + ], + "default": None, + "title": "Proxies", + }, + "timeouts": { + "anyOf": [{"$ref": "#/$defs/Timeouts"}, {"type": "null"}], + "default": None, + }, + "ssl_verify": { + "anyOf": [ + {"type": "boolean"}, + {"format": "file-path", "type": "string"}, + {"type": "null"}, + ], + "default": None, + "title": "Ssl Verify", + }, + }, + "title": "Common Connector Configuration Schema", + "type": "object", + }, + "ErrorSeverity": { + "description": "ErrorSeverity is an enumeration the describes the severity of an error\nand establishes semantics about how it should be handled.", + "enum": ["warning", "common", "critical"], + "title": "ErrorSeverity", + "type": "string", + }, + "OpsaniOptimizer": { + "additionalProperties": False, + "description": "An Optimizer models an Opsani optimization engines that the Servo can connect to\nin order to access the Opsani machine learning technology for optimizing system infrastructure\nand application workloads.\n\nAttributes:\n id: A friendly identifier formed by joining the `organization` and the `name` with a slash character\n of the form `example.com/my-app` or `another.com/app-2`.\n token: An opaque access token for interacting with the Optimizer via HTTP Bearer Token authentication.\n base_url: The base URL for accessing the Opsani API. This field is typically only useful to Opsani developers or in the context\n of deployments with specific contractual, firewall, or security mandates that preclude access to the primary API.\n url: An optional URL that overrides the computed URL for accessing the Opsani API. This option is utilized during development\n and automated testing to bind the servo to a fixed URL.", + "properties": { + "opsani_optimizer": { + "pattern": "^([A-Za-z0-9-.]{5,50})/[a-zA-Z\\_\\-\\.0-9]{1,64}$", + "title": "Opsani Optimizer", + "type": "string", + }, + "token": { + "format": "password", + "title": "Token", + "type": "string", + "writeOnly": True, + }, + "base_url": { + "default": "https://api.opsani.com", + "format": "uri", + "minLength": 1, + "title": "Base Url", + "type": "string", + }, + "url": { + "anyOf": [ + {"format": "uri", "minLength": 1, "type": "string"}, + {"type": "null"}, + ], + "default": None, + "title": "Url", + }, + }, + "required": ["opsani_optimizer", "token"], + "title": "OpsaniOptimizer", + "type": "object", + }, + "TargetFormat": { + "enum": ["http", "json"], + "title": "TargetFormat", + "type": "string", + }, + "Timeouts": { + "additionalProperties": False, + "description": "Timeouts models the configuration of timeouts for the HTTPX library, which provides HTTP networking capabilities to the\nservo.\n\nSee https://www.python-httpx.org/advanced/#timeout-configuration", + "properties": { + "connect": { + "anyOf": [ + {"format": "duration", "type": "string"}, + {"type": "null"}, ], + "title": "Connect", }, - "proxies": { - "title": "Proxies", - "env_names": [ - "COMMON_PROXIES", - ], + "read": { "anyOf": [ - { - "type": "string", - "pattern": "^(https?|all)://", - }, - { - "type": "object", - "additionalProperties": { - "format": "uri", - "maxLength": 65536, - "minLength": 1, - "type": "string", - }, - "patternProperties": { - "^(https?|all)://": { - "type": "string", - "minLength": 1, - "maxLength": 65536, - "format": "uri", - }, - }, - }, + {"format": "duration", "type": "string"}, + {"type": "null"}, ], + "title": "Read", }, - "timeouts": { - "title": "Timeouts", - "env_names": [ - "COMMON_TIMEOUTS", - ], - "allOf": [ - { - "$ref": "#/definitions/Timeouts", - }, + "write": { + "anyOf": [ + {"format": "duration", "type": "string"}, + {"type": "null"}, ], + "title": "Write", }, - "ssl_verify": { - "title": "Ssl Verify", - "env_names": [ - "COMMON_SSL_VERIFY", - ], + "pool": { "anyOf": [ - { - "type": "boolean", - }, - { - "type": "string", - "format": "file-path", - }, + {"format": "duration", "type": "string"}, + {"type": "null"}, ], + "title": "Pool", }, }, - "additionalProperties": False, - }, - "ErrorSeverity": { - "description": "ErrorSeverity is an enumeration the describes the severity of an error\nand establishes semantics about how it should be handled.", - "enum": ["warning", "common", "critical"], - "title": "ErrorSeverity", - "type": "string", - }, - "TargetFormat": { - "title": "TargetFormat", - "description": "An enumeration.", - "enum": [ - "http", - "json", - ], - "type": "string", - }, - "VegetaConfiguration__other": { - "title": "Vegeta Connector Settings (named other)", - "description": "Configuration of the Vegeta connector", + "required": ["connect", "read", "write", "pool"], + "title": "Timeouts Connector Configuration Schema", "type": "object", + }, + "VegetaConfiguration": { + "additionalProperties": False, "properties": { "description": { - "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, "description": "An optional description of the configuration.", - "env_names": [ - "SERVO_OTHER_DESCRIPTION", - ], - "type": "string", + "title": "Description", }, "rate": { + "description": "Specifies the request rate per time unit to issue against the targets. Given in the format of request/time unit.", "title": "Rate", - "description": ( - "Specifies the request rate per time unit to issue against the targets. Given in the forma" - "t of request/time unit." - ), - "env_names": [ - "SERVO_OTHER_RATE", - ], "type": "string", }, "format": { - "description": ( - "Specifies the format of the targets input. Valid values are http and json. Refer to the V" - "egeta docs for details." - ), + "allOf": [{"$ref": "#/$defs/TargetFormat"}], "default": "http", - "env_names": [ - "SERVO_OTHER_FORMAT", - ], - "allOf": [ - { - "$ref": "#/definitions/TargetFormat", - }, - ], + "description": "Specifies the format of the targets input. Valid values are http and json. Refer to the Vegeta docs for details.", }, "target": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, + "description": "Specifies a single formatted Vegeta target to load. See the format option to learn about available target formats. This option is exclusive of the targets option and will provide a target to Vegeta via stdin.", "title": "Target", - "description": ( - "Specifies a single formatted Vegeta target to load. See the format option to learn about " - "available target formats. This option is exclusive of the targets option and will provide" - " a target to Vegeta via stdin." - ), - "env_names": [ - "SERVO_OTHER_TARGET", - ], - "type": "string", }, "targets": { - "title": "Targets", - "description": ( - "Specifies the file from which to read targets. See the format option to learn about avail" - "able target formats. This option is exclusive of the target option and will provide targe" - "ts to via through a file on disk." - ), - "env_names": [ - "SERVO_OTHER_TARGETS", + "anyOf": [ + {"format": "file-path", "type": "string"}, + {"type": "null"}, ], - "format": "file-path", - "type": "string", + "default": None, + "description": "Specifies the file from which to read targets. See the format option to learn about available target formats. This option is exclusive of the target option and will provide targets to via through a file on disk.", + "title": "Targets", }, "connections": { - "title": "Connections", - "description": "Specifies the maximum number of idle open connections per target host.", "default": 10000, - "env_names": [ - "SERVO_OTHER_CONNECTIONS", - ], + "description": "Specifies the maximum number of idle open connections per target host.", + "title": "Connections", "type": "integer", }, "workers": { - "title": "Workers", - "description": ( - "Specifies the initial number of workers used in the attack. The workers will automaticall" - "y increase to achieve the target request rate, up to max-workers." - ), "default": 10, - "env_names": [ - "SERVO_OTHER_WORKERS", - ], + "description": "Specifies the initial number of workers used in the attack. The workers will automatically increase to achieve the target request rate, up to max-workers.", + "title": "Workers", "type": "integer", }, "max_workers": { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": None, + "description": "The maximum number of workers used to sustain the attack. This can be used to control the concurrency of the attack to simulate a target number of clients.", "title": "Max Workers", - "description": ( - "The maximum number of workers used to sustain the attack. This can be used to control the" - " concurrency of the attack to simulate a target number of clients." - ), - "env_names": [ - "SERVO_OTHER_MAX_WORKERS", - ], - "type": "integer", }, "max_body": { - "title": "Max Body", - "description": ( - "Specifies the maximum number of bytes to capture from the body of each response. Remainin" - "g unread bytes will be fully read but discarded." - ), "default": -1, - "env_names": [ - "SERVO_OTHER_MAX_BODY", - ], + "description": "Specifies the maximum number of bytes to capture from the body of each response. Remaining unread bytes will be fully read but discarded.", + "title": "Max Body", "type": "integer", }, "http2": { - "title": "Http2", - "description": "Specifies whether to enable HTTP/2 requests to servers which support it.", "default": True, - "env_names": [ - "SERVO_OTHER_HTTP2", - ], + "description": "Specifies whether to enable HTTP/2 requests to servers which support it.", + "title": "Http2", "type": "boolean", }, "keepalive": { - "title": "Keepalive", - "description": "Specifies whether to reuse TCP connections between HTTP requests.", "default": True, - "env_names": [ - "SERVO_OTHER_KEEPALIVE", - ], + "description": "Specifies whether to reuse TCP connections between HTTP requests.", + "title": "Keepalive", "type": "boolean", }, "insecure": { - "title": "Insecure", - "description": "Specifies whether to ignore invalid server TLS certificates.", "default": False, - "env_names": [ - "SERVO_OTHER_INSECURE", - ], + "description": "Specifies whether to ignore invalid server TLS certificates.", + "title": "Insecure", "type": "boolean", }, "reporting_interval": { - "title": "Reporting Interval", - "description": "How often to report metrics during a measurement cycle.", "default": "15s", - "env_names": [ - "SERVO_OTHER_REPORTING_INTERVAL", - ], - "type": "string", + "description": "How often to report metrics during a measurement cycle.", "format": "duration", - "pattern": ( - "([\\d\\.]+y)?([\\d\\.]+mm)?(([\\d\\.]+w)?[\\d\\.]+d)?([\\d\\.]+h)?([\\d\\.]+m)?([\\d\\.]+s)?([\\d\\.]+ms)" - "?([\\d\\.]+us)?([\\d\\.]+ns)?" - ), - "examples": [ - "300ms", - "5m", - "2h45m", - "72h3m0.5s", - ], + "title": "Reporting Interval", + "type": "string", }, }, "required": ["rate"], - "additionalProperties": False, - }, - "VegetaConfiguration": { "title": "Vegeta Connector Settings (named vegeta)", - "description": "Configuration of the Vegeta connector", "type": "object", + }, + "VegetaConfiguration__other": { + "additionalProperties": False, "properties": { "description": { - "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, "description": "An optional description of the configuration.", - "env_names": [ - "SERVO_VEGETA_DESCRIPTION", - ], - "type": "string", + "title": "Description", }, "rate": { + "description": "Specifies the request rate per time unit to issue against the targets. Given in the format of request/time unit.", "title": "Rate", - "description": ( - "Specifies the request rate per time unit to issue against the targets. Given in the forma" - "t of request/time unit." - ), - "env_names": [ - "SERVO_VEGETA_RATE", - ], "type": "string", }, "format": { - "description": ( - "Specifies the format of the targets input. Valid values are http and json. Refer to the V" - "egeta docs for details." - ), + "allOf": [{"$ref": "#/$defs/TargetFormat"}], "default": "http", - "env_names": [ - "SERVO_VEGETA_FORMAT", - ], - "allOf": [ - { - "$ref": "#/definitions/TargetFormat", - }, - ], + "description": "Specifies the format of the targets input. Valid values are http and json. Refer to the Vegeta docs for details.", }, "target": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, + "description": "Specifies a single formatted Vegeta target to load. See the format option to learn about available target formats. This option is exclusive of the targets option and will provide a target to Vegeta via stdin.", "title": "Target", - "description": ( - "Specifies a single formatted Vegeta target to load. See the format option to learn about " - "available target formats. This option is exclusive of the targets option and will provide" - " a target to Vegeta via stdin." - ), - "env_names": [ - "SERVO_VEGETA_TARGET", - ], - "type": "string", }, "targets": { - "title": "Targets", - "description": ( - "Specifies the file from which to read targets. See the format option to learn about avail" - "able target formats. This option is exclusive of the target option and will provide targe" - "ts to via through a file on disk." - ), - "env_names": [ - "SERVO_VEGETA_TARGETS", + "anyOf": [ + {"format": "file-path", "type": "string"}, + {"type": "null"}, ], - "format": "file-path", - "type": "string", + "default": None, + "description": "Specifies the file from which to read targets. See the format option to learn about available target formats. This option is exclusive of the target option and will provide targets to via through a file on disk.", + "title": "Targets", }, "connections": { - "title": "Connections", - "description": "Specifies the maximum number of idle open connections per target host.", "default": 10000, - "env_names": [ - "SERVO_VEGETA_CONNECTIONS", - ], + "description": "Specifies the maximum number of idle open connections per target host.", + "title": "Connections", "type": "integer", }, "workers": { - "title": "Workers", - "description": ( - "Specifies the initial number of workers used in the attack. The workers will automaticall" - "y increase to achieve the target request rate, up to max-workers." - ), "default": 10, - "env_names": [ - "SERVO_VEGETA_WORKERS", - ], + "description": "Specifies the initial number of workers used in the attack. The workers will automatically increase to achieve the target request rate, up to max-workers.", + "title": "Workers", "type": "integer", }, "max_workers": { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": None, + "description": "The maximum number of workers used to sustain the attack. This can be used to control the concurrency of the attack to simulate a target number of clients.", "title": "Max Workers", - "description": ( - "The maximum number of workers used to sustain the attack. This can be used to control the" - " concurrency of the attack to simulate a target number of clients." - ), - "env_names": [ - "SERVO_VEGETA_MAX_WORKERS", - ], - "type": "integer", }, "max_body": { - "title": "Max Body", - "description": ( - "Specifies the maximum number of bytes to capture from the body of each response. Remainin" - "g unread bytes will be fully read but discarded." - ), "default": -1, - "env_names": [ - "SERVO_VEGETA_MAX_BODY", - ], + "description": "Specifies the maximum number of bytes to capture from the body of each response. Remaining unread bytes will be fully read but discarded.", + "title": "Max Body", "type": "integer", }, "http2": { - "title": "Http2", - "description": "Specifies whether to enable HTTP/2 requests to servers which support it.", "default": True, - "env_names": [ - "SERVO_VEGETA_HTTP2", - ], + "description": "Specifies whether to enable HTTP/2 requests to servers which support it.", + "title": "Http2", "type": "boolean", }, "keepalive": { - "title": "Keepalive", - "description": "Specifies whether to reuse TCP connections between HTTP requests.", "default": True, - "env_names": [ - "SERVO_VEGETA_KEEPALIVE", - ], + "description": "Specifies whether to reuse TCP connections between HTTP requests.", + "title": "Keepalive", "type": "boolean", }, "insecure": { - "title": "Insecure", - "description": "Specifies whether to ignore invalid server TLS certificates.", "default": False, - "env_names": [ - "SERVO_VEGETA_INSECURE", - ], + "description": "Specifies whether to ignore invalid server TLS certificates.", + "title": "Insecure", "type": "boolean", }, "reporting_interval": { - "title": "Reporting Interval", - "description": "How often to report metrics during a measurement cycle.", "default": "15s", - "env_names": [ - "SERVO_VEGETA_REPORTING_INTERVAL", - ], - "type": "string", + "description": "How often to report metrics during a measurement cycle.", "format": "duration", - "pattern": ( - "([\\d\\.]+y)?([\\d\\.]+mm)?(([\\d\\.]+w)?[\\d\\.]+d)?([\\d\\.]+h)?([\\d\\.]+m)?([\\d\\.]+s)?([\\d\\.]+ms)" - "?([\\d\\.]+us)?([\\d\\.]+ns)?" - ), - "examples": [ - "300ms", - "5m", - "2h45m", - "72h3m0.5s", - ], + "title": "Reporting Interval", + "type": "string", }, }, "required": ["rate"], - "additionalProperties": False, + "title": "Vegeta Connector Settings (named other)", + "type": "object", + }, + }, + "description": "Schema for configuration of Servo v100.0.0 with Vegeta Connector v100.0.0", + "properties": { + "name": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, + "title": "Name", + }, + "description": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, + "title": "Description", + }, + "SERVO_UID": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, + "title": "Servo Uid", + }, + "optimizer": { + "anyOf": [ + {"$ref": "#/$defs/AppdynamicsOptimizer"}, + {"$ref": "#/$defs/OpsaniOptimizer"}, + ], + "default": {}, + "title": "Optimizer", + }, + "connectors": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"additionalProperties": {"type": "string"}, "type": "object"}, + {"type": "null"}, + ], + "default": None, + "description": "An optional, explicit configuration of the active connectors.\n\nConfigurable as either an array of connector identifiers (names or class) or\na dictionary where the keys specify the key path to the connectors configuration\nand the values identify the connector (by name or class name).", + "examples": [ + ["kubernetes", "prometheus"], + {"gateway_prom": "prometheus", "staging_prom": "prometheus"}, + ], + "title": "Connectors", + }, + "no_diagnostics": { + "default": True, + "description": "Do not poll the Opsani API for diagnostics", + "title": "No Diagnostics", + "type": "boolean", + }, + "settings": { + "anyOf": [ + {"$ref": "#/$defs/CommonConfiguration"}, + {"type": "null"}, + ], + "description": "Configuration of the Servo connector", + }, + "checks": { + "anyOf": [ + {"$ref": "#/$defs/ChecksConfiguration"}, + {"type": "null"}, + ], + "description": "Configuration of Checks behavior", + }, + "other": { + "anyOf": [ + {"$ref": "#/$defs/VegetaConfiguration__other"}, + {"type": "null"}, + ] + }, + "vegeta": { + "anyOf": [{"$ref": "#/$defs/VegetaConfiguration"}, {"type": "null"}] }, }, + "required": ["other", "vegeta"], + "title": "Servo Configuration Schema", + "type": "object", } + @pytest.mark.usefixtures("optimizer_env") async def test_aliased_connectors_get_distinct_env_configuration( self, servo_yaml: Path ) -> None: @@ -1613,29 +1291,36 @@ async def test_aliased_connectors_get_distinct_env_configuration( DynamicServoConfiguration = assembly.servos[0].config.__class__ # Grab the vegeta field and check it - vegeta_field = DynamicServoConfiguration.__fields__["vegeta"] - vegeta_settings_type = vegeta_field.type_ - assert vegeta_settings_type.__name__ == "VegetaConfiguration" - assert vegeta_field.field_info.extra["env_names"] == {"SERVO_VEGETA"} + vegeta_field = DynamicServoConfiguration.model_fields["vegeta"] + vegeta_settings_type = vegeta_field.annotation + assert ( + str(vegeta_settings_type) + == "typing.Optional[servo.assembly.VegetaConfiguration]" + ) + vegeta_settings_inner_type = get_args(vegeta_settings_type)[0] # Grab the other field and check it - other_field = DynamicServoConfiguration.__fields__["other"] - other_settings_type = other_field.type_ - assert other_settings_type.__name__ == "VegetaConfiguration__other" - assert other_field.field_info.extra["env_names"] == {"SERVO_OTHER"} + other_field = DynamicServoConfiguration.model_fields["other"] + other_settings_type = other_field.annotation + assert ( + str(other_settings_type) + == "typing.Optional[servo.assembly.VegetaConfiguration__other]" + ) with environment_overrides({"SERVO_DESCRIPTION": "this description"}): assert os.environ["SERVO_DESCRIPTION"] == "this description" s = DynamicServoConfiguration( - other=other_settings_type.construct(), - vegeta=vegeta_settings_type(rate=10, target="http://example.com/"), + other=None, + vegeta=vegeta_settings_inner_type( + rate=10, target="http://example.com/" + ), ) assert s.description == "this description" # Make sure the incorrect case does pass with environment_overrides({"SERVO_RATE": "invalid"}): with pytest.raises(ValidationError): - vegeta_settings_type(target="https://foo.com/") + vegeta_settings_inner_type(target="https://foo.com/") # Try setting values via env with environment_overrides( @@ -1644,9 +1329,13 @@ async def test_aliased_connectors_get_distinct_env_configuration( "SERVO_OTHER_TARGET": "https://opsani.com/servox", } ): - s = other_settings_type() - assert s.rate == "100/1s" - assert s.target == "https://opsani.com/servox" + s = DynamicServoConfiguration( + vegeta=vegeta_settings_inner_type( + rate=10, target="http://example.com/" + ), + ) + assert s.other.rate == "100/1s" + assert s.other.target == "https://opsani.com/servox" async def test_generating_schema_with_test_connectors( @@ -1655,16 +1344,50 @@ async def test_generating_schema_with_test_connectors( assembly = await Assembly.assemble(config_file=servo_yaml) assert len(assembly.servos) == 1, "servo was not assembled" DynamicServoConfiguration = assembly.servos[0].config.__class__ - DynamicServoConfiguration.schema() + DynamicServoConfiguration.model_json_schema() # NOTE: Covers naming conflicts between settings models -- will raise if misconfigured +def test_optimizer_required(): + with pytest.raises(ValidationError) as e: + BaseServoConfiguration( + connectors={"test_vegeta": "VegetaConnector"}, + ) + + assert "3 validation errors for Abstract Servo Configuration Schema" in str(e.value) + assert e.value.errors()[0]["loc"] == ( + "optimizer", + "AppdynamicsOptimizer", + "optimizer_id", + ) + assert e.value.errors()[0]["msg"] == "Field required" + + +# automatically tests for presence of servo_connectors env var +class FooServoConfiguration(BaseServoConfiguration, pydantic_settings.BaseSettings): + model_config = pydantic_settings.SettingsConfigDict( + env_prefix="servo_", env_nested_delimiter="_" + ) + + @classmethod + def settings_customise_sources( + cls, + _: Type[pydantic_settings.BaseSettings], + init_settings: pydantic_settings.PydanticBaseSettingsSource, + env_settings: pydantic_settings.EnvSettingsSource, + dotenv_settings: pydantic_settings.PydanticBaseSettingsSource, + file_secret_settings: pydantic_settings.PydanticBaseSettingsSource, + ) -> Tuple[pydantic_settings.PydanticBaseSettingsSource, ...]: + assert "servo_connectors" in env_settings.env_vars + return init_settings, env_settings, dotenv_settings, file_secret_settings + + @pytest.mark.usefixtures("optimizer_env") class TestServoSettings: - def test_forbids_extra_attributes(self) -> None: + def test_forbids_extra_attributes(self, optimizer) -> None: with pytest.raises(ValidationError) as e: - BaseServoConfiguration(forbidden=[]) - assert "extra fields not permitted" in str(e) + BaseServoConfiguration(forbidden=[], optimizer=optimizer) + assert "extra fields not permitted" in str(e.value) def test_override_optimizer_settings_with_env_vars(self) -> None: with environment_overrides({"OPSANI_TOKEN": "abcdefg"}): @@ -1675,22 +1398,20 @@ def test_override_optimizer_settings_with_env_vars(self) -> None: def test_set_connectors_with_env_vars(self) -> None: with environment_overrides({"SERVO_CONNECTORS": '["measure"]'}): assert os.environ["SERVO_CONNECTORS"] is not None - s = BaseServoConfiguration() + + s = FooServoConfiguration() assert s is not None - schema = s.schema() - assert schema["properties"]["connectors"]["env_names"] == { - "SERVO_CONNECTORS" - } assert s.connectors is not None assert s.connectors == ["measure"] - def test_connectors_allows_none(self): + def test_connectors_allows_none(self, optimizer): s = BaseServoConfiguration( + optimizer=optimizer, connectors=None, ) assert s.connectors is None - def test_connectors_allows_set_of_classes(self): + def test_connectors_allows_set_of_classes(self, optimizer): class FooConnector(BaseConnector): pass @@ -1698,60 +1419,67 @@ class BarConnector(BaseConnector): pass s = BaseServoConfiguration( + optimizer=optimizer, connectors={FooConnector, BarConnector}, ) assert set(s.connectors) == {"FooConnector", "BarConnector"} - def test_connectors_rejects_invalid_connector_set_elements(self): + def test_connectors_rejects_invalid_connector_set_elements(self, optimizer): with pytest.raises(ValidationError) as e: BaseServoConfiguration( + optimizer=optimizer, connectors={BaseServoConfiguration}, ) - assert "1 validation error for BaseServoConfiguration" in str(e.value) + assert "1 validation error for Abstract Servo Configuration Schema" in str( + e.value + ) assert e.value.errors()[0]["loc"] == ("connectors",) assert ( e.value.errors()[0]["msg"] - == "Invalid connectors value: " + == "Value error, Invalid connectors value: " ) - def test_connectors_allows_set_of_class_names(self): + def test_connectors_allows_set_of_class_names(self, optimizer): s = BaseServoConfiguration( + optimizer=optimizer, connectors={"MeasureConnector", "AdjustConnector"}, ) assert set(s.connectors) == {"MeasureConnector", "AdjustConnector"} - def test_connectors_rejects_invalid_connector_set_class_name_elements(self): - with pytest.raises(ValidationError) as e: + def test_connectors_rejects_invalid_connector_set_class_name_elements( + self, optimizer + ): + with pytest.raises(TypeError) as e: BaseServoConfiguration( + optimizer=optimizer, connectors={"servo.servo.BaseServoConfiguration"}, ) - assert "1 validation error for BaseServoConfiguration" in str(e.value) - assert e.value.errors()[0]["loc"] == ("connectors",) - assert ( - e.value.errors()[0]["msg"] - == "BaseServoConfiguration is not a Connector subclass" - ) + assert "BaseServoConfiguration is not a Connector subclass" in str(e.value) - def test_connectors_allows_set_of_keys(self): + def test_connectors_allows_set_of_keys(self, optimizer): s = BaseServoConfiguration( + optimizer=optimizer, connectors={"vegeta"}, ) assert s.connectors == ["vegeta"] - def test_connectors_allows_dict_of_keys_to_classes(self): + def test_connectors_allows_dict_of_keys_to_classes(self, optimizer): s = BaseServoConfiguration( + optimizer=optimizer, connectors={"alias": VegetaConnector}, ) assert s.connectors == {"alias": "VegetaConnector"} - def test_connectors_allows_dict_of_keys_to_class_names(self): + def test_connectors_allows_dict_of_keys_to_class_names(self, optimizer): s = BaseServoConfiguration( + optimizer=optimizer, connectors={"alias": "VegetaConnector"}, ) assert s.connectors == {"alias": "VegetaConnector"} - def test_connectors_allows_dict_with_explicit_map_to_default_name(self): + def test_connectors_allows_dict_with_explicit_map_to_default_name(self, optimizer): s = BaseServoConfiguration( + optimizer=optimizer, connectors={"vegeta": "VegetaConnector"}, ) assert s.connectors == {"vegeta": "VegetaConnector"} @@ -1764,16 +1492,19 @@ def test_connectors_allows_dict_with_explicit_map_to_default_class( ) assert s.connectors == {"vegeta": "VegetaConnector"} - def test_connectors_forbids_dict_with_existing_key(self): + def test_connectors_forbids_dict_with_existing_key(self, optimizer): with pytest.raises(ValidationError) as e: BaseServoConfiguration( + optimizer=optimizer, connectors={"vegeta": "MeasureConnector"}, ) - assert "1 validation error for BaseServoConfiguration" in str(e.value) + assert "1 validation error for Abstract Servo Configuration Schema" in str( + e.value + ) assert e.value.errors()[0]["loc"] == ("connectors",) assert ( e.value.errors()[0]["msg"] - == 'Name "vegeta" is reserved by `VegetaConnector`' + == 'Value error, Name "vegeta" is reserved by `VegetaConnector`' ) @pytest.fixture(autouse=True, scope="session") @@ -1784,37 +1515,48 @@ def discover_connectors(self) -> None: for connector in loader.load(): pass - def test_connectors_forbids_dict_with_reserved_key(self): + def test_connectors_forbids_dict_with_reserved_key(self, optimizer): with pytest.raises(ValidationError) as e: BaseServoConfiguration( + optimizer=optimizer, connectors={"connectors": "VegetaConnector"}, ) - assert "1 validation error for BaseServoConfiguration" in str(e.value) + assert "1 validation error for Abstract Servo Configuration Schema" in str( + e.value + ) assert e.value.errors()[0]["loc"] == ("connectors",) - assert e.value.errors()[0]["msg"] == 'Name "connectors" is reserved' + assert ( + e.value.errors()[0]["msg"] == 'Value error, Name "connectors" is reserved' + ) - def test_connectors_forbids_dict_with_invalid_key(self): + def test_connectors_forbids_dict_with_invalid_key(self, optimizer): with pytest.raises(ValidationError) as e: BaseServoConfiguration( + optimizer=optimizer, connectors={"This Is Not Valid": "VegetaConnector"}, ) - assert "1 validation error for BaseServoConfiguration" in str(e.value) + assert "1 validation error for Abstract Servo Configuration Schema" in str( + e.value + ) assert e.value.errors()[0]["loc"] == ("connectors",) assert ( e.value.errors()[0]["msg"] - == '"This Is Not Valid" is not a valid connector name: names may only contain alphanumeric characters, hyphens, slashes, periods, and underscores' + == 'Value error, "This Is Not Valid" is not a valid connector name: names may only contain alphanumeric characters, hyphens, slashes, periods, and underscores' ) - def test_connectors_rejects_invalid_connector_dict_values(self): + def test_connectors_rejects_invalid_connector_dict_values(self, optimizer): with pytest.raises(ValidationError) as e: BaseServoConfiguration( + optimizer=optimizer, connectors={"whatever": "Not a Real Connector"}, ) - assert "1 validation error for BaseServoConfiguration" in str(e.value) + assert "1 validation error for Abstract Servo Configuration Schema" in str( + e.value + ) assert e.value.errors()[0]["loc"] == ("connectors",) assert ( e.value.errors()[0]["msg"] - == "Invalid connectors value: Not a Real Connector" + == "Value error, Invalid connectors value: Not a Real Connector" ) @@ -1841,7 +1583,7 @@ def test_valid_timeouts_input(attr, value, expected) -> None: @pytest.mark.parametrize("attr", ["connect", "read", "write", "pool"]) @pytest.mark.parametrize("value", [[], "not valid", {}]) def test_invalid_timeouts_input(attr, value) -> None: - with pytest.raises(ValidationError): + with pytest.raises((ValidationError, TypeError)): Timeouts(**{attr: value}) @@ -1916,7 +1658,6 @@ def test_timeouts_parsing(value, expected) -> None: # and the "internal" subdomain on port 5550 is requested... "http://internal.example.com:5550": "http://localhost:8032", }, - [], {}, ], ) @@ -1935,19 +1676,24 @@ def test_api_client_options() -> None: settings = CommonConfiguration(proxies="http://localhost:1234", ssl_verify=False) # NOTE: SETTINGS AND OPTIMIZER NOT TOGETHER!!! - servo = Servo(config={"settings": settings, "optimizer": optimizer}, connectors=[]) - assert servo.config.optimizer, "expected config to have an optimizer" - assert servo.optimizer, "expected to have an optimizer" - assert servo.optimizer == optimizer - - assert servo.config.settings, "expected settings" - assert servo.config.settings == settings, "expected settings" - assert servo.config.settings.proxies - - assert servo._api_client._timeout == httpx.Timeout(timeout=None) - assert servo._api_client._transport._pool._ssl_context.verify_mode == ssl.CERT_NONE - assert servo._api_client._transport._pool._ssl_context.check_hostname == False - for k, v in servo._api_client._mounts.items(): + test_servo = Servo( + config={"settings": settings, "optimizer": optimizer}, connectors=[] + ) + assert test_servo.config.optimizer, "expected config to have an optimizer" + assert test_servo.optimizer, "expected to have an optimizer" + assert test_servo.optimizer == optimizer + + assert test_servo.config.settings, "expected settings" + assert test_servo.config.settings == settings, "expected settings" + assert test_servo.config.settings.proxies + + assert test_servo._api_client._timeout == httpx.Timeout(timeout=None) + assert ( + test_servo._api_client._transport._pool._ssl_context.verify_mode + == ssl.CERT_NONE + ) + assert test_servo._api_client._transport._pool._ssl_context.check_hostname == False + for k, v in test_servo._api_client._mounts.items(): assert v._pool._proxy_url.scheme == b"http" assert v._pool._proxy_url.host == b"localhost" assert v._pool._proxy_url.port == 1234 @@ -1964,17 +1710,20 @@ async def test_httpx_client_config() -> None: # TODO: init with config that has optimizer, use optimizer + config? allow optimizer=UUU only on Servo class? connector = MeasureConnector(config={}) - servo = Servo( + test_servo = Servo( config={"settings": common, "optimizer": optimizer}, connectors=[connector] ) assert connector.optimizer == optimizer assert connector._global_config assert connector._global_config == common - for k, v in servo._api_client._mounts.items(): + for k, v in test_servo._api_client._mounts.items(): assert k.pattern == "all://" - assert servo._api_client._transport._pool._ssl_context.verify_mode == ssl.CERT_NONE - assert servo._api_client._transport._pool._ssl_context.check_hostname == False + assert ( + test_servo._api_client._transport._pool._ssl_context.verify_mode + == ssl.CERT_NONE + ) + assert test_servo._api_client._transport._pool._ssl_context.check_hostname == False def test_backoff_defaults() -> None: @@ -1987,15 +1736,15 @@ def test_backoff_defaults() -> None: def test_backoff_contexts() -> None: - contexts = servox.configuration.BackoffConfigurations( - __root__={ + contexts = servo.configuration.BackoffConfigurations( + **{ "__default__": {"max_time": "10m", "max_tries": None}, "connect": {"max_time": "1h", "max_tries": None}, } ) assert contexts - config = servox.configuration.CommonConfiguration(backoff=contexts) + config = servo.configuration.CommonConfiguration(backoff=contexts) assert config @@ -2039,8 +1788,10 @@ def test_checks_defaults() -> None: async def test_proxy_utilization(proxies) -> None: optimizer = OpsaniOptimizer(id="test.com/foo", token="12345") config = CommonConfiguration(proxies=proxies) - servo = Servo(config={"settings": config, "optimizer": optimizer}, connectors=[]) - transport = servo._api_client._transport_for_url(httpx.URL(optimizer.base_url)) + test_servo = Servo( + config={"settings": config, "optimizer": optimizer}, connectors=[] + ) + transport = test_servo._api_client._transport_for_url(httpx.URL(optimizer.base_url)) assert isinstance(transport, httpx.AsyncHTTPTransport) assert transport._pool._proxy_url.origin == Origin( scheme=b"http", host=b"localhost", port=1234 @@ -2051,39 +1802,39 @@ def test_codename() -> None: assert __cryptonym__ -async def test_add_connector(servo: Servo) -> None: +async def test_add_connector(test_servo: Servo) -> None: connector = FirstTestServoConnector(config=BaseConfiguration()) - assert connector not in servo.connectors - await servo.add_connector("whatever", connector) - assert connector in servo.connectors - assert servo.config.whatever == connector.config + assert connector not in test_servo.connectors + await test_servo.add_connector("whatever", connector) + assert connector in test_servo.connectors + assert test_servo.config.whatever == connector.config -async def test_add_connector_sends_attach_event(servo: Servo) -> None: +async def test_add_connector_sends_attach_event(test_servo: Servo) -> None: connector = FirstTestServoConnector(config=BaseConfiguration()) assert connector.attached is False - await servo.add_connector("whatever", connector) + await test_servo.add_connector("whatever", connector) assert connector.attached is True -async def test_add_connector_can_handle_events(servo: Servo) -> None: - results = await servo.dispatch_event("this_is_an_event") +async def test_add_connector_can_handle_events(test_servo: Servo) -> None: + results = await test_servo.dispatch_event("this_is_an_event") assert len(results) == 2 connector = FirstTestServoConnector(config=BaseConfiguration()) - await servo.add_connector("whatever", connector) + await test_servo.add_connector("whatever", connector) - results = await servo.dispatch_event("this_is_an_event") + results = await test_servo.dispatch_event("this_is_an_event") assert len(results) == 3 -async def test_add_connector_raises_if_name_exists(servo: Servo) -> None: +async def test_add_connector_raises_if_name_exists(test_servo: Servo) -> None: connector_1 = FirstTestServoConnector(config=BaseConfiguration()) - await servo.add_connector("whatever", connector_1) + await test_servo.add_connector("whatever", connector_1) connector_2 = FirstTestServoConnector(config=BaseConfiguration()) with pytest.raises(ValueError) as error: - await servo.add_connector("whatever", connector_2) + await test_servo.add_connector("whatever", connector_2) assert ( str(error.value) @@ -2091,40 +1842,42 @@ async def test_add_connector_raises_if_name_exists(servo: Servo) -> None: ) -async def test_remove_connector(servo: Servo) -> None: - connector = servo.get_connector("first_test_servo") - assert connector in servo.connectors - assert servo.config.first_test_servo == connector.config - await servo.remove_connector(connector) - assert connector not in servo.connectors +async def test_remove_connector(test_servo: Servo) -> None: + connector = test_servo.get_connector("first_test_servo") + assert connector in test_servo.connectors + assert test_servo.config.first_test_servo == connector.config + await test_servo.remove_connector(connector) + assert connector not in test_servo.connectors with pytest.raises(AttributeError): - assert servo.config.first_test_servo + assert test_servo.config.first_test_servo -async def test_remove_connector_by_name(servo: Servo) -> None: - connector = servo.get_connector("first_test_servo") - assert connector in servo.connectors - assert servo.config.first_test_servo == connector.config - await servo.remove_connector("first_test_servo") - assert connector not in servo.connectors +async def test_remove_connector_by_name(test_servo: Servo) -> None: + connector = test_servo.get_connector("first_test_servo") + assert connector in test_servo.connectors + assert test_servo.config.first_test_servo == connector.config + await test_servo.remove_connector("first_test_servo") + assert connector not in test_servo.connectors with pytest.raises(AttributeError): - assert servo.config.first_test_servo + assert test_servo.config.first_test_servo # TODO: shutdown if running -async def test_remove_connector_sends_detach_event(servo: Servo, mocker) -> None: - connector = servo.get_connector("first_test_servo") +async def test_remove_connector_sends_detach_event(test_servo: Servo, mocker) -> None: + connector = test_servo.get_connector("first_test_servo") on_handler = connector.get_event_handlers("detach", Preposition.on)[0] on_spy = mocker.spy(on_handler, "handler") - await servo.remove_connector(connector) + await test_servo.remove_connector(connector) on_spy.assert_called() -async def test_remove_connector_raises_if_name_does_not_exists(servo: Servo) -> None: +async def test_remove_connector_raises_if_name_does_not_exists( + test_servo: Servo, +) -> None: with pytest.raises(ValueError) as error: - await servo.remove_connector("whatever") + await test_servo.remove_connector("whatever") assert ( str(error.value) @@ -2132,10 +1885,12 @@ async def test_remove_connector_raises_if_name_does_not_exists(servo: Servo) -> ) -async def test_remove_connector_raises_if_obj_does_not_exists(servo: Servo) -> None: +async def test_remove_connector_raises_if_obj_does_not_exists( + test_servo: Servo, +) -> None: connector = FirstTestServoConnector(config=BaseConfiguration()) with pytest.raises(ValueError) as error: - await servo.remove_connector(connector) + await test_servo.remove_connector(connector) assert ( str(error.value) @@ -2150,17 +1905,17 @@ async def test_backoff() -> None: assert config.backoff.max_time("connect") == Duration("1h").total_seconds() -def test_servo_name_literal(servo: Servo) -> None: - servo.name = "hrm" - assert servo.name == "hrm" +def test_servo_name_literal(test_servo: Servo) -> None: + test_servo.name = "hrm" + assert test_servo.name == "hrm" def test_servo_name_from_config(optimizer_config: dict[str, str]) -> None: config = BaseServoConfiguration(name="archibald", optimizer=optimizer_config) - servo = Servo(config=config, connectors=[]) - assert servo.name == "archibald" + test_servo = Servo(config=config, connectors=[]) + assert test_servo.name == "archibald" -def test_servo_name_falls_back_to_optimizer_id(servo: Servo) -> None: - debug("SERVO IS: ", servo) - assert servo.name == "dev.opsani.com/servox" +def test_servo_name_falls_back_to_optimizer_id(test_servo: Servo) -> None: + debug("SERVO IS: ", test_servo) + assert test_servo.name == "dev.opsani.com/servox" From c3b2a2737253453f4eb33a02d3f44cdcc80458fc Mon Sep 17 00:00:00 2001 From: Linkous Sharp Date: Wed, 29 May 2024 13:21:16 -0500 Subject: [PATCH 6/8] Fix telemetry_test and pubsub_test --- servo/api.py | 2 +- servo/pubsub.py | 12 +++++++---- servo/servo.py | 5 +++-- tests/pubsub_test.py | 44 +++++++++++++++++++++++------------------ tests/telemetry_test.py | 4 ++-- 5 files changed, 39 insertions(+), 28 deletions(-) diff --git a/servo/api.py b/servo/api.py index 048bf5fa..06da897c 100644 --- a/servo/api.py +++ b/servo/api.py @@ -116,7 +116,7 @@ def event_str(self, event: Events | str) -> str: class Status(pydantic.BaseModel): status: Statuses message: Optional[str] = None - other_messages: Optional[list[str]] = ( + additional_messages: Optional[list[str]] = ( None # other lower priority error in exception group ) reason: Optional[str] = None diff --git a/servo/pubsub.py b/servo/pubsub.py index 5d20e0fc..cae84aee 100644 --- a/servo/pubsub.py +++ b/servo/pubsub.py @@ -141,8 +141,11 @@ def __init__( content = text.encode() elif json is not None: content = ( - json.json() - if (hasattr(json, "json") and callable(json.json)) + json.model_dump_json() + if ( + hasattr(json, "model_dump_json") + and callable(json.model_dump_json) + ) else json_.dumps(json) ) elif yaml is not None: @@ -204,7 +207,8 @@ class _ExchangeChildModel(pydantic.BaseModel): def __init__(self, *args, exchange: Exchange, **kwargs) -> None: super().__init__(*args, **kwargs) - self._exchange = weakref.ref(exchange) + if exchange is not None: + self._exchange = weakref.ref(exchange) @property def exchange(self) -> Exchange: @@ -1370,7 +1374,7 @@ def _random_unique_channel_name(self) -> str: while True: name = _random_string() if self.pubsub_exchange.get_channel(name) is None and re.match( - ChannelName.regex, name + ChannelName.__metadata__[0].pattern, name ): return name diff --git a/servo/servo.py b/servo/servo.py index 7f314e60..d0db8514 100644 --- a/servo/servo.py +++ b/servo/servo.py @@ -618,7 +618,7 @@ async def _post_event( try: try: response = await self._api_client.post( - "servo", data=event_request.model_dump_json() + "servo", data=event_request.model_dump_json(exclude_none=True) ) except RuntimeError as e: if "the handler is closed" in str(e): @@ -629,7 +629,8 @@ async def _post_event( self.config.optimizer, self.config.settings ) response = await self._api_client.post( - "servo", data=event_request.model_dump_json() + "servo", + data=event_request.model_dump_json(exclude_none=True), ) else: raise diff --git a/tests/pubsub_test.py b/tests/pubsub_test.py index 114fa251..bd558197 100644 --- a/tests/pubsub_test.py +++ b/tests/pubsub_test.py @@ -47,18 +47,18 @@ def test_json_message(self) -> None: @freezegun.freeze_time("2021-01-01 12:00:01") def test_json_message_via_protocol(self) -> None: # NOTE: Use Pydantic's json() method support - channel = servo.pubsub.Channel.construct( - name="whatever", created_at=datetime.datetime.now() + channel = servo.pubsub.Channel( + name="whatever", created_at=datetime.datetime.now(), exchange=None ) message = servo.pubsub.Message(json=channel) assert ( message.text - == '{"name": "whatever", "description": null, "created_at": "2021-01-01T12:00:01"}' + == '{"name":"whatever","description":null,"created_at":"2021-01-01T12:00:01"}' ) assert message.content_type == "application/json" assert ( message.content - == b'{"name": "whatever", "description": null, "created_at": "2021-01-01T12:00:01"}' + == b'{"name":"whatever","description":null,"created_at":"2021-01-01T12:00:01"}' ) def test_yaml_message(self) -> None: @@ -87,9 +87,11 @@ def test_content_required(self) -> None: servo.pubsub.Message() assert { + "input": None, "loc": ("content",), - "msg": "none is not an allowed value", - "type": "type_error.none.not_allowed", + "msg": "Input should be a valid bytes", + "type": "bytes_type", + "url": "https://errors.pydantic.dev/2.7/v/bytes_type", } in excinfo.value.errors() def test_content_type_required(self) -> None: @@ -97,9 +99,11 @@ def test_content_type_required(self) -> None: servo.pubsub.Message(content=b"foo") assert { + "input": None, "loc": ("content_type",), - "msg": "none is not an allowed value", - "type": "type_error.none.not_allowed", + "msg": "Input should be a valid string", + "type": "string_type", + "url": "https://errors.pydantic.dev/2.7/v/string_type", } in excinfo.value.errors() @@ -127,9 +131,11 @@ def test_name_required(self, exchange: servo.pubsub.Exchange) -> None: servo.pubsub.Channel(exchange=exchange) assert { + "input": {}, "loc": ("name",), - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "type": "missing", + "url": "https://errors.pydantic.dev/2.7/v/missing", } in excinfo.value.errors() def test_name_constraints(self, exchange: servo.pubsub.Exchange) -> None: @@ -137,12 +143,12 @@ def test_name_constraints(self, exchange: servo.pubsub.Exchange) -> None: servo.pubsub.Channel(exchange=exchange, name="THIS_IS_INVALID***") assert { + "ctx": {"pattern": "^[0-9a-zA-Z]([0-9a-zA-Z\\.\\-_])*[0-9A-Za-z]$"}, + "input": "THIS_IS_INVALID***", "loc": ("name",), - "msg": 'string does not match regex "^[0-9a-zA-Z]([0-9a-zA-Z\\.\\-_])*[0-9A-Za-z]$"', - "type": "value_error.str.regex", - "ctx": { - "pattern": "^[0-9a-zA-Z]([0-9a-zA-Z\\.\\-_])*[0-9A-Za-z]$", - }, + "msg": "String should match pattern '^[0-9a-zA-Z]([0-9a-zA-Z\\.\\-_])*[0-9A-Za-z]$'", + "type": "string_pattern_mismatch", + "url": "https://errors.pydantic.dev/2.7/v/string_pattern_mismatch", } in excinfo.value.errors() def test_exchange_required(self) -> None: @@ -158,7 +164,7 @@ def test_hashing(self, channel: servo.pubsub.Channel) -> None: channels = { channel, } - copy_of_channel = channel.copy() + copy_of_channel = channel.model_copy() assert copy_of_channel in channels copy_of_channel.name = "another_name" assert copy_of_channel not in channels @@ -916,7 +922,7 @@ async def _aggregate_metrics( channel: servo.pubsub.Channel, ) -> None: if aggregator.message is None: - aggregator.message = message.copy() + aggregator.message = message.model_copy() else: text = "\n".join([aggregator.message.text, message.text]) aggregator.message = servo.pubsub.Message(text=text) @@ -944,7 +950,7 @@ async def _aggregate_metrics( channel: servo.pubsub.Channel, ) -> None: if aggregator.message is None: - aggregator.message = message.copy() + aggregator.message = message.model_copy() else: text = "\n".join([aggregator.message.text, message.text]) aggregator.message = servo.pubsub.Message(text=text) @@ -997,7 +1003,7 @@ async def _aggregate_metrics( channel: servo.pubsub.Channel, ) -> None: if aggregator.message is None: - aggregator.message = message.copy() + aggregator.message = message.model_copy() else: text = "\n".join([aggregator.message.text, message.text]) aggregator.message = servo.pubsub.Message(text=text) diff --git a/tests/telemetry_test.py b/tests/telemetry_test.py index 10baa4c9..6c4257e1 100644 --- a/tests/telemetry_test.py +++ b/tests/telemetry_test.py @@ -14,7 +14,7 @@ async def test_telemetry_hello( monkeypatch, optimizer: servo.configuration.OpsaniOptimizer ) -> None: - expected = f'"telemetry": {{"servox.version": "{servo.__version__}", "servox.platform": "{platform.platform()}", "servox.namespace": "test-namespace"}}' + expected = f'"telemetry":{{"servox.version":"{servo.__version__}","servox.platform":"{platform.platform()}","servox.namespace":"test-namespace"}}' # Simulate running as a k8s pod monkeypatch.setenv("POD_NAMESPACE", "test-namespace") @@ -142,7 +142,7 @@ async def test_diagnostics_put( method="PUT", endpoint=servo.telemetry.DIAGNOSTICS_OUTPUT_ENDPOINT, output_model=servo.api.Status, - json=diagnostic_data.dict(), + json=diagnostic_data.model_dump(), ) assert put.called From b8fbfc68c4d73dff34a2f59d3f16c22b1f3424b2 Mon Sep 17 00:00:00 2001 From: Linkous Sharp Date: Mon, 10 Jun 2024 18:08:20 -0500 Subject: [PATCH 7/8] Fix unit test failures in kubernetes_test.py --- servo/api.py | 6 +- servo/configuration.py | 35 ++--- servo/connectors/kube_metrics.py | 3 +- servo/connectors/kubernetes.py | 155 +++++++++++++--------- servo/connectors/vegeta.py | 2 +- servo/events.py | 2 +- servo/runner.py | 22 +-- servo/servo.py | 2 +- servo/telemetry.py | 2 +- servo/types/core.py | 10 +- servo/types/settings.py | 199 +++++++++++----------------- servo/types/slo.py | 28 ++-- tests/conftest.py | 9 +- tests/connector_test.py | 17 +-- tests/connectors/kubernetes_test.py | 186 ++++++++++++++------------ tests/connectors/opsani_dev_test.py | 2 +- tests/fake.py | 2 +- tests/fast_fail_test.py | 15 ++- tests/helpers.py | 2 +- tests/kubernetes_test.py | 47 ++++--- tests/types/settings_test.py | 8 +- 21 files changed, 389 insertions(+), 365 deletions(-) diff --git a/servo/api.py b/servo/api.py index 06da897c..0cf8dfc0 100644 --- a/servo/api.py +++ b/servo/api.py @@ -163,14 +163,16 @@ def from_error( return cls(status=status, message=str(error), reason=reason, **kwargs) - def dict( + def model_dump( self, *, exclude_unset: bool = True, by_alias: bool = True, **kwargs, ) -> DictStrAny: - return super().dict(exclude_unset=exclude_unset, by_alias=by_alias, **kwargs) + return super().model_dump( + exclude_unset=exclude_unset, by_alias=by_alias, **kwargs + ) model_config = pydantic.ConfigDict(populate_by_name=True) diff --git a/servo/configuration.py b/servo/configuration.py index 3335fa84..9c0be307 100644 --- a/servo/configuration.py +++ b/servo/configuration.py @@ -312,11 +312,9 @@ def yaml( include: Union[pydantic.AbstractSetIntStr, pydantic.MappingIntStrAny] = None, exclude: Union[pydantic.AbstractSetIntStr, pydantic.MappingIntStrAny] = None, by_alias: bool = False, - skip_defaults: bool = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, - encoder: Optional[Callable[[Any], Any]] = None, **dumps_kwargs: Any, ) -> str: """ @@ -325,15 +323,13 @@ def yaml( Arguments are passed through to the Pydantic `BaseModel.json` method. """ # NOTE: We have to serialize through JSON first (not all fields serialize directly to YAML) - config_json = self.json( + config_json = self.model_dump_json( include=include, exclude=exclude, by_alias=by_alias, - skip_defaults=skip_defaults, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, - encoder=encoder, **dumps_kwargs, ) return yaml.dump(json.loads(config_json), sort_keys=False) @@ -388,6 +384,10 @@ class BackoffSettings(AbstractBaseConfiguration): The maximum amount of time to retry before giving up. """ + @pydantic.field_serializer("max_time") + def serialize_courses_in_order(max_time: servo.types.Duration): + return str(max_time) + max_tries: Optional[int] """ The maximum number of retry attempts to make before giving up. @@ -657,17 +657,17 @@ def generate( """ Generates configuration for the servo assembly. """ - for name, field in cls.__fields__.items(): + for name, field in cls.model_fields.items(): if ( name not in kwargs - and inspect.isclass(field.type_) - and issubclass(field.type_, AbstractBaseConfiguration) + and inspect.isclass(field.annotation) + and issubclass(field.annotation, AbstractBaseConfiguration) ): - if inspect.isgeneratorfunction(field.type_.generate): - for name, config in field.type_.generate(): + if inspect.isgeneratorfunction(field.annotation.generate): + for name, config in field.annotation.generate(): kwargs[name] = config else: - if config := field.type_.generate(): + if config := field.annotation.generate(): kwargs[name] = config if "optimizer" not in kwargs: @@ -743,8 +743,11 @@ class FastFailConfiguration(pydantic_settings.BaseSettings): env_prefix="SERVO_", ) - @pydantic.field_validator("span", mode="before") - def span_defaults_to_period(cls, v, *, values, **kwargs): - if v is None: - return values["period"] - return v + @pydantic.model_validator(mode="before") + def span_defaults_to_period(cls, values): + if values.get("span", None) is None: + if "period" in values: + values["span"] = values["period"] + else: + values["span"] = "60s" + return values diff --git a/servo/connectors/kube_metrics.py b/servo/connectors/kube_metrics.py index ccac2a0c..db2daeda 100644 --- a/servo/connectors/kube_metrics.py +++ b/servo/connectors/kube_metrics.py @@ -121,10 +121,11 @@ class KubeMetricsConfiguration(servo.BaseConfiguration): description="How often to get metrics from the metrics-server. Default is once per minute", ) kubeconfig: Optional[pydantic.FilePath] = pydantic.Field( + None, description="Path to the kubeconfig file. If `None`, use the default from the environment.", ) context: Optional[str] = pydantic.Field( - description="Name of the kubeconfig context to use." + None, description="Name of the kubeconfig context to use." ) @pydantic.field_validator("metrics_to_collect") diff --git a/servo/connectors/kubernetes.py b/servo/connectors/kubernetes.py index f4a4f611..945f79ac 100644 --- a/servo/connectors/kubernetes.py +++ b/servo/connectors/kubernetes.py @@ -41,6 +41,9 @@ Type, Union, cast, + get_args, + get_origin, + override, ) import kubernetes_asyncio @@ -100,8 +103,14 @@ def __get_pydantic_core_schema__( # def __get_validators__(cls) -> pydantic.types.CallableGenerator: # yield cls.parse + @staticmethod + def try_parse(v: Union[str, int, float]) -> Optional["Core"]: + if v is None: + return v + return Core.parse(v) + @classmethod - def parse(cls, v: pydantic.types.StrIntFloat) -> "Core": + def parse(cls, v: Union[str, int, float]) -> "Core": """ Parses a string, integer, or float input value into Core units. @@ -191,11 +200,16 @@ class CPU(servo.CPU): min: Core max: Core step: Core - value: Optional[Core] + value: Optional[Core] = None + + _parse_min_core = pydantic.field_validator("min", mode="before")(Core.try_parse) + _parse_max_core = pydantic.field_validator("max", mode="before")(Core.try_parse) + _parse_step_core = pydantic.field_validator("step", mode="before")(Core.try_parse) + _parse_value_core = pydantic.field_validator("value", mode="before")(Core.try_parse) # Kubernetes resource requirements - request: Optional[Core] - limit: Optional[Core] + request: Optional[Core] = None + limit: Optional[Core] = None get: list[ResourceRequirement] = pydantic.Field( default=[ ResourceRequirement.request, @@ -226,31 +240,12 @@ def __opsani_repr__(self) -> dict: return o_dict -# Gibibyte is the base unit of Kubernetes memory -MiB = 2**20 GiB = 2**30 -class ShortByteSize(pydantic.ByteSize): +class ReadableShortByteSize(pydantic.ByteSize): """Kubernetes omits the 'B' suffix for some reason""" - @classmethod - def validate(cls, v: pydantic.StrIntFloat) -> "ShortByteSize": - if isinstance(v, str): - # Unitless decimals are not use by k8s API but are used in servo protocol implicitly as GiB - if re.match(r"^\d*\.\d+$", v): - v = f"{v}GiB" - - try: - return super().validate(v) - except: - # Append the byte suffix and retry parsing - return super().validate(v + "b") - elif isinstance(v, float): - # Unitless decimals are not use by k8s API but are used in servo protocol implicitly as GiB - v = v * GiB - return super().validate(v) - def human_readable(self) -> str: """NOTE: only represents precision up to 1 decimal place (see pydantic's human_readable)""" sup = super().human_readable() @@ -273,6 +268,23 @@ def __str__(self) -> str: return f"{num:f}Ei" +def default_gib(v: Union[str, int, float]) -> pydantic.ByteSize: + if isinstance(v, float) or (isinstance(v, str) and re.match(r"^\d*\.\d+$", v)): + # Unitless decimals are not use by k8s API but are used in servo protocol implicitly as GiB + v = f"{v}GiB" + + return v + + +ShortByteSize = Annotated[ + ReadableShortByteSize, + pydantic.BeforeValidator(default_gib), + pydantic.PlainSerializer( + lambda x: x.human_readable(), return_type=str, when_used="json" + ), +] + + class Memory(servo.Memory): """ The Memory class models a Kubernetes Memory resource. @@ -281,11 +293,11 @@ class Memory(servo.Memory): min: ShortByteSize max: ShortByteSize step: ShortByteSize - value: Optional[ShortByteSize] + value: Optional[ShortByteSize] = None # Kubernetes resource requirements - request: Optional[ShortByteSize] - limit: Optional[ShortByteSize] + request: Optional[ShortByteSize] = None + limit: Optional[ShortByteSize] = None get: list[ResourceRequirement] = pydantic.Field( default=[ ResourceRequirement.request, @@ -301,6 +313,11 @@ class Memory(servo.Memory): min_length=1, ) + # convert parent output to bytes to maximize readability + @override + def _suggest_step_aligned_values(self) -> tuple[ShortByteSize, ShortByteSize]: + return (ShortByteSize(v) for v in super()._suggest_step_aligned_values()) + def __opsani_repr__(self) -> dict: o_dict = super().__opsani_repr__() @@ -1751,12 +1768,12 @@ class ContainerConfiguration(servo.BaseConfiguration): """ name: ContainerTagName - alias: Optional[ContainerTagName] - command: Optional[str] # TODO: create model... + alias: Optional[ContainerTagName] = None + command: Optional[str] = None # TODO: create model... cpu: CPU memory: Memory - env: Optional[servo.EnvironmentSettingList] - static_environment_variables: Optional[Dict[str, str]] + env: Optional[servo.EnvironmentSettingList] = None + static_environment_variables: Optional[Dict[str, str]] = None class OptimizationStrategy(enum.StrEnum): @@ -1775,8 +1792,6 @@ class OptimizationStrategy(enum.StrEnum): class BaseOptimizationStrategyConfiguration(pydantic.BaseModel): - type: OptimizationStrategy - def __eq__(self, other) -> bool: if isinstance(other, OptimizationStrategy): return self.type == other @@ -1786,11 +1801,11 @@ def __eq__(self, other) -> bool: class DefaultOptimizationStrategyConfiguration(BaseOptimizationStrategyConfiguration): - type: OptimizationStrategy = pydantic.Field(OptimizationStrategy.default) + type: Literal["default"] class CanaryOptimizationStrategyConfiguration(BaseOptimizationStrategyConfiguration): - type: OptimizationStrategy = pydantic.Field(OptimizationStrategy.canary) + type: Literal["canary"] alias: Optional[ContainerTagName] = None @@ -1862,24 +1877,27 @@ class BaseKubernetesConfiguration(servo.BaseConfiguration): """ kubeconfig: Optional[pydantic.FilePath] = pydantic.Field( + None, description="Path to the kubeconfig file. If `None`, use the default from the environment.", ) context: Optional[str] = pydantic.Field( - description="Name of the kubeconfig context to use." + None, description="Name of the kubeconfig context to use." ) namespace: Optional[DNSSubdomainName] = pydantic.Field( - ..., + None, description="Kubernetes namespace where the target deployments are running.", ) settlement: Optional[servo.Duration] = pydantic.Field( - description="Duration to observe the application after an adjust to ensure the deployment is stable. May be overridden by optimizer supplied `control.adjust.settlement` value." + None, + description="Duration to observe the application after an adjust to ensure the deployment is stable. May be overridden by optimizer supplied `control.adjust.settlement` value.", ) on_failure: FailureMode = pydantic.Field( FailureMode.exception, description=f"How to handle a failed adjustment. Options are: {servo.utilities.strings.join_to_series(list(FailureMode.__members__.values()))}", ) timeout: Optional[servo.Duration] = pydantic.Field( - description="Time interval to wait before considering Kubernetes operations to have failed." + None, + description="Time interval to wait before considering Kubernetes operations to have failed.", ) container_logs_in_error_status: bool = pydantic.Field( False, description="Enable to include container logs in error message" @@ -1899,13 +1917,6 @@ def validate_failure_mode(cls, v): return v -StrategyTypes = Union[ - OptimizationStrategy, - DefaultOptimizationStrategyConfiguration, - CanaryOptimizationStrategyConfiguration, -] - - class DeploymentConfiguration(BaseKubernetesConfiguration): """ The DeploymentConfiguration class models the configuration of an optimizable Kubernetes Deployment. @@ -1913,9 +1924,22 @@ class DeploymentConfiguration(BaseKubernetesConfiguration): name: DNSSubdomainName containers: List[ContainerConfiguration] - strategy: StrategyTypes = OptimizationStrategy.default + strategy: Union[ + DefaultOptimizationStrategyConfiguration, + CanaryOptimizationStrategyConfiguration, + ] = pydantic.Field(OptimizationStrategy.default.value, discriminator="type") replicas: servo.Replicas + @pydantic.field_validator("strategy", mode="before") + def _strategy_str_to_config(cls, v: Any) -> Any: + if isinstance(v, (str)): + return {"type": v} + + if isinstance(v, OptimizationStrategy): + return {"type": str(v)} + + return v + class StatefulSetConfiguration(DeploymentConfiguration): @pydantic.field_validator("strategy") @@ -1938,10 +1962,12 @@ class KubernetesConfiguration(BaseKubernetesConfiguration): # TODO streamlining with a 'workloads' property name as the these three are used for the same purpose. Their # differences are a k8s implementation detail, not relevant to servox beyond the variance in API calls stateful_sets: Optional[List[StatefulSetConfiguration]] = pydantic.Field( + None, description="StatefulSets to be optimized.", ) deployments: Optional[List[DeploymentConfiguration]] = pydantic.Field( + None, description="Deployments to be optimized.", ) @@ -1998,8 +2024,15 @@ def cascade_common_settings(self, *, overwrite: bool = False) -> None: # FIXME: Cascaded settings should only be optional if they can be optional at the top level. Right now we are implying that namespace can be None as well. """ - for name, field in self.__fields__.items(): - if issubclass(field.type_, BaseKubernetesConfiguration): + for name, field_info in self.model_fields.items(): + field_type = field_info.annotation + while True: + if get_origin(field_type) in [Union, list, Annotated]: + field_type = get_args(field_type)[0] + else: + break + + if issubclass(field_type, BaseKubernetesConfiguration): attribute = getattr(self, name) for obj in ( attribute if isinstance(attribute, Collection) else [attribute] @@ -2008,30 +2041,30 @@ def cascade_common_settings(self, *, overwrite: bool = False) -> None: if obj is None: continue for ( - field_name, - field, - ) in BaseKubernetesConfiguration.__fields__.items(): - if field_name in servo.BaseConfiguration.__fields__: + bkc_field_name, + bkc_field_info, + ) in BaseKubernetesConfiguration.model_fields.items(): + if bkc_field_name in servo.BaseConfiguration.model_fields: # don't cascade from the base class continue - if field_name in obj.__fields_set__ and not overwrite: + if bkc_field_name in obj.model_fields_set and not overwrite: self.logger.trace( - f"skipping config cascade for field '{field_name}' set with value '{getattr(obj, field_name)}'" + f"skipping config cascade for field '{bkc_field_name}' set with value '{getattr(obj, bkc_field_name)}'" ) continue - current_value = getattr(obj, field_name) - if overwrite or current_value == field.default: - parent_value = getattr(self, field_name) - setattr(obj, field_name, parent_value) + current_value = getattr(obj, bkc_field_name) + if overwrite or current_value == bkc_field_info.default: + parent_value = getattr(self, bkc_field_name) + setattr(obj, bkc_field_name, parent_value) self.logger.trace( - f"cascaded setting '{field_name}' from KubernetesConfiguration to child '{attribute}': value={parent_value}" + f"cascaded setting '{bkc_field_name}' from KubernetesConfiguration to child '{attribute}': value={parent_value}" ) else: self.logger.trace( - f"declining to cascade value to field '{field_name}': the default value is set and overwrite is false" + f"declining to cascade value to field '{bkc_field_name}': the default value is set and overwrite is false" ) async def load_kubeconfig(self) -> None: diff --git a/servo/connectors/vegeta.py b/servo/connectors/vegeta.py index f9750a62..2bee209f 100644 --- a/servo/connectors/vegeta.py +++ b/servo/connectors/vegeta.py @@ -512,7 +512,7 @@ def _time_series_readings_from_vegeta_reports( data_points: List[servo.DataPoint] = [] for report in vegeta_reports: - value = servo.value_for_key_path(report.dict(by_alias=True), key) + value = servo.value_for_key_path(report.model_dump(by_alias=True), key) data_points.append(servo.DataPoint(metric, report.end, value)) readings.append(servo.TimeSeries(metric, data_points)) diff --git a/servo/events.py b/servo/events.py index d9b7027e..93e8b652 100644 --- a/servo/events.py +++ b/servo/events.py @@ -146,7 +146,7 @@ def dict( if exclude is None: exclude = set() exclude.add("on_handler_context_manager") - return super().dict( + return super().model_dump( include=include, exclude=exclude, by_alias=by_alias, diff --git a/servo/runner.py b/servo/runner.py index 1e5111f3..0e988cba 100644 --- a/servo/runner.py +++ b/servo/runner.py @@ -165,11 +165,13 @@ async def exec_command(self) -> servo.api.Status: error=top_error, command_uid=cmd_response.command_uid, ) - self.logger.error(f"Responding with {status.dict()}") + self.logger.error(f"Responding with {status.model_dump()}") self.logger.opt(exception=error_group).debug("Describe failure details") self.clear_progress_queue() - return await self.servo.post_event(servo.api.Events.describe, status.dict()) + return await self.servo.post_event( + servo.api.Events.describe, status.model_dump() + ) elif cmd_response.command == servo.api.Commands.measure: try: @@ -189,11 +191,13 @@ async def exec_command(self) -> servo.api.Status: error=top_error, command_uid=cmd_response.command_uid, ) - self.logger.error(f"Responding with {status.dict()}") + self.logger.error(f"Responding with {status.model_dump()}") self.logger.opt(exception=error_group).debug("Measure failure details") self.clear_progress_queue() - return await self.servo.post_event(servo.api.Events.measure, status.dict()) + return await self.servo.post_event( + servo.api.Events.measure, status.model_dump() + ) elif cmd_response.command == servo.api.Commands.adjust: adjustments = servo.api.descriptor_to_adjustments( @@ -223,11 +227,13 @@ async def exec_command(self) -> servo.api.Status: error=top_error, command_uid=cmd_response.command_uid, ) - self.logger.error(f"Responding with {status.dict()}") + self.logger.error(f"Responding with {status.model_dump()}") self.logger.opt(exception=error_group).debug("Adjust failure details") self.clear_progress_queue() - return await self.servo.post_event(servo.api.Events.adjust, status.dict()) + return await self.servo.post_event( + servo.api.Events.adjust, status.model_dump() + ) elif cmd_response.command == servo.api.Commands.sleep: # TODO: Model this @@ -758,8 +764,8 @@ async def _handle_progress_exception( status = servo.api.Status.from_error( error=error, command_uid=command_uid ) - self.logger.error(f"Responding with {status.dict()}") - await servo_.post_event(operation, status.dict()) + self.logger.error(f"Responding with {status.model_dump()}") + await servo_.post_event(operation, status.model_dump()) if self.shutting_down: self.logger.warning( diff --git a/servo/servo.py b/servo/servo.py index d0db8514..9ace06dc 100644 --- a/servo/servo.py +++ b/servo/servo.py @@ -541,7 +541,7 @@ async def report_progress(self, **kwargs) -> None: raise servo.errors.EventCancelledError(status.reason or "Command cancelled") elif status.status == servo.api.OptimizerStatuses.invalid: self.logger.warning( - f"progress report was rejected as invalid: {devtools.pformat(status.dict())}" + f"progress report was rejected as invalid: {devtools.pformat(status.model_dump())}" ) if status.reason == "unexpected cmd_uid": raise servo.errors.UnexpectedCommandIdError(status.reason) diff --git a/servo/telemetry.py b/servo/telemetry.py index debe108d..4b9e96c7 100644 --- a/servo/telemetry.py +++ b/servo/telemetry.py @@ -115,7 +115,7 @@ async def diagnostics_check(self) -> None: method="PUT", endpoint=DIAGNOSTICS_OUTPUT_ENDPOINT, output_model=servo.api.Status, - json=diagnostic_data.dict(), + json=diagnostic_data.model_dump(), ) # Reset diagnostics check state to withhold diff --git a/servo/types/core.py b/servo/types/core.py index a0b5471d..612a8b0a 100644 --- a/servo/types/core.py +++ b/servo/types/core.py @@ -193,6 +193,7 @@ class Duration(datetime.timedelta): def __new__( cls, duration: Union[str, Numeric, datetime.timedelta] = 0, + *args, **kwargs, ) -> datetime.timedelta: seconds = kwargs.pop("seconds", 0) @@ -218,11 +219,10 @@ def __new__( cls, seconds=seconds, microseconds=microseconds, **kwargs ) - def __init__( - self, duration: Union[str, datetime.timedelta, Numeric] = 0, **kwargs - ) -> None: # noqa: D107 - # Add a type signature so we don't get warning from linters. Implementation is not used (see __new__) - ... + # def __init__( + # self, duration: Union[str, datetime.timedelta, Numeric] = 0, *args, **kwargs + # ) -> Duration: + # return self.__class__.__new__(duration, *args, **kwargs) # @classmethod # def __get_validators__(cls): diff --git a/servo/types/settings.py b/servo/types/settings.py index f908574d..e6b5c364 100644 --- a/servo/types/settings.py +++ b/servo/types/settings.py @@ -15,8 +15,8 @@ import abc import decimal import enum -import functools from inspect import isclass +import typing import pydantic import pydantic_core import pydantic.fields @@ -25,7 +25,6 @@ Annotated, Any, Callable, - Generator, Literal, Optional, Type, @@ -125,8 +124,23 @@ def _validate_pinned_values_cannot_be_changed(self, new_value) -> None: raise pydantic.ValidationError([error_], self.__class__) @classmethod - def get_setting_type(cls) -> Type[Any]: - return cls.__fields__["value"].type_ + def get_setting_type(cls, unwrap_union=False) -> Type[Any]: + value_type = cls.model_fields["value"].annotation + if unwrap_union and get_origin(value_type) is Union: + none_filtered = [ + a for a in typing.get_args(value_type) if a is not type(None) + ] + if len(none_filtered) == 1: + value_type = none_filtered[0] + else: + import servo + + servo.logger.warning( + f"unable to determine inner type for Union field value of model {cls}" + ) + value_type = str + + return value_type @classmethod def human_readable(cls, value: Any) -> str: @@ -176,7 +190,7 @@ class EnumSetting(Setting): @pydantic.model_validator(mode="before") @classmethod def _validate_value_in_values(cls, values: dict[str, Any]) -> dict[str, Any]: - value, options = values["value"], values["values"] + value, options = values.get("value", None), values["values"] if value is not None and value not in options: raise ValueError( f"invalid value: {repr(value)} is not in the values list {repr(options)}" @@ -191,12 +205,13 @@ def summary(self) -> str: def __opsani_repr__(self) -> dict[str, dict[Any, Any]]: return { - self.name: self.dict( + self.name: self.model_dump( include={"type", "unit", "values", "pinned", "value"}, exclude_none=True ) } +# TODO implement generics if possible class RangeSetting(Setting): """RangeSetting objects describe an inclusive span of numeric values that can be applied to an adjustable parameter. @@ -234,12 +249,13 @@ class RangeSetting(Setting): def summary(self) -> str: return f"{self.__class__.__name__}(range=[{self.human_readable(self.min)}..{self.human_readable(self.max)}], step={self.human_readable(self.step)}, unit={self.unit})" - @pydantic.model_validator(mode="before") - @classmethod - def _attributes_must_be_of_same_type(cls, values: dict[str, Any]) -> dict[str, Any]: + @pydantic.model_validator(mode="after") + def _attributes_must_be_of_same_type(self) -> "RangeSetting": range_types: dict[TypeVar, list[str]] = {} for attr in ("min", "max", "step"): - value = values[attr] if attr in values else cls.__fields__[attr].default + value = getattr(self, attr, None) + if value is None: + value = self.__class__.model_fields[attr].default attr_cls = value.__class__ if attr_cls in range_types: range_types[attr_cls].append(attr) @@ -260,52 +276,67 @@ def _attributes_must_be_of_same_type(cls, values: dict[str, Any]) -> dict[str, A f"invalid range: min, max, and step must all be of the same Numeric type ({desc})" ) - return values + return self - @pydantic.model_validator(mode="before") - @classmethod - def _value_must_fall_in_range(cls, values) -> Numeric: - value, min, max = values["value"], values["min"], values["max"] - if value is not None and (value < min or value > max): + @pydantic.model_validator(mode="after") + def _value_must_fall_in_range(self) -> "RangeSetting": + cls = self.__class__ + if self.value is not None and (self.value < self.min or self.value > self.max): import servo servo.logger.warning( - f"invalid value: {cls.human_readable(value)} is outside of the range {cls.human_readable(min)}-{cls.human_readable(max)}" + f"invalid value: {cls.human_readable(self.value)} is outside of the range {cls.human_readable(self.min)}-{cls.human_readable(self.max)}" ) - return values - - @pydantic.field_validator("max") - @classmethod - def _max_must_define_valid_range(cls, max_: Numeric, values) -> Numeric: - if not "min" in values: - # can't validate if we don't have a min (likely failed validation) - return max_ + return self - min_ = values["min"] - if min_ > max_: + @pydantic.model_validator(mode="after") + def _max_must_define_valid_range(self) -> "RangeSetting": + cls = self.__class__ + if self.min > self.max: import servo servo.logger.warning( - f"min cannot be greater than max ({cls.human_readable(min_)} > {cls.human_readable(max_)})" + f"min cannot be greater than max ({cls.human_readable(self.min)} > {cls.human_readable(self.max)})" ) - return max_ + return self - @pydantic.model_validator(mode="before") + def _suggest_step_aligned_values(self) -> tuple[Numeric, Numeric]: + # FIXME + range_size = decimal.Decimal(self.max - self.min) + lower_bound, upper_bound = range_size, range_size + + # Find the values that are closest on either side of the value + # Ensure the smaller size isn't smaller than step + remainder = range_size % self.step + + # lower bound -- align by offseting by remainder + lower_bound -= remainder + assert lower_bound % self.step == 0 + if lower_bound <= self.step: + lower_bound = self.step + + # upper bound -- start from the lower bound and find the next value + upper_bound = lower_bound + self.step + if upper_bound == range_size: + upper_bound += self.step + + return (lower_bound, upper_bound) + + @pydantic.model_validator(mode="after") @classmethod - def _min_and_max_must_be_step_aligned( - cls, values: dict[str, Any] - ) -> dict[str, Any]: + def _min_and_max_must_be_step_aligned(cls, value: "RangeSetting") -> dict[str, Any]: name, min_, max_, step = ( - values["name"], - values["min"], - values["max"], - values["step"], + value.name, + value.min, + value.max, + value.step, ) if max_ is not None and min_ is not None: - diff = max_ - min_ + value_type = cls.get_setting_type(unwrap_union=True) + diff = value_type(max_ - min_) if step == 0 and diff == 0: pass elif step != 0 and diff == 0: @@ -322,26 +353,19 @@ def _min_and_max_must_be_step_aligned( servo.logger.warning(f"step cannot be zero") if _is_step_aligned(diff, step): - return values + return value else: import servo - smaller_range, larger_range = _suggest_step_aligned_values(diff, step) + smaller_range, larger_range = value._suggest_step_aligned_values() desc = f"{cls.__name__}({repr(name)} {cls.human_readable(min_)}-{cls.human_readable(max_)}, {cls.human_readable(step)})" # try new error handling and fall back to old if bugs try: - value_type = cls.get_setting_type() - if get_origin(value_type) is Union: - value_type = str - cast_diff, lower_min, upper_min, lower_max, upper_max = ( - value_type(v) - for v in ( - diff, - max_ - smaller_range, - max_ - larger_range, - min_ + smaller_range, - min_ + larger_range, - ) + lower_min, upper_min, lower_max, upper_max = ( + value_type(max_ - smaller_range), + value_type(max_ - larger_range), + value_type(min_ + smaller_range), + value_type(min_ + larger_range), ) except: servo.logger.exception( @@ -353,19 +377,19 @@ def _min_and_max_must_be_step_aligned( ) else: servo.logger.warning( - f"{desc} min/max difference is not step aligned: {cast_diff} is not a multiple of {step} (consider " + f"{desc} min/max difference is not step aligned: {diff} is not a multiple of {step} (consider " f"min {lower_min} or {upper_min}, max {lower_max} " f"or {upper_max})." ) - return values + return value def __str__(self) -> str: return f"{self.name} ({self.type} {self.human_readable(self.min)}-{self.human_readable(self.max)}, {self.human_readable(self.step)})" def __opsani_repr__(self) -> dict[str, dict[Any, Any]]: return { - self.name: self.dict( + self.name: self.model_dump( include={"type", "unit", "min", "max", "step", "pinned", "value"}, exclude_none=True, ) @@ -504,71 +528,6 @@ class InstanceType(EnumSetting): ) -def _suggest_step_aligned_values( - value: Numeric, - step: Numeric, - *, - in_repr: Optional[Callable[[Numeric], Union[str, float, int]]] = None, -) -> tuple[str, str]: - if in_repr is None: - # return raw data for further processing - in_repr = lambda x: x - - # declare numeric and textual representations - parser = functools.partial(pydantic.TypeAdapter.validate_python, value.__class__) - value_dec, step_dec = decimal.Decimal(str(float(value))), decimal.Decimal( - str(float(step)) - ) - lower_bound, upper_bound = value_dec, value_dec - value_repr, lower_repr, upper_repr = in_repr(parser(value_dec)), None, None - - # Find the values that are closest on either side of the value - # Don't recommend anything smaller than step - while value_dec < step_dec: - value_dec += step_dec - - remainder = value_dec % step_dec - - # lower bound -- align by offseting by remainder - lower_bound -= remainder - assert lower_bound % step_dec == 0 - while True: - # only decrement after first iteration - if lower_repr is not None: - lower_bound -= step_dec - - # if we dip below the step, anchor on it as the minimum value - if lower_bound <= step_dec: - lower_bound = step_dec - lower_repr = in_repr(parser(lower_bound)) - break - - lower_repr = in_repr(parser(lower_bound)) - # if we are step aligned take the current value as lower bound - if remainder != 0 and lower_repr == value_repr: - continue - - # round trip the value to make sure its not a lossy representation - repr_decimal = decimal.Decimal(str(float(parser(lower_repr)))) - if repr_decimal % step_dec == 0: - break - - # upper bound -- start from the lower bound and find the next value - upper_bound = lower_bound - while True: - upper_bound += step_dec - upper_repr = in_repr(parser(upper_bound)) - if upper_repr == value_repr: - continue - - # round trip the value to make sure its not a lossy representation - repr_decimal = decimal.Decimal(str(float(parser(upper_repr)))) - if repr_decimal % step_dec == 0: - break - - return (lower_repr, upper_repr) - - class EnvironmentSetting(Setting): literal: Optional[str] = pydantic.Field( None, diff --git a/servo/types/slo.py b/servo/types/slo.py index ae68b680..b18d1da5 100644 --- a/servo/types/slo.py +++ b/servo/types/slo.py @@ -84,27 +84,23 @@ def _check_duplicated_minimum(cls, values): return values - @pydantic.field_validator("trigger_window", mode="before") - @classmethod - def _trigger_window_defaults_to_trigger_count(cls, v, *, values, **kwargs): - if v is None: - return values["trigger_count"] - return v - @pydantic.model_validator(mode="before") @classmethod - def _trigger_count_cannot_be_greater_than_window(cls, values) -> Numeric: - trigger_window, trigger_count = ( - values["trigger_window"], - values["trigger_count"], - ) - if trigger_count > trigger_window: - raise ValueError( - f"trigger_count cannot be greater than trigger_window ({trigger_count} > {trigger_window})" + def _trigger_window_defaults_to_trigger_count(cls, values): + if values.get("trigger_window") is None: + values["trigger_window"] = values.get( + "trigger_count", cast(TriggerConstraints, 1) ) - return values + @pydantic.model_validator(mode="after") + def _trigger_count_cannot_be_greater_than_window(self) -> Numeric: + if self.trigger_count > self.trigger_window: + raise ValueError( + f"trigger_count cannot be greater than trigger_window ({self.trigger_count} > {self.trigger_window})" + ) + return self + def __str__(self) -> str: ret_str = f"{self.metric} {self.keep}" diff --git a/tests/conftest.py b/tests/conftest.py index 1b79e361..99a40245 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -374,7 +374,7 @@ def stub_servo_yaml(tmp_path: pathlib.Path) -> pathlib.Path: settings = servo.BaseConfiguration() measure_config_json = json.loads( json.dumps( - settings.dict( + settings.model_dump( by_alias=True, ) ) @@ -396,7 +396,7 @@ def stub_multiservo_yaml(tmp_path: pathlib.Path) -> pathlib.Path: settings = tests.helpers.BaseConfiguration() measure_config_json = json.loads( json.dumps( - settings.dict( + settings.model_dump( by_alias=True, ) ) @@ -673,10 +673,11 @@ async def assembly(servo_yaml: pathlib.Path) -> servo.assembly.Assembly: config_model = servo.assembly._create_config_model_from_routes( { "adjust": tests.helpers.AdjustConnector, - } + }, + require_fields=False, ) config = config_model.generate() - servo_yaml.write_text(config.yaml()) + servo_yaml.write_text(config.yaml(warnings=False, exclude_unset=True)) assembly_ = await servo.assembly.Assembly.assemble(config_file=servo_yaml) return assembly_ diff --git a/tests/connector_test.py b/tests/connector_test.py index c9906f54..529eb745 100644 --- a/tests/connector_test.py +++ b/tests/connector_test.py @@ -6,6 +6,7 @@ from pathlib import Path import httpx +import pydantic import pytest import pytest_mock import respx @@ -190,9 +191,6 @@ def test_duration_schema(self) -> None: } def test_configuring_with_environment_variables(self) -> None: - assert BaseConfiguration.__fields__["description"].field_info.extra[ - "env_names" - ] == {"BASE_DESCRIPTION"} with environment_overrides({"BASE_DESCRIPTION": "this description"}): assert os.environ["BASE_DESCRIPTION"] == "this description" s = BaseConfiguration() @@ -1090,7 +1088,7 @@ def test_vegeta_cli_validate( ) -> None: config_file = tmp_path / "vegeta.yaml" config = VegetaConfiguration.generate() - write_config_yaml({"vegeta": config.dict(exclude_unset=True)}, config_file) + write_config_yaml({"vegeta": config.model_dump(exclude_unset=True)}, config_file) result = cli_runner.invoke( servo_cli, "validate -f vegeta.yaml", catch_exceptions=False @@ -1106,7 +1104,7 @@ def test_vegeta_cli_validate_quiet( ) -> None: config_file = tmp_path / "vegeta.yaml" config = VegetaConfiguration.generate() - write_config_yaml({"vegeta": config.dict(exclude_unset=True)}, config_file) + write_config_yaml({"vegeta": config.model_dump(exclude_unset=True)}, config_file) result = cli_runner.invoke(servo_cli, "validate -q -f vegeta.yaml") assert ( result.exit_code == 0 @@ -1121,8 +1119,8 @@ def test_vegeta_cli_validate_dict( config = VegetaConfiguration.generate() config_dict = { "connectors": {"first": "vegeta", "second": "vegeta"}, - "first": config.dict(exclude_unset=True), - "second": config.dict(exclude_unset=True), + "first": config.model_dump(exclude_unset=True), + "second": config.model_dump(exclude_unset=True), } write_config_yaml(config_dict, config_file) @@ -1153,7 +1151,7 @@ def test_vegeta_cli_validate_invalid_key( ) -> None: config_file = tmp_path / "vegeta.yaml" config = VegetaConfiguration.generate() - write_config_yaml({"nonsense": config.dict(exclude_unset=True)}, config_file) + write_config_yaml({"nonsense": config.model_dump(exclude_unset=True)}, config_file) result = cli_runner.invoke(servo_cli, "validate -f vegeta.yaml") assert ( result.exit_code == 1 @@ -1322,8 +1320,7 @@ async def example_event(self) -> int: async def get_event_context(self) -> Optional[EventContext]: return servo.current_event() - class Config: - extra = Extra.allow + model_config = pydantic.ConfigDict(extra="allow") class AnotherFakeConnector(FakeConnector): @event(handler=True) diff --git a/tests/connectors/kubernetes_test.py b/tests/connectors/kubernetes_test.py index ff05f369..1df21df6 100644 --- a/tests/connectors/kubernetes_test.py +++ b/tests/connectors/kubernetes_test.py @@ -85,12 +85,12 @@ def test_cannot_be_blank(self, model) -> None: model(name=invalid_name) assert e assert { + "ctx": {"min_length": 1}, + "input": "", "loc": ("name",), - "msg": "ensure this value has at least 1 characters", - "type": "value_error.any_str.min_length", - "ctx": { - "limit_value": 1, - }, + "msg": "String should have at least 1 character", + "type": "string_too_short", + "url": "https://errors.pydantic.dev/2.7/v/string_too_short", } in e.value.errors() def test_handles_uppercase_chars(self, model) -> None: @@ -106,12 +106,13 @@ def test_cannot_be_longer_than_253_chars(self, model) -> None: model(name=invalid_name) assert e assert { + "ctx": {"max_length": 253}, + "input": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab", "loc": ("name",), - "msg": "ensure this value has at most 253 characters", - "type": "value_error.any_str.max_length", - "ctx": { - "limit_value": 253, - }, + "msg": "String should have at most 253 characters", + "type": "string_too_long", + "url": "https://errors.pydantic.dev/2.7/v/string_too_long", } in e.value.errors() def test_can_only_contain_alphanumerics_hyphens_and_dots(self, model) -> None: @@ -123,12 +124,12 @@ def test_can_only_contain_alphanumerics_hyphens_and_dots(self, model) -> None: model(name=invalid_name) assert e assert { + "ctx": {"pattern": "^[0-9a-zA-Z]([0-9a-zA-Z\\\\.-])*[0-9A-Za-z]$"}, + "input": "abcd1234.-sss_$%!", "loc": ("name",), - "msg": f'string does not match regex "{DNS_SUBDOMAIN_NAME_REGEX}"', - "type": "value_error.str.regex", - "ctx": { - "pattern": DNS_SUBDOMAIN_NAME_REGEX, - }, + "msg": "String should match pattern '^[0-9a-zA-Z]([0-9a-zA-Z\\\\.-])*[0-9A-Za-z]$'", + "type": "string_pattern_mismatch", + "url": "https://errors.pydantic.dev/2.7/v/string_pattern_mismatch", } in e.value.errors() def test_must_start_with_alphanumeric_character(self, model) -> None: @@ -140,12 +141,12 @@ def test_must_start_with_alphanumeric_character(self, model) -> None: model(name=invalid_name) assert e assert { + "ctx": {"pattern": "^[0-9a-zA-Z]([0-9a-zA-Z\\\\.-])*[0-9A-Za-z]$"}, + "input": "-abcd", "loc": ("name",), - "msg": f'string does not match regex "{DNS_SUBDOMAIN_NAME_REGEX}"', - "type": "value_error.str.regex", - "ctx": { - "pattern": DNS_SUBDOMAIN_NAME_REGEX, - }, + "msg": "String should match pattern '^[0-9a-zA-Z]([0-9a-zA-Z\\\\.-])*[0-9A-Za-z]$'", + "type": "string_pattern_mismatch", + "url": "https://errors.pydantic.dev/2.7/v/string_pattern_mismatch", } in e.value.errors() def test_must_end_with_alphanumeric_character(self, model) -> None: @@ -157,12 +158,12 @@ def test_must_end_with_alphanumeric_character(self, model) -> None: model(name=invalid_name) assert e assert { + "ctx": {"pattern": "^[0-9a-zA-Z]([0-9a-zA-Z\\\\.-])*[0-9A-Za-z]$"}, + "input": "abcd-", "loc": ("name",), - "msg": f'string does not match regex "{DNS_SUBDOMAIN_NAME_REGEX}"', - "type": "value_error.str.regex", - "ctx": { - "pattern": DNS_SUBDOMAIN_NAME_REGEX, - }, + "msg": "String should match pattern '^[0-9a-zA-Z]([0-9a-zA-Z\\\\.-])*[0-9A-Za-z]$'", + "type": "string_pattern_mismatch", + "url": "https://errors.pydantic.dev/2.7/v/string_pattern_mismatch", } in e.value.errors() @@ -183,12 +184,12 @@ def test_cannot_be_blank(self, model) -> None: model(name=invalid_name) assert e assert { + "ctx": {"min_length": 1}, + "input": "", "loc": ("name",), - "msg": "ensure this value has at least 1 characters", - "type": "value_error.any_str.min_length", - "ctx": { - "limit_value": 1, - }, + "msg": "String should have at least 1 character", + "type": "string_too_short", + "url": "https://errors.pydantic.dev/2.7/v/string_too_short", } in e.value.errors() def test_handles_uppercase_chars(self, model) -> None: @@ -204,12 +205,12 @@ def test_cannot_be_longer_than_63_chars(self, model) -> None: model(name=invalid_name) assert e assert { + "ctx": {"max_length": 63}, + "input": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab", "loc": ("name",), - "msg": "ensure this value has at most 63 characters", - "type": "value_error.any_str.max_length", - "ctx": { - "limit_value": 63, - }, + "msg": "String should have at most 63 characters", + "type": "string_too_long", + "url": "https://errors.pydantic.dev/2.7/v/string_too_long", } in e.value.errors() def test_can_only_contain_alphanumerics_and_hyphens(self, model) -> None: @@ -221,12 +222,12 @@ def test_can_only_contain_alphanumerics_and_hyphens(self, model) -> None: model(name=invalid_name) assert e assert { + "ctx": {"pattern": "^[0-9a-zA-Z]([0-9a-zA-Z-])*[0-9A-Za-z]$"}, + "input": "abcd1234.-sss_$%!", "loc": ("name",), - "msg": f'string does not match regex "{DNSLabelNameField.regex}"', - "type": "value_error.str.regex", - "ctx": { - "pattern": DNSLabelNameField.regex, - }, + "msg": "String should match pattern '^[0-9a-zA-Z]([0-9a-zA-Z-])*[0-9A-Za-z]$'", + "type": "string_pattern_mismatch", + "url": "https://errors.pydantic.dev/2.7/v/string_pattern_mismatch", } in e.value.errors() def test_must_start_with_alphanumeric_character(self, model) -> None: @@ -238,12 +239,12 @@ def test_must_start_with_alphanumeric_character(self, model) -> None: model(name=invalid_name) assert e assert { + "ctx": {"pattern": "^[0-9a-zA-Z]([0-9a-zA-Z-])*[0-9A-Za-z]$"}, + "input": "-abcd", "loc": ("name",), - "msg": f'string does not match regex "{DNSLabelNameField.regex}"', - "type": "value_error.str.regex", - "ctx": { - "pattern": DNSLabelNameField.regex, - }, + "msg": "String should match pattern '^[0-9a-zA-Z]([0-9a-zA-Z-])*[0-9A-Za-z]$'", + "type": "string_pattern_mismatch", + "url": "https://errors.pydantic.dev/2.7/v/string_pattern_mismatch", } in e.value.errors() def test_must_end_with_alphanumeric_character(self, model) -> None: @@ -255,12 +256,12 @@ def test_must_end_with_alphanumeric_character(self, model) -> None: model(name=invalid_name) assert e assert { + "ctx": {"pattern": "^[0-9a-zA-Z]([0-9a-zA-Z-])*[0-9A-Za-z]$"}, + "input": "abcd-", "loc": ("name",), - "msg": f'string does not match regex "{DNSLabelNameField.regex}"', - "type": "value_error.str.regex", - "ctx": { - "pattern": DNSLabelNameField.regex, - }, + "msg": "String should match pattern '^[0-9a-zA-Z]([0-9a-zA-Z-])*[0-9A-Za-z]$'", + "type": "string_pattern_mismatch", + "url": "https://errors.pydantic.dev/2.7/v/string_pattern_mismatch", } in e.value.errors() @@ -281,12 +282,12 @@ def test_cant_be_more_than_128_characters(self, model) -> None: model(name=invalid_name) assert e assert { + "ctx": {"max_length": 128}, + "input": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab", "loc": ("name",), - "msg": "ensure this value has at most 128 characters", - "type": "value_error.any_str.max_length", - "ctx": { - "limit_value": 128, - }, + "msg": "String should have at most 128 characters", + "type": "string_too_long", + "url": "https://errors.pydantic.dev/2.7/v/string_too_long", } in e.value.errors() @pytest.mark.parametrize( @@ -314,14 +315,19 @@ def test_tags(self, model, tag_name, valid) -> None: with pytest.raises(ValidationError) as e: model(name=tag_name) assert e - assert { + e = next( + iter((v for v in e.value.errors() if v.get("loc", None) == ("name",))), + None, + ) + assert e + assert e.pop("input", None) in ["-", "."] + assert e == { + "ctx": {"pattern": "^[0-9a-zA-Z]([0-9a-zA-Z_\\.\\-/:@])*$"}, "loc": ("name",), - "msg": f'string does not match regex "{ContainerTagNameField.regex}"', - "type": "value_error.str.regex", - "ctx": { - "pattern": ContainerTagNameField.regex, - }, - } in e.value.errors() + "msg": "String should match pattern '^[0-9a-zA-Z]([0-9a-zA-Z_\\.\\-/:@])*$'", + "type": "string_pattern_mismatch", + "url": "https://errors.pydantic.dev/2.7/v/string_pattern_mismatch", + } class TestEnvironmentConfiguration: @@ -335,11 +341,13 @@ class TestCommandConfiguration: class TestKubernetesConfiguration: @pytest.fixture def funkytown(self, config: KubernetesConfiguration) -> KubernetesConfiguration: - return config.copy(update={"namespace": "funkytown"}) + return config.model_computed_fields( + update={"namespace": "funkytown"}, deep=True + ) def test_cascading_defaults(self, config: KubernetesConfiguration) -> None: # Verify that by default we get a null namespace - assert DeploymentConfiguration.__fields__["namespace"].default is None + assert DeploymentConfiguration.model_fields["namespace"].default is None assert ( DeploymentConfiguration( name="testing", containers=[], replicas=servo.Replicas(min=0, max=1) @@ -352,7 +360,7 @@ def test_cascading_defaults(self, config: KubernetesConfiguration) -> None: assert config.deployments[0].namespace == "default" def test_explicit_cascade(self, config: KubernetesConfiguration) -> None: - model = config.copy(update={"namespace": "funkytown"}) + model = config.model_copy(update={"namespace": "funkytown"}, deep=True) assert model.namespace == "funkytown" assert model.deployments[0].namespace == "default" @@ -362,7 +370,7 @@ def test_explicit_cascade(self, config: KubernetesConfiguration) -> None: def test_respects_explicit_override(self, config: KubernetesConfiguration) -> None: # set the property explictly to value equal to default, then trigger - model = config.copy(update={"namespace": "funkytown"}) + model = config.model_copy(update={"namespace": "funkytown"}) model.deployments[0].namespace = "default" assert model.namespace == "funkytown" assert model.deployments[0].namespace == "default" @@ -460,12 +468,13 @@ def test_strategy_enum(self) -> None: name="testing", containers=[], replicas=servo.Replicas(min=1, max=4), - strategy=OptimizationStrategy.default, + strategy=OptimizationStrategy.default.value, ) assert config.yaml(exclude_unset=True) == ( "name: testing\n" "containers: []\n" - "strategy: default\n" + "strategy:\n" + " type: default\n" "replicas:\n" " min: 1\n" " max: 4\n" @@ -476,9 +485,7 @@ def test_strategy_object_default(self) -> None: name="testing", containers=[], replicas=servo.Replicas(min=1, max=4), - strategy=DefaultOptimizationStrategyConfiguration( - type=OptimizationStrategy.default - ), + strategy=OptimizationStrategy.default.value, ) assert config.yaml(exclude_unset=True) == ( "name: testing\n" @@ -496,7 +503,7 @@ def test_strategy_object_canary(self) -> None: containers=[], replicas=servo.Replicas(min=1, max=4), strategy=CanaryOptimizationStrategyConfiguration( - type=OptimizationStrategy.canary, alias="tuning" + type=OptimizationStrategy.canary.value, alias="tuning" ), ) assert config.yaml(exclude_unset=True) == ( @@ -521,7 +528,7 @@ def test_strategy_object_default_parsing(self) -> None: " type: default\n" ) config_dict = yaml.load(config_yaml, Loader=yaml.FullLoader) - config = DeploymentConfiguration.parse_obj(config_dict) + config = DeploymentConfiguration.model_validate(config_dict) assert isinstance(config.strategy, DefaultOptimizationStrategyConfiguration) assert config.strategy.type == OptimizationStrategy.default @@ -536,7 +543,8 @@ def test_strategy_object_tuning_parsing(self) -> None: " type: canary\n" ) config_dict = yaml.load(config_yaml, Loader=yaml.FullLoader) - config = DeploymentConfiguration.parse_obj(config_dict) + config = DeploymentConfiguration.model_validate(config_dict) + assert isinstance(config.strategy, CanaryOptimizationStrategyConfiguration) assert config.strategy.type == OptimizationStrategy.canary assert config.strategy.alias is None @@ -553,7 +561,7 @@ def test_strategy_object_tuning_parsing_with_alias(self) -> None: " type: canary\n" ) config_dict = yaml.load(config_yaml, Loader=yaml.FullLoader) - config = DeploymentConfiguration.parse_obj(config_dict) + config = DeploymentConfiguration.model_validate(config_dict) assert isinstance(config.strategy, CanaryOptimizationStrategyConfiguration) assert config.strategy.type == OptimizationStrategy.canary assert config.strategy.alias == "tuning" @@ -562,8 +570,8 @@ def test_strategy_object_tuning_parsing_with_alias(self) -> None: class TestCanaryOptimization: @pytest.mark.xfail def test_to_components_default_name(self, config) -> None: - config.deployments[0].strategy = OptimizationStrategy.canary - optimization = CanaryOptimization.construct( + config.deployments[0].strategy = OptimizationStrategy.canary.value + optimization = CanaryOptimization.model_construct( name="fiber-http-deployment/opsani/fiber-http:latest-canary", target_deployment_config=config.deployments[0], target_container_config=config.deployments[0].containers[0], @@ -579,10 +587,10 @@ def test_to_components_default_name(self, config) -> None: @pytest.mark.xfail def test_to_components_respects_aliases(self, config) -> None: config.deployments[0].strategy = CanaryOptimizationStrategyConfiguration( - type=OptimizationStrategy.canary, alias="tuning" + type=OptimizationStrategy.canary.value, alias="tuning" ) config.deployments[0].containers[0].alias = "main" - optimization = CanaryOptimization.construct( + optimization = CanaryOptimization.model_construct( name="fiber-http-deployment/opsani/fiber-http:latest-canary", target_deployment_config=config.deployments[0], target_container_config=config.deployments[0].containers[0], @@ -593,7 +601,7 @@ def test_to_components_respects_aliases(self, config) -> None: def test_compare_strategy() -> None: config = CanaryOptimizationStrategyConfiguration( - type=OptimizationStrategy.canary, alias="tuning" + type=OptimizationStrategy.canary.value, alias="tuning" ) assert config == OptimizationStrategy.canary @@ -747,7 +755,7 @@ def test_parsing(self, replicas: servo.Replicas) -> None: "unit": None, "value": None, "pinned": False, - } == replicas.dict() + } == replicas.model_dump() def test_to___opsani_repr__(self, replicas: servo.Replicas) -> None: replicas.value = 3 @@ -788,7 +796,7 @@ def test_parsing(self, cpu: CPU) -> None: ResourceRequirement.request, ResourceRequirement.limit, ], - } == cpu.dict() + } == cpu.model_dump() def test_to___opsani_repr__(self, cpu: CPU) -> None: cpu.value = "3" @@ -811,7 +819,7 @@ def test_resolving_equivalent_units(self) -> None: assert cpu.step.millicores == 125 def test_resources_encode_to_json_human_readable(self, cpu) -> None: - serialization = json.loads(cpu.json()) + serialization = json.loads(cpu.model_dump_json()) assert serialization["min"] == "125m" assert serialization["max"] == "4" assert serialization["step"] == "125m" @@ -898,7 +906,7 @@ def test_parsing(self, memory: Memory) -> None: ResourceRequirement.request, ResourceRequirement.limit, ], - } == memory.dict() + } == memory.model_dump() def test_to___opsani_repr__(self, memory: Memory) -> None: memory.value = "3.0 GiB" @@ -935,7 +943,7 @@ def test_resolving_equivalent_units(self) -> None: assert memory.step == 134217728 def test_resources_encode_to_json_human_readable(self, memory) -> None: - serialization = json.loads(memory.json()) + serialization = json.loads(memory.model_dump_json()) assert serialization["min"] == "256.0Mi" assert serialization["max"] == "4.0Gi" assert serialization["step"] == "128.0Mi" @@ -946,7 +954,7 @@ def test_mem_must_be_step_aligned( Memory(min="32 MiB", max=4.0, step="256MiB") assert ( captured_logs[0].record["message"] - == "Memory('mem' 32.0Mi-4.0Gi, 256.0Mi) min/max difference is not step aligned: 3.96875Gi is not a multiple of 256Mi (consider min 256Mi or 0B, max 3.78125Gi or 4.03125Gi)." + == "Memory('mem' 32Mi-4Gi, 256Mi) min/max difference is not step aligned: 3.96875Gi is not a multiple of 256Mi (consider min 256Mi or 0B, max 3.78125Gi or 4.03125Gi)." ) def test_min_can_be_less_than_step(self) -> None: @@ -957,6 +965,12 @@ def test_millicpu(): class Model(pydantic.BaseModel): cpu: Core + @pydantic.field_validator("cpu", mode="before") + def _parse_cpu(v: Any): + if v is None: + return v + return Core.parse(v) + assert Model(cpu=0.1).cpu.millicores == 100 assert Model(cpu=0.5).cpu.millicores == 500 assert Model(cpu=1).cpu.millicores == 1000 diff --git a/tests/connectors/opsani_dev_test.py b/tests/connectors/opsani_dev_test.py index c50dc832..51932a50 100644 --- a/tests/connectors/opsani_dev_test.py +++ b/tests/connectors/opsani_dev_test.py @@ -95,7 +95,7 @@ def no_tuning_checks( class TestConfig: def test_generate(self) -> None: config = servo.connectors.opsani_dev.OpsaniDevConfiguration.generate() - assert list(config.dict().keys()) == [ + assert list(config.model_dump().keys()) == [ "description", "namespace", "workload_name", diff --git a/tests/fake.py b/tests/fake.py index 97a1389b..8e57a7ec 100644 --- a/tests/fake.py +++ b/tests/fake.py @@ -87,7 +87,7 @@ async def _enter_awaiting_adjustment( control: servo.Control = servo.Control(), ) -> None: descriptor = servo.api.adjustments_to_descriptor(adjustments) - descriptor["control"] = control.dict(exclude_unset=True) + descriptor["control"] = control.model_dump(exclude_unset=True) self.command_response = servo.api.CommandResponse( cmd=servo.api.Commands.adjust, param=descriptor diff --git a/tests/fast_fail_test.py b/tests/fast_fail_test.py index a1828a11..966a4b79 100644 --- a/tests/fast_fail_test.py +++ b/tests/fast_fail_test.py @@ -42,9 +42,10 @@ def test_non_unique_conditions() -> None: SloInput(conditions=conditions) assert str(err_info.value) == ( - "1 validation error for SloInput\n" - "conditions\n" - " Slo conditions must be unique. Redundant conditions found: (same below 6000), (same2 below same3) (type=value_error)" + "1 validation error for SloInput" + "\nconditions" + "\n Value error, Slo conditions must be unique. Redundant conditions found: (same below 6000), (same2 below same3) [type=value_error, input_value=[SloCondition(description...threshold_minimum=0.25)], input_type=list]" + "\n For further information visit https://errors.pydantic.dev/2.7/v/value_error" ) @@ -57,9 +58,9 @@ def test_trigger_count_greater_than_window() -> None: trigger_window=1, ) assert str(err_info.value) == ( - "1 validation error for SloCondition\n" - "__root__\n" - " trigger_count cannot be greater than trigger_window (2 > 1) (type=value_error)" + "1 validation error for SloCondition" + "\n Value error, trigger_count cannot be greater than trigger_window (2 > 1) [type=value_error, input_value={'metric': 'test', 'thres... 2, 'trigger_window': 1}, input_type=dict]" + "\n For further information visit https://errors.pydantic.dev/2.7/v/value_error" ) @@ -120,7 +121,7 @@ def _make_time_series_list( ret_list = [] for index, val_list in enumerate(values): points = list(map(lambda v: DataPoint(metric, datetime.now(), v), val_list)) - ret_list.append(TimeSeries(metric=metric, data_points=points, id=index)) + ret_list.append(TimeSeries(metric=metric, data_points=points, id=str(index))) return ret_list diff --git a/tests/helpers.py b/tests/helpers.py index ac5af493..f6f233dd 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -142,7 +142,7 @@ def generate_config_yaml( config_dict = {} for k, v in config.items(): if isinstance(v, BaseConfiguration): - config_dict[k] = v.dict(**dict_kwargs) + config_dict[k] = v.model_dump(**dict_kwargs) else: config_dict[k] = v config_json = cls.__config__.json_dumps(config, default=pydantic_encoder) diff --git a/tests/kubernetes_test.py b/tests/kubernetes_test.py index 6f1fd4af..72c66668 100644 --- a/tests/kubernetes_test.py +++ b/tests/kubernetes_test.py @@ -2,7 +2,7 @@ import datetime import hashlib import loguru -from typing import cast +from typing import cast, Literal import re import kubernetes_asyncio @@ -20,7 +20,7 @@ ServiceHelper, ) import tests.helpers -from servo.types.settings import _is_step_aligned, _suggest_step_aligned_values +from servo.types.settings import _is_step_aligned pytestmark = [ pytest.mark.integration, @@ -642,6 +642,7 @@ async def wait_for_new_replicaset(): await asyncio.sleep(0.1) +# TODO apply fix from test_step_alignment_calculations_cpu @pytest.mark.parametrize( "value, step, expected_lower, expected_upper", [ @@ -663,7 +664,6 @@ def test_step_alignment_calculations_memory( lower, upper = _suggest_step_aligned_values( value_bytes, step_bytes, - in_repr=servo.connectors.kubernetes.Memory.human_readable, ) assert lower == expected_lower assert upper == expected_upper @@ -676,26 +676,37 @@ def test_step_alignment_calculations_memory( @pytest.mark.parametrize( - "value, step, expected_lower, expected_upper", + "value, expected_lower, expected_upper", [ - ("250m", "64m", "192m", "256m"), - ("4100m", "250m", "4", "4.25"), - ("3", "100m", "3", "3.1"), + ( + servo.connectors.kubernetes.CPU( + value="250m", min="250m", max="500m", step="64m" + ), + "192m", + "256m", + ), + ( + servo.connectors.kubernetes.CPU( + value="500m", min="0m", max="4100m", step="250m" + ), + "4", + "4.25", + ), + ( + servo.connectors.kubernetes.CPU(value="1", min="0", max="3", step="100m"), + "3", + "3.1", + ), ], ) def test_step_alignment_calculations_cpu( - value, step, expected_lower, expected_upper + value: servo.connectors.kubernetes.CPU, expected_lower, expected_upper ) -> None: - value_cores, step_cores = servo.connectors.kubernetes.Core.parse( - value - ), servo.connectors.kubernetes.Core.parse(step) - lower, upper = _suggest_step_aligned_values( - value_cores, step_cores, in_repr=servo.connectors.kubernetes.CPU.human_readable - ) - assert lower == expected_lower - assert upper == expected_upper - assert _is_step_aligned(servo.connectors.kubernetes.Core.parse(lower), step_cores) - assert _is_step_aligned(servo.connectors.kubernetes.Core.parse(upper), step_cores) + lower, upper = value._suggest_step_aligned_values() + assert servo.connectors.kubernetes.Core.parse(lower) == expected_lower + assert servo.connectors.kubernetes.Core.parse(upper) == expected_upper + assert _is_step_aligned(servo.connectors.kubernetes.Core.parse(lower), value.step) + assert _is_step_aligned(servo.connectors.kubernetes.Core.parse(upper), value.step) def test_cpu_not_step_aligned(captured_logs: list["loguru.Message"]) -> None: diff --git a/tests/types/settings_test.py b/tests/types/settings_test.py index da2f4f90..90b02bb9 100644 --- a/tests/types/settings_test.py +++ b/tests/types/settings_test.py @@ -425,10 +425,10 @@ def test_is_range_setting(self, setting: CPU) -> None: assert isinstance(setting, RangeSetting) def test_default_step(self) -> None: - assert CPU.__fields__["step"].default == 0.125 + assert CPU.model_fields["step"].default == 0.125 def test_name(self) -> None: - assert CPU.__fields__["name"].default == "cpu" + assert CPU.model_fields["name"].default == "cpu" def test_validate_name_cannot_be_changed(self) -> None: with pytest.raises(pydantic.ValidationError) as error: @@ -488,7 +488,7 @@ def test_is_range_setting(self) -> None: def test_range_fields_strict_integers( self, field_name: str, required: bool, allow_none: bool ) -> None: - field = Replicas.__fields__[field_name] + field = Replicas.model_fields[field_name] assert field.type_ == pydantic.StrictInt assert field.required == required assert field.allow_none == allow_none @@ -525,7 +525,7 @@ def test_validate_name_cannot_be_changed(self) -> None: ) def test_validate_unit(self) -> None: - field = InstanceType.__fields__["unit"] + field = InstanceType.model_fields["unit"] assert field.type_ == InstanceTypeUnits assert field.default == InstanceTypeUnits.ec2 assert field.required == False From b99d9f96b8bf5e4d5a65f3269fbcd8c52158afb7 Mon Sep 17 00:00:00 2001 From: Linkous Sharp Date: Tue, 11 Jun 2024 12:55:56 -0500 Subject: [PATCH 8/8] Fix unit test failures in settings_test.py --- servo/types/settings.py | 99 +++++++---------------- tests/types/settings_test.py | 148 +++++++++++++++++++---------------- 2 files changed, 111 insertions(+), 136 deletions(-) diff --git a/servo/types/settings.py b/servo/types/settings.py index e6b5c364..ceefff2a 100644 --- a/servo/types/settings.py +++ b/servo/types/settings.py @@ -103,25 +103,22 @@ def human_readable_value(self) -> str: property if one exists, else coerces the value into a string. Subclasses can provide arbitrary implementations to directly control the representation. """ - if isinstance(self.value, HumanReadable): + if getattr(self.value, "human_readable", None): return cast(HumanReadable, self.value).human_readable() return str(self.value) def __setattr__(self, name, value) -> None: - if name == "value": - self._validate_pinned_values_cannot_be_changed(value) - super().__setattr__(name, value) - - def _validate_pinned_values_cannot_be_changed(self, new_value) -> None: - if not self.pinned or self.value is None: - return - - if new_value != self.value: - error = ValueError( - f"value of pinned settings cannot be changed: assigned value {repr(new_value)} is not equal to existing value {repr(self.value)}" + if ( + name == "value" + and self.pinned + and self.value is not None + and value != self.value + ): + raise ValueError( + f"value of pinned settings cannot be changed: assigned value {repr(value)} is not equal to existing value {repr(self.value)}" ) - error_ = pydantic.error_wrappers.ErrorWrapper(error, loc="value") - raise pydantic.ValidationError([error_], self.__class__) + + super().__setattr__(name, value) @classmethod def get_setting_type(cls, unwrap_union=False) -> Type[Any]: @@ -130,7 +127,7 @@ def get_setting_type(cls, unwrap_union=False) -> Type[Any]: none_filtered = [ a for a in typing.get_args(value_type) if a is not type(None) ] - if len(none_filtered) == 1: + if len(none_filtered) > 0: value_type = none_filtered[0] else: import servo @@ -304,25 +301,28 @@ def _max_must_define_valid_range(self) -> "RangeSetting": def _suggest_step_aligned_values(self) -> tuple[Numeric, Numeric]: # FIXME - range_size = decimal.Decimal(self.max - self.min) + range_size, step_decimal = decimal.Decimal( + self.max - self.min + ), decimal.Decimal(self.step) lower_bound, upper_bound = range_size, range_size # Find the values that are closest on either side of the value # Ensure the smaller size isn't smaller than step - remainder = range_size % self.step + remainder = range_size % step_decimal # lower bound -- align by offseting by remainder lower_bound -= remainder - assert lower_bound % self.step == 0 - if lower_bound <= self.step: - lower_bound = self.step + assert lower_bound % step_decimal == 0 + if lower_bound <= step_decimal: + lower_bound = step_decimal # upper bound -- start from the lower bound and find the next value - upper_bound = lower_bound + self.step + upper_bound = lower_bound + step_decimal if upper_bound == range_size: - upper_bound += self.step + upper_bound += step_decimal - return (lower_bound, upper_bound) + value_type = self.get_setting_type(unwrap_union=True) + return (value_type(lower_bound), value_type(upper_bound)) @pydantic.model_validator(mode="after") @classmethod @@ -540,45 +540,9 @@ def variable_name(self) -> str: return self.literal or self.name -class NumericType(Setting): - - @classmethod - def __get_pydantic_core_schema__( - cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler - ) -> pydantic_core.CoreSchema: - return pydantic_core.core_schema.no_info_after_validator_function( - cls.validate, handler(Setting) - ) - - @classmethod - def validate(cls, value): - if isclass(value) and issubclass(value, (int, float)): - return value - - if value == "int": - return int - if value == "float": - return float - - raise ValueError(f"Unrecognized numeric type {repr(value)}") - - @classmethod - def __get_pydantic_json_schema__( - cls, - _core_schema: pydantic_core.core_schema.CoreSchema, - handler: pydantic.GetJsonSchemaHandler, - ) -> pydantic.json_schema.JsonSchemaValue: - # _core_schema.update(anyOf=["int", "float"]) - # return handler(Setting) - json_schema = handler(_core_schema) - json_schema = handler.resolve_ref_schema(json_schema) - json_schema["anyOf"] = ["int", "float"] - return json_schema - - class EnvironmentRangeSetting(RangeSetting, EnvironmentSetting): # # TODO promote to RangeSetting base - value_type: NumericType = pydantic.Field( + value_type: Union[Literal["int"], Literal["float"], None] = pydantic.Field( None, description="The optional data type of the value of the setting" ) @@ -587,20 +551,17 @@ class EnvironmentRangeSetting(RangeSetting, EnvironmentSetting): None, description="The optional value of the setting as reported by the servo" ) - @pydantic.field_validator("value_type", mode="before") - def _set_value_type_to_type(cls, value: Any) -> Union[Type[int], Type[float]]: - if value == "int": - return int - if value == "float": - return float - return value - @pydantic.model_validator(mode="before") def _cast_value_to_value_type(cls, values: dict[Any, Any]) -> dict[Any, Any]: if (value := values.get("value")) is not None and ( value_type := values.get("value_type") ) is not None: - values["value"] = value_type(value) + if value_type == "int": + values["value"] = int(value) + + if value_type == "float": + values["value"] = float(value) + return values diff --git a/tests/types/settings_test.py b/tests/types/settings_test.py index 90b02bb9..dc539f80 100644 --- a/tests/types/settings_test.py +++ b/tests/types/settings_test.py @@ -8,8 +8,8 @@ class BasicSetting(Setting): - name = "foo" - type = "bar" + name: str = "foo" + type: str = "bar" def __opsani_repr__(self) -> dict: return {} @@ -22,11 +22,11 @@ def test_is_abstract_base_class(self) -> None: def test_requires_opsani_repr_implementation(self) -> None: assert "__opsani_repr__" in Setting.__abstractmethods__ - def test_validates_all(self) -> None: - assert Setting.__config__.validate_all + def test_validates_default(self) -> None: + assert Setting.model_config["validate_default"] def test_validates_assignment(self) -> None: - assert Setting.__config__.validate_assignment + assert Setting.model_config["validate_assignment"] def test_human_readable_value(self) -> None: setting = BasicSetting(value="whatever") @@ -36,8 +36,19 @@ class HumanReadableTestValue(str): def human_readable(self) -> str: return "another-value" - setting = BasicSetting(value=HumanReadableTestValue("whatever")) - assert setting.human_readable_value == "another-value" + @classmethod + def __get_pydantic_core_schema__( + cls, _: Any, handler: pydantic.GetCoreSchemaHandler + ) -> pydantic_core.CoreSchema: + return pydantic_core.core_schema.no_info_after_validator_function( + cls, handler(str) + ) + + class HumanReadableSetting(BasicSetting): + value: Optional[HumanReadableTestValue] = None + + setting2 = HumanReadableSetting(value=HumanReadableTestValue("whatever")) + assert setting2.human_readable_value == "another-value" @pytest.mark.parametrize( ("pinned", "init_value", "new_value", "error_message"), @@ -112,13 +123,11 @@ def test_validate_pinned_value_cannot_change( if error_message is not None: assert pinned, "Cannot test validation for non-pinned settings" - with pytest.raises(pydantic.ValidationError) as error: + with pytest.raises(ValueError) as error: setting.value = new_value assert error - assert "1 validation error for BasicSetting" in str(error.value) - assert error.value.errors()[0]["loc"] == ("value",) - assert error.value.errors()[0]["msg"] == error_message + assert str(error.value) == error_message else: setting.value = new_value assert setting.value == new_value @@ -164,8 +173,8 @@ def test_validate_type_must_be_enum(self, setting: EnumSetting) -> None: assert error assert "1 validation error for EnumSetting" in str(error.value) assert error.value.errors()[0]["loc"] == ("type",) - assert error.value.errors()[0]["type"] == "value_error.const" - assert error.value.errors()[0]["msg"] == "unexpected value; permitted: 'enum'" + assert error.value.errors()[0]["type"] == "literal_error" + assert error.value.errors()[0]["msg"] == "Input should be 'enum'" def test_validate_values_list_is_not_empty(self) -> None: with pytest.raises(pydantic.ValidationError) as error: @@ -174,9 +183,10 @@ def test_validate_values_list_is_not_empty(self) -> None: assert error assert "1 validation error for EnumSetting" in str(error.value) assert error.value.errors()[0]["loc"] == ("values",) - assert error.value.errors()[0]["type"] == "value_error.list.min_length" + assert error.value.errors()[0]["type"] == "too_short" assert ( - error.value.errors()[0]["msg"] == "ensure this value has at least 1 items" + error.value.errors()[0]["msg"] + == "List should have at least 1 item after validation, not 0" ) def test_validate_value_is_included_in_values_list(self) -> None: @@ -185,11 +195,11 @@ def test_validate_value_is_included_in_values_list(self) -> None: assert error assert "1 validation error for EnumSetting" in str(error.value) - assert error.value.errors()[0]["loc"] == ("__root__",) + assert error.value.errors()[0]["loc"] == () assert error.value.errors()[0]["type"] == "value_error" assert ( error.value.errors()[0]["msg"] - == "invalid value: 'three' is not in the values list ['one', 'two']" + == "Value error, invalid value: 'three' is not in the values list ['one', 'two']" ) @@ -205,17 +215,18 @@ def test_validate_type_must_be_enum(self) -> None: assert error assert "1 validation error for RangeSetting" in str(error.value) assert error.value.errors()[0]["loc"] == ("type",) - assert error.value.errors()[0]["type"] == "value_error.const" - assert error.value.errors()[0]["msg"] == "unexpected value; permitted: 'range'" + assert error.value.errors()[0]["type"] == "literal_error" + assert error.value.errors()[0]["msg"] == "Input should be 'range'" def test_validate_step_alignment_suggestion( self, captured_logs: list["loguru.Message"] ) -> None: RangeSetting(name="invalid", min=3.0, max=11.0, step=3.0) - assert ( - captured_logs[0].record["message"] + assert any( + c.record["message"] == "RangeSetting('invalid' 3.0-11.0, 3.0) min/max difference is not step aligned: 8.0 is not a multiple of 3.0 (consider min 5.0 or 2.0, max 9.0 or 12.0)." - ) + for c in captured_logs + ), captured_logs @pytest.mark.parametrize( ("min", "max", "step", "error_message"), @@ -246,7 +257,7 @@ def test_validate_step_alignment( ) -> None: if error_message is not None: RangeSetting(name="invalid", min=min, max=max, step=step) - assert captured_logs[0].record["message"] == error_message + any(c.record["message"] == error_message for c in captured_logs) else: RangeSetting(name="valid", min=min, max=max, step=step) @@ -287,7 +298,9 @@ def test_validate_all_elements_of_range_are_same_type( ) -> None: if error_message is not None: RangeSetting(name="invalid", min=min, max=max, step=step) - assert captured_logs[0].record["message"] == error_message + any( + c.record["message"] == error_message for c in captured_logs + ), captured_logs else: RangeSetting(name="valid", min=min, max=max, step=step) assert len(captured_logs) < 1 @@ -331,7 +344,9 @@ def test_value_falls_in_range( ) -> None: if error_message is not None: RangeSetting(name="invalid", min=min, max=max, step=step, value=value) - assert captured_logs[0].record["message"] == error_message + assert any( + c.record["message"] == error_message for c in captured_logs + ), captured_logs else: RangeSetting(name="valid", min=min, max=max, step=step, value=value) @@ -347,7 +362,7 @@ def test_value_falls_in_range( 1, "step must be zero when min equals max: step 1 cannot step from 1 to 1 (consider using the pinned attribute of settings if you have a value you don't want changed)", ), - (1, 0, 1, "min cannot be greater than max (1 > 0)"), + (1, 0, 1, "invalid value: 1 is outside of the range 1-0"), (1.0, 3.0, 1.0, None), ( 1.0, @@ -355,7 +370,7 @@ def test_value_falls_in_range( 3.0, "RangeSetting('invalid' 1.0-2.0, 3.0) min/max difference is not step aligned: 1.0 is not a multiple of 3.0 (consider min -1.0 or -4.0, max 4.0 or 7.0).", ), - (1.0, 0.0, 1.0, "min cannot be greater than max (1.0 > 0.0)"), + (1.0, 0.0, 1.0, "invalid value: 1 is outside of the range 1.0-0.0"), ], ) def test_max_validation( @@ -368,7 +383,9 @@ def test_max_validation( ) -> None: if error_message is not None: RangeSetting(name="invalid", min=min, max=max, step=step, value=1) - assert captured_logs[0].record["message"] == error_message + assert any( + c.record["message"] == error_message for c in captured_logs + ), captured_logs else: RangeSetting(name="valid", min=min, max=max, step=step, value=1) @@ -379,10 +396,10 @@ def test_validation_on_value_mutation( ) -> None: setting = RangeSetting(name="range", min=0, max=10, step=1) setting.value = 25 - assert ( - captured_logs[0].record["message"] - == "invalid value: 25 is outside of the range 0-10" - ) + assert any( + c.record["message"] == "invalid value: 25 is outside of the range 0-10" + for c in captured_logs + ), captured_logs @pytest.mark.parametrize( ("min", "max", "step", "error_message"), @@ -401,7 +418,9 @@ def test_step_validation( ) -> None: if error_message is not None: RangeSetting(name="invalid", min=min, max=max, step=step, value=1) - assert captured_logs[0].record["message"] == error_message + assert any( + c.record["message"] == error_message for c in captured_logs + ), captured_logs else: RangeSetting(name="valid", min=min, max=max, step=step, value=1) @@ -409,8 +428,11 @@ def test_step_validation( def test_step_cannot_be_zero(self, captured_logs: list["loguru.Message"]) -> None: RangeSetting(name="range", min=0, max=10, step=0) - assert captured_logs[0].record["message"] == "step cannot be zero" - assert captured_logs[0].record["level"].name == "WARNING" + assert any( + c.record["message"] == "step cannot be zero" + and c.record["level"].name == "WARNING" + for c in captured_logs + ), captured_logs def test_min_can_equal_max(self) -> None: RangeSetting(name="range", min=5, max=5, step=0) @@ -437,8 +459,8 @@ def test_validate_name_cannot_be_changed(self) -> None: assert error assert "1 validation error for CPU" in str(error.value) assert error.value.errors()[0]["loc"] == ("name",) - assert error.value.errors()[0]["type"] == "value_error.const" - assert error.value.errors()[0]["msg"] == "unexpected value; permitted: 'cpu'" + assert error.value.errors()[0]["type"] == "literal_error" + assert error.value.errors()[0]["msg"] == "Input should be 'cpu'" def test_validate_min_cant_be_zero(self) -> None: with pytest.raises(pydantic.ValidationError) as error: @@ -447,8 +469,8 @@ def test_validate_min_cant_be_zero(self) -> None: assert error assert "1 validation error for CPU" in str(error.value) assert error.value.errors()[0]["loc"] == ("min",) - assert error.value.errors()[0]["type"] == "value_error.number.not_gt" - assert error.value.errors()[0]["msg"] == "ensure this value is greater than 0" + assert error.value.errors()[0]["type"] == "greater_than" + assert error.value.errors()[0]["msg"] == "Input should be greater than 0" class TestMemory: @@ -462,14 +484,17 @@ def test_validate_name_cannot_be_changed(self) -> None: assert error assert "1 validation error for Memory" in str(error.value) assert error.value.errors()[0]["loc"] == ("name",) - assert error.value.errors()[0]["type"] == "value_error.const" - assert error.value.errors()[0]["msg"] == "unexpected value; permitted: 'mem'" + assert error.value.errors()[0]["type"] == "literal_error" + assert error.value.errors()[0]["msg"] == "Input should be 'mem'" def test_validate_min_cant_be_zero( self, captured_logs: list["loguru.Message"] ) -> None: Memory(min=0.0, max=10.0, step=1.0) - assert captured_logs[0].record["message"] == "min must be greater than zero" + any( + c.record["message"] == "min must be greater than zero" + for c in captured_logs + ) class TestReplicas: @@ -489,9 +514,14 @@ def test_range_fields_strict_integers( self, field_name: str, required: bool, allow_none: bool ) -> None: field = Replicas.model_fields[field_name] - assert field.type_ == pydantic.StrictInt - assert field.required == required - assert field.allow_none == allow_none + if not required and allow_none: + assert ( + field.annotation + == typing.Optional[typing.Annotated[int, pydantic.Strict(strict=True)]] + ) + else: + assert field.annotation == int + assert field.is_required() == required def test_validate_name_cannot_be_changed(self) -> None: with pytest.raises(pydantic.ValidationError) as error: @@ -500,10 +530,8 @@ def test_validate_name_cannot_be_changed(self) -> None: assert error assert "1 validation error for Replicas" in str(error.value) assert error.value.errors()[0]["loc"] == ("name",) - assert error.value.errors()[0]["type"] == "value_error.const" - assert ( - error.value.errors()[0]["msg"] == "unexpected value; permitted: 'replicas'" - ) + assert error.value.errors()[0]["type"] == "literal_error" + assert error.value.errors()[0]["msg"] == "Input should be 'replicas'" class TestInstanceType: @@ -519,17 +547,14 @@ def test_validate_name_cannot_be_changed(self) -> None: assert error assert "1 validation error for InstanceType" in str(error.value) assert error.value.errors()[0]["loc"] == ("name",) - assert error.value.errors()[0]["type"] == "value_error.const" - assert ( - error.value.errors()[0]["msg"] == "unexpected value; permitted: 'inst_type'" - ) + assert error.value.errors()[0]["type"] == "literal_error" + assert error.value.errors()[0]["msg"] == "Input should be 'inst_type'" def test_validate_unit(self) -> None: field = InstanceType.model_fields["unit"] - assert field.type_ == InstanceTypeUnits + assert field.annotation == InstanceTypeUnits assert field.default == InstanceTypeUnits.ec2 - assert field.required == False - assert field.allow_none == False + assert field.is_required() == False @pytest.mark.parametrize( @@ -557,17 +582,6 @@ def test_step_alignment(value, step, aligned) -> None: ), f"Expected value {value} {qualifier} be aligned with step {step}" -@pytest.mark.parametrize( - "input, expected_type", - [ - ("int", int), - ("float", float), - ], -) -def test_numeric_type(input, expected_type): - assert NumericType.validate(input) == expected_type - - class TestEnvironmentSettings: @pytest.mark.parametrize( "expected_value, min, max, step, value, value_type",