diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 487423f4..9c9abac1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: - id: check-toml - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.3 + rev: v0.8.4 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/docs/development/index.rst b/docs/development/index.rst index f5a838fb..f2a178f9 100644 --- a/docs/development/index.rst +++ b/docs/development/index.rst @@ -8,3 +8,4 @@ Here are some guides on developing and testing mobu. idfdev github + sentry diff --git a/docs/development/sentry.rst b/docs/development/sentry.rst new file mode 100644 index 00000000..3e11ccd8 --- /dev/null +++ b/docs/development/sentry.rst @@ -0,0 +1,19 @@ +###### +Sentry +###### + +Footguns +======== + +* `~sentry_sdk.Transaction.set_data` will `silently not do anything `_. + All calls to ``set_data`` must be called on `~sentry_sdk.Span` instances. + +* Unhandled errors will be reported in the scope in which they are sent to sentry, NOT the scope in which they are thrown. + * This means that if you call `~sentry_sdk.new_scope`, set tags on the scope, and an exception is raised but not explicitly captured in that scope, the `tags will not show up in the Sentry UI `_. + * This also means that the error events will occur in the transaction that is active when they are handled, not the active transaction when they are raised. + +* Alerts that are set to fire when an issue is first created, or regressed, will do so once an event that matches that issue occurs in `ANY environment `_, not every environment. + This means that if an the first event from an issue comes from a development environment, the alert will be triggered, and will not trigger again when the first event comes from a production environment. + To have such an alert trigger per-environment, we need to `change the fingerprint of the event `_ to include the environment, which can be done by using the the ``add_environment_to_fingerprint`` helper method as a `before_send` method in your Sentry initialization. + +* Calling `~sentry_sdk.` diff --git a/pyproject.toml b/pyproject.toml index 667d80c8..9ac26ac2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,6 +133,9 @@ extend = "ruff-shared.toml" "tests/data/**/*.ipynb" = [ "T201", # test notebooks are allowed to use print ] +"src/mobu/safir/sentry.py" = [ + "N818", # Exception is correct in some cases, others are part of API +] [tool.ruff.lint.isort] known-first-party = ["monkeyflocker", "mobu", "tests"] diff --git a/requirements/dev.txt b/requirements/dev.txt index 64e6276d..c66168be 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -28,9 +28,9 @@ asttokens==3.0.0 \ --hash=sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7 \ --hash=sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2 # via stack-data -attrs==24.2.0 \ - --hash=sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346 \ - --hash=sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2 +attrs==24.3.0 \ + --hash=sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff \ + --hash=sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308 # via # -c requirements/main.txt # jsonschema @@ -45,9 +45,9 @@ beautifulsoup4==4.12.3 \ --hash=sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051 \ --hash=sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed # via pydata-sphinx-theme -certifi==2024.8.30 \ - --hash=sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8 \ - --hash=sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9 +certifi==2024.12.14 \ + --hash=sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56 \ + --hash=sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db # via # -c requirements/main.txt # httpcore @@ -250,33 +250,33 @@ coverage==7.6.9 \ # via # -r requirements/dev.in # pytest-cov -debugpy==1.8.9 \ - --hash=sha256:1339e14c7d980407248f09824d1b25ff5c5616651689f1e0f0e51bdead3ea13e \ - --hash=sha256:17c5e0297678442511cf00a745c9709e928ea4ca263d764e90d233208889a19e \ - --hash=sha256:1efbb3ff61487e2c16b3e033bc8595aea578222c08aaf3c4bf0f93fadbd662ee \ - --hash=sha256:365e556a4772d7d0d151d7eb0e77ec4db03bcd95f26b67b15742b88cacff88e9 \ - --hash=sha256:3d9755e77a2d680ce3d2c5394a444cf42be4a592caaf246dbfbdd100ffcf7ae5 \ - --hash=sha256:3e59842d6c4569c65ceb3751075ff8d7e6a6ada209ceca6308c9bde932bcef11 \ - --hash=sha256:472a3994999fe6c0756945ffa359e9e7e2d690fb55d251639d07208dbc37caea \ - --hash=sha256:54a7e6d3014c408eb37b0b06021366ee985f1539e12fe49ca2ee0d392d9ceca5 \ - --hash=sha256:5e565fc54b680292b418bb809f1386f17081d1346dca9a871bf69a8ac4071afe \ - --hash=sha256:62d22dacdb0e296966d7d74a7141aaab4bec123fa43d1a35ddcb39bf9fd29d70 \ - --hash=sha256:66eeae42f3137eb428ea3a86d4a55f28da9bd5a4a3d369ba95ecc3a92c1bba53 \ - --hash=sha256:6953b335b804a41f16a192fa2e7851bdcfd92173cbb2f9f777bb934f49baab65 \ - --hash=sha256:7c4d65d03bee875bcb211c76c1d8f10f600c305dbd734beaed4077e902606fee \ - --hash=sha256:7e646e62d4602bb8956db88b1e72fe63172148c1e25c041e03b103a25f36673c \ - --hash=sha256:7e8b079323a56f719977fde9d8115590cb5e7a1cba2fcee0986ef8817116e7c1 \ - --hash=sha256:8138efff315cd09b8dcd14226a21afda4ca582284bf4215126d87342bba1cc66 \ - --hash=sha256:8e99c0b1cc7bf86d83fb95d5ccdc4ad0586d4432d489d1f54e4055bcc795f693 \ - --hash=sha256:957363d9a7a6612a37458d9a15e72d03a635047f946e5fceee74b50d52a9c8e2 \ - --hash=sha256:957ecffff80d47cafa9b6545de9e016ae8c9547c98a538ee96ab5947115fb3dd \ - --hash=sha256:ada7fb65102a4d2c9ab62e8908e9e9f12aed9d76ef44880367bc9308ebe49a0f \ - --hash=sha256:b74a49753e21e33e7cf030883a92fa607bddc4ede1aa4145172debc637780040 \ - --hash=sha256:c36856343cbaa448171cba62a721531e10e7ffb0abff838004701454149bc037 \ - --hash=sha256:cc37a6c9987ad743d9c3a14fa1b1a14b7e4e6041f9dd0c8abf8895fe7a97b899 \ - --hash=sha256:cfe1e6c6ad7178265f74981edf1154ffce97b69005212fbc90ca22ddfe3d017e \ - --hash=sha256:e46b420dc1bea64e5bbedd678148be512442bc589b0111bd799367cde051e71a \ - --hash=sha256:ff54ef77ad9f5c425398efb150239f6fe8e20c53ae2f68367eba7ece1e96226d +debugpy==1.8.11 \ + --hash=sha256:0e22f846f4211383e6a416d04b4c13ed174d24cc5d43f5fd52e7821d0ebc8920 \ + --hash=sha256:116bf8342062246ca749013df4f6ea106f23bc159305843491f64672a55af2e5 \ + --hash=sha256:189058d03a40103a57144752652b3ab08ff02b7595d0ce1f651b9acc3a3a35a0 \ + --hash=sha256:23dc34c5e03b0212fa3c49a874df2b8b1b8fda95160bd79c01eb3ab51ea8d851 \ + --hash=sha256:28e45b3f827d3bf2592f3cf7ae63282e859f3259db44ed2b129093ca0ac7940b \ + --hash=sha256:2b26fefc4e31ff85593d68b9022e35e8925714a10ab4858fb1b577a8a48cb8cd \ + --hash=sha256:32db46ba45849daed7ccf3f2e26f7a386867b077f39b2a974bb5c4c2c3b0a280 \ + --hash=sha256:40499a9979c55f72f4eb2fc38695419546b62594f8af194b879d2a18439c97a9 \ + --hash=sha256:44b1b8e6253bceada11f714acf4309ffb98bfa9ac55e4fce14f9e5d4484287a1 \ + --hash=sha256:52c3cf9ecda273a19cc092961ee34eb9ba8687d67ba34cc7b79a521c1c64c4c0 \ + --hash=sha256:52d8a3166c9f2815bfae05f386114b0b2d274456980d41f320299a8d9a5615a7 \ + --hash=sha256:61bc8b3b265e6949855300e84dc93d02d7a3a637f2aec6d382afd4ceb9120c9f \ + --hash=sha256:654130ca6ad5de73d978057eaf9e582244ff72d4574b3e106fb8d3d2a0d32458 \ + --hash=sha256:6ad2688b69235c43b020e04fecccdf6a96c8943ca9c2fb340b8adc103c655e57 \ + --hash=sha256:6c1f6a173d1140e557347419767d2b14ac1c9cd847e0b4c5444c7f3144697e4e \ + --hash=sha256:84e511a7545d11683d32cdb8f809ef63fc17ea2a00455cc62d0a4dbb4ed1c308 \ + --hash=sha256:85de8474ad53ad546ff1c7c7c89230db215b9b8a02754d41cb5a76f70d0be296 \ + --hash=sha256:8988f7163e4381b0da7696f37eec7aca19deb02e500245df68a7159739bbd0d3 \ + --hash=sha256:8da1db4ca4f22583e834dcabdc7832e56fe16275253ee53ba66627b86e304da1 \ + --hash=sha256:8ffc382e4afa4aee367bf413f55ed17bd91b191dcaf979890af239dda435f2a1 \ + --hash=sha256:987bce16e86efa86f747d5151c54e91b3c1e36acc03ce1ddb50f9d09d16ded0e \ + --hash=sha256:ad7efe588c8f5cf940f40c3de0cd683cc5b76819446abaa50dc0829a30c094db \ + --hash=sha256:bb3b15e25891f38da3ca0740271e63ab9db61f41d4d8541745cfc1824252cb28 \ + --hash=sha256:c928bbf47f65288574b78518449edaa46c82572d340e2750889bbf8cd92f3737 \ + --hash=sha256:ce291a5aca4985d82875d6779f61375e959208cdf09fcec40001e65fb0a54768 \ + --hash=sha256:d8768edcbeb34da9e11bcb8b5c2e0958d25218df7a6e56adf415ef262cd7b6d1 # via ipykernel decorator==5.1.1 \ --hash=sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330 \ @@ -448,9 +448,9 @@ ipykernel==6.29.5 \ --hash=sha256:afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5 \ --hash=sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215 # via myst-nb -ipython==8.30.0 \ - --hash=sha256:85ec56a7e20f6c38fce7727dcca699ae4ffc85985aa7b23635a8008f918ae321 \ - --hash=sha256:cb0a405a306d2995a5cbb9901894d240784a9f341394c6ba3f4fe8c6eb89ff6e +ipython==8.31.0 \ + --hash=sha256:46ec58f8d3d076a61d128fe517a51eb730e3aaf0c184ea8c17d16e366660c6a6 \ + --hash=sha256:b6a2274606bec6166405ff05e54932ed6e5cfecaca1fc05f2cacde7bb074d70b # via # ipykernel # myst-nb @@ -594,39 +594,39 @@ mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba # via markdown-it-py -mypy==1.13.0 \ - --hash=sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc \ - --hash=sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e \ - --hash=sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f \ - --hash=sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74 \ - --hash=sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a \ - --hash=sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2 \ - --hash=sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b \ - --hash=sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73 \ - --hash=sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e \ - --hash=sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d \ - --hash=sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d \ - --hash=sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6 \ - --hash=sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca \ - --hash=sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d \ - --hash=sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5 \ - --hash=sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62 \ - --hash=sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a \ - --hash=sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc \ - --hash=sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7 \ - --hash=sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb \ - --hash=sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7 \ - --hash=sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732 \ - --hash=sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80 \ - --hash=sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a \ - --hash=sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc \ - --hash=sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2 \ - --hash=sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0 \ - --hash=sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24 \ - --hash=sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7 \ - --hash=sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b \ - --hash=sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372 \ - --hash=sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8 +mypy==1.14.0 \ + --hash=sha256:00df23b42e533e02a6f0055e54de9a6ed491cd8b7ea738647364fd3a39ea7efc \ + --hash=sha256:0b16738b1d80ec4334654e89e798eb705ac0c36c8a5c4798496cd3623aa02286 \ + --hash=sha256:10065fcebb7c66df04b05fc799a854b1ae24d9963c8bb27e9064a9bdb43aa8ad \ + --hash=sha256:14117b9da3305b39860d0aa34b8f1ff74d209a368829a584eb77524389a9c13e \ + --hash=sha256:1628c5c3ce823d296e41e2984ff88c5861499041cb416a8809615d0c1f41740e \ + --hash=sha256:1daca283d732943731a6a9f20fdbcaa927f160bc51602b1d4ef880a6fb252015 \ + --hash=sha256:2238d7f93fc4027ed1efc944507683df3ba406445a2b6c96e79666a045aadfab \ + --hash=sha256:273e70fcb2e38c5405a188425aa60b984ffdcef65d6c746ea5813024b68c73dc \ + --hash=sha256:342de51c48bab326bfc77ce056ba08c076d82ce4f5a86621f972ed39970f94d8 \ + --hash=sha256:3498cb55448dc5533e438cd13d6ddd28654559c8c4d1fd4b5ca57a31b81bac01 \ + --hash=sha256:390dfb898239c25289495500f12fa73aa7f24a4c6d90ccdc165762462b998d63 \ + --hash=sha256:3fa76988dc760da377c1e5069200a50d9eaaccf34f4ea18428a3337034ab5a44 \ + --hash=sha256:56b2280cedcb312c7a79f5001ae5325582d0d339bce684e4a529069d0e7ca1e7 \ + --hash=sha256:585ed36031d0b3ee362e5107ef449a8b5dfd4e9c90ccbe36414ee405ee6b32ba \ + --hash=sha256:6e73c8a154eed31db3445fe28f63ad2d97b674b911c00191416cf7f6459fd49a \ + --hash=sha256:74e925649c1ee0a79aa7448baf2668d81cc287dc5782cff6a04ee93f40fb8d3f \ + --hash=sha256:7a52f26b9c9b1664a60d87675f3bae00b5c7f2806e0c2800545a32c325920bcc \ + --hash=sha256:7e026d55ddcd76e29e87865c08cbe2d0104e2b3153a523c529de584759379d3d \ + --hash=sha256:7e68047bedb04c1c25bba9901ea46ff60d5eaac2d71b1f2161f33107e2b368eb \ + --hash=sha256:7fadb29b77fc14a0dd81304ed73c828c3e5cde0016c7e668a86a3e0dfc9f3af3 \ + --hash=sha256:822dbd184d4a9804df5a7d5335a68cf7662930e70b8c1bc976645d1509f9a9d6 \ + --hash=sha256:af98c5a958f9c37404bd4eef2f920b94874507e146ed6ee559f185b8809c44cc \ + --hash=sha256:bf4ec4980bec1e0e24e5075f449d014011527ae0055884c7e3abc6a99cd2c7f1 \ + --hash=sha256:c7b243408ea43755f3a21a0a08e5c5ae30eddb4c58a80f415ca6b118816e60aa \ + --hash=sha256:cdb5563c1726c85fb201be383168f8c866032db95e1095600806625b3a648cb7 \ + --hash=sha256:d5326ab70a6db8e856d59ad4cb72741124950cbbf32e7b70e30166ba7bbf61dd \ + --hash=sha256:e86aaeaa3221a278c66d3d673b297232947d873773d61ca3ee0e28b2ff027179 \ + --hash=sha256:e8c8387e5d9dff80e7daf961df357c80e694e942d9755f3ad77d69b0957b8e3f \ + --hash=sha256:e971c1c667007f9f2b397ffa80fa8e1e0adccff336e5e77e74cb5f22868bee87 \ + --hash=sha256:e9f6f4c0b27401d14c483c622bc5105eff3911634d576bbdf6695b9a7c1ba741 \ + --hash=sha256:f0b343a1d3989547024377c2ba0dca9c74a2428ad6ed24283c213af8dbb0710b \ + --hash=sha256:fbb7d683fa6bdecaa106e8368aa973ecc0ddb79a9eaeb4b821591ecd07e9e03c # via -r requirements/dev.in mypy-extensions==1.0.0 \ --hash=sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d \ @@ -642,9 +642,9 @@ myst-parser==4.0.0 \ # via # documenteer # myst-nb -nbclient==0.10.1 \ - --hash=sha256:3e93e348ab27e712acd46fccd809139e356eb9a31aab641d1a7991a6eb4e6f68 \ - --hash=sha256:949019b9240d66897e442888cfb618f69ef23dc71c01cb5fced8499c2cfc084d +nbclient==0.10.2 \ + --hash=sha256:4ffee11e788b4a27fabeb7955547e4318a5298f34342a4bfd01f2e1faaeadc3d \ + --hash=sha256:90b7fc6b810630db87a6d0c2250b1f0ab4cf4d3c27a299b0cde78a4ed3fd9193 # via # jupyter-cache # myst-nb @@ -699,24 +699,24 @@ prompt-toolkit==3.0.48 \ --hash=sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90 \ --hash=sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e # via ipython -psutil==6.1.0 \ - --hash=sha256:000d1d1ebd634b4efb383f4034437384e44a6d455260aaee2eca1e9c1b55f047 \ - --hash=sha256:045f00a43c737f960d273a83973b2511430d61f283a44c96bf13a6e829ba8fdc \ - --hash=sha256:0895b8414afafc526712c498bd9de2b063deaac4021a3b3c34566283464aff8e \ - --hash=sha256:1209036fbd0421afde505a4879dee3b2fd7b1e14fee81c0069807adcbbcca747 \ - --hash=sha256:1ad45a1f5d0b608253b11508f80940985d1d0c8f6111b5cb637533a0e6ddc13e \ - --hash=sha256:353815f59a7f64cdaca1c0307ee13558a0512f6db064e92fe833784f08539c7a \ - --hash=sha256:498c6979f9c6637ebc3a73b3f87f9eb1ec24e1ce53a7c5173b8508981614a90b \ - --hash=sha256:5cd2bcdc75b452ba2e10f0e8ecc0b57b827dd5d7aaffbc6821b2a9a242823a76 \ - --hash=sha256:6d3fbbc8d23fcdcb500d2c9f94e07b1342df8ed71b948a2649b5cb060a7c94ca \ - --hash=sha256:6e2dcd475ce8b80522e51d923d10c7871e45f20918e027ab682f94f1c6351688 \ - --hash=sha256:9118f27452b70bb1d9ab3198c1f626c2499384935aaf55388211ad982611407e \ - --hash=sha256:9dcbfce5d89f1d1f2546a2090f4fcf87c7f669d1d90aacb7d7582addece9fb38 \ - --hash=sha256:a8506f6119cff7015678e2bce904a4da21025cc70ad283a53b099e7620061d85 \ - --hash=sha256:a8fb3752b491d246034fa4d279ff076501588ce8cbcdbb62c32fd7a377d996be \ - --hash=sha256:c0e0c00aa18ca2d3b2b991643b799a15fc8f0563d2ebb6040f64ce8dc027b942 \ - --hash=sha256:d905186d647b16755a800e7263d43df08b790d709d575105d419f8b6ef65423a \ - --hash=sha256:ff34df86226c0227c52f38b919213157588a678d049688eded74c76c8ba4a5d0 +psutil==6.1.1 \ + --hash=sha256:018aeae2af92d943fdf1da6b58665124897cfc94faa2ca92098838f83e1b1bca \ + --hash=sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377 \ + --hash=sha256:1924e659d6c19c647e763e78670a05dbb7feaf44a0e9c94bf9e14dfc6ba50468 \ + --hash=sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3 \ + --hash=sha256:384636b1a64b47814437d1173be1427a7c83681b17a450bfc309a1953e329603 \ + --hash=sha256:6d4281f5bbca041e2292be3380ec56a9413b790579b8e593b1784499d0005dac \ + --hash=sha256:8be07491f6ebe1a693f17d4f11e69d0dc1811fa082736500f649f79df7735303 \ + --hash=sha256:8df0178ba8a9e5bc84fed9cfa61d54601b371fbec5c8eebad27575f1e105c0d4 \ + --hash=sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160 \ + --hash=sha256:9ccc4316f24409159897799b83004cb1e24f9819b0dcf9c0b68bdcb6cefee6a8 \ + --hash=sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003 \ + --hash=sha256:c777eb75bb33c47377c9af68f30e9f11bc78e0f07fbf907be4a5d70b2fe5f030 \ + --hash=sha256:ca9609c77ea3b8481ab005da74ed894035936223422dc591d6772b147421f777 \ + --hash=sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5 \ + --hash=sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53 \ + --hash=sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649 \ + --hash=sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8 # via ipykernel ptyprocess==0.7.0 \ --hash=sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35 \ @@ -736,113 +736,113 @@ pybtex-docutils==1.0.3 \ --hash=sha256:3a7ebdf92b593e00e8c1c538aa9a20bca5d92d84231124715acc964d51d93c6b \ --hash=sha256:8fd290d2ae48e32fcb54d86b0efb8d573198653c7e2447d5bec5847095f430b9 # via sphinxcontrib-bibtex -pydantic==2.10.3 \ - --hash=sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d \ - --hash=sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9 +pydantic==2.10.4 \ + --hash=sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d \ + --hash=sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06 # via # -c requirements/main.txt # documenteer -pydantic-core==2.27.1 \ - --hash=sha256:00e6424f4b26fe82d44577b4c842d7df97c20be6439e8e685d0d715feceb9fb9 \ - --hash=sha256:029d9757eb621cc6e1848fa0b0310310de7301057f623985698ed7ebb014391b \ - --hash=sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c \ - --hash=sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529 \ - --hash=sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc \ - --hash=sha256:0b3dfe500de26c52abe0477dde16192ac39c98f05bf2d80e76102d394bd13854 \ - --hash=sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d \ - --hash=sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278 \ - --hash=sha256:159cac0a3d096f79ab6a44d77a961917219707e2a130739c64d4dd46281f5c2a \ - --hash=sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c \ - --hash=sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f \ - --hash=sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27 \ - --hash=sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f \ - --hash=sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac \ - --hash=sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2 \ - --hash=sha256:1c39b07d90be6b48968ddc8c19e7585052088fd7ec8d568bb31ff64c70ae3c97 \ - --hash=sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a \ - --hash=sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919 \ - --hash=sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9 \ - --hash=sha256:2d4567c850905d5eaaed2f7a404e61012a51caf288292e016360aa2b96ff38d4 \ - --hash=sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c \ - --hash=sha256:38de0a70160dd97540335b7ad3a74571b24f1dc3ed33f815f0880682e6880131 \ - --hash=sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5 \ - --hash=sha256:3b748c44bb9f53031c8cbc99a8a061bc181c1000c60a30f55393b6e9c45cc5bd \ - --hash=sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089 \ - --hash=sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107 \ - --hash=sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6 \ - --hash=sha256:4228b5b646caa73f119b1ae756216b59cc6e2267201c27d3912b592c5e323b60 \ - --hash=sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf \ - --hash=sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5 \ - --hash=sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08 \ - --hash=sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05 \ - --hash=sha256:46ccfe3032b3915586e469d4972973f893c0a2bb65669194a5bdea9bacc088c2 \ - --hash=sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e \ - --hash=sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c \ - --hash=sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17 \ - --hash=sha256:5897bec80a09b4084aee23f9b73a9477a46c3304ad1d2d07acca19723fb1de62 \ - --hash=sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23 \ - --hash=sha256:5ca038c7f6a0afd0b2448941b6ef9d5e1949e999f9e5517692eb6da58e9d44be \ - --hash=sha256:5f6c8a66741c5f5447e047ab0ba7a1c61d1e95580d64bce852e3df1f895c4067 \ - --hash=sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02 \ - --hash=sha256:5fde892e6c697ce3e30c61b239330fc5d569a71fefd4eb6512fc6caec9dd9e2f \ - --hash=sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235 \ - --hash=sha256:62ba45e21cf6571d7f716d903b5b7b6d2617e2d5d67c0923dc47b9d41369f840 \ - --hash=sha256:64c65f40b4cd8b0e049a8edde07e38b476da7e3aaebe63287c899d2cff253fa5 \ - --hash=sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807 \ - --hash=sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16 \ - --hash=sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c \ - --hash=sha256:6b9af86e1d8e4cfc82c2022bfaa6f459381a50b94a29e95dcdda8442d6d83864 \ - --hash=sha256:6e0bd57539da59a3e4671b90a502da9a28c72322a4f17866ba3ac63a82c4498e \ - --hash=sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a \ - --hash=sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35 \ - --hash=sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737 \ - --hash=sha256:7699b1df36a48169cdebda7ab5a2bac265204003f153b4bd17276153d997670a \ - --hash=sha256:7ccebf51efc61634f6c2344da73e366c75e735960b5654b63d7e6f69a5885fa3 \ - --hash=sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52 \ - --hash=sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05 \ - --hash=sha256:816f5aa087094099fff7edabb5e01cc370eb21aa1a1d44fe2d2aefdfb5599b31 \ - --hash=sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89 \ - --hash=sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de \ - --hash=sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6 \ - --hash=sha256:8f1edcea27918d748c7e5e4d917297b2a0ab80cad10f86631e488b7cddf76a36 \ - --hash=sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c \ - --hash=sha256:98476c98b02c8e9b2eec76ac4156fd006628b1b2d0ef27e548ffa978393fd154 \ - --hash=sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb \ - --hash=sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e \ - --hash=sha256:9a42d6a8156ff78981f8aa56eb6394114e0dedb217cf8b729f438f643608cbcd \ - --hash=sha256:9c10c309e18e443ddb108f0ef64e8729363adbfd92d6d57beec680f6261556f3 \ - --hash=sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f \ - --hash=sha256:9fdcf339322a3fae5cbd504edcefddd5a50d9ee00d968696846f089b4432cf78 \ - --hash=sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960 \ - --hash=sha256:a28af0695a45f7060e6f9b7092558a928a28553366519f64083c63a44f70e618 \ - --hash=sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08 \ - --hash=sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4 \ - --hash=sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c \ - --hash=sha256:a57847b090d7892f123726202b7daa20df6694cbd583b67a592e856bff603d6c \ - --hash=sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330 \ - --hash=sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8 \ - --hash=sha256:ac6c2c45c847bbf8f91930d88716a0fb924b51e0c6dad329b793d670ec5db792 \ - --hash=sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025 \ - --hash=sha256:aee66be87825cdf72ac64cb03ad4c15ffef4143dbf5c113f64a5ff4f81477bf9 \ - --hash=sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f \ - --hash=sha256:b94d4ba43739bbe8b0ce4262bcc3b7b9f31459ad120fb595627eaeb7f9b9ca01 \ - --hash=sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337 \ - --hash=sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4 \ - --hash=sha256:bf99c8404f008750c846cb4ac4667b798a9f7de673ff719d705d9b2d6de49c5f \ - --hash=sha256:c3027001c28434e7ca5a6e1e527487051136aa81803ac812be51802150d880dd \ - --hash=sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51 \ - --hash=sha256:d0165ab2914379bd56908c02294ed8405c252250668ebcb438a55494c69f44ab \ - --hash=sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc \ - --hash=sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676 \ - --hash=sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381 \ - --hash=sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed \ - --hash=sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb \ - --hash=sha256:e9386266798d64eeb19dd3677051f5705bf873e98e15897ddb7d76f477131967 \ - --hash=sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073 \ - --hash=sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae \ - --hash=sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c \ - --hash=sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206 \ - --hash=sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b +pydantic-core==2.27.2 \ + --hash=sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278 \ + --hash=sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50 \ + --hash=sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9 \ + --hash=sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f \ + --hash=sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6 \ + --hash=sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc \ + --hash=sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54 \ + --hash=sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630 \ + --hash=sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9 \ + --hash=sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236 \ + --hash=sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7 \ + --hash=sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee \ + --hash=sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b \ + --hash=sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048 \ + --hash=sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc \ + --hash=sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130 \ + --hash=sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4 \ + --hash=sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd \ + --hash=sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4 \ + --hash=sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7 \ + --hash=sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7 \ + --hash=sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4 \ + --hash=sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e \ + --hash=sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa \ + --hash=sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6 \ + --hash=sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962 \ + --hash=sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b \ + --hash=sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f \ + --hash=sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474 \ + --hash=sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5 \ + --hash=sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459 \ + --hash=sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf \ + --hash=sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a \ + --hash=sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c \ + --hash=sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76 \ + --hash=sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362 \ + --hash=sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4 \ + --hash=sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934 \ + --hash=sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320 \ + --hash=sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118 \ + --hash=sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96 \ + --hash=sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306 \ + --hash=sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046 \ + --hash=sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3 \ + --hash=sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2 \ + --hash=sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af \ + --hash=sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9 \ + --hash=sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67 \ + --hash=sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a \ + --hash=sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27 \ + --hash=sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35 \ + --hash=sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b \ + --hash=sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151 \ + --hash=sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b \ + --hash=sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154 \ + --hash=sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133 \ + --hash=sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef \ + --hash=sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145 \ + --hash=sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15 \ + --hash=sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4 \ + --hash=sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc \ + --hash=sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee \ + --hash=sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c \ + --hash=sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0 \ + --hash=sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5 \ + --hash=sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57 \ + --hash=sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b \ + --hash=sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8 \ + --hash=sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1 \ + --hash=sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da \ + --hash=sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e \ + --hash=sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc \ + --hash=sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993 \ + --hash=sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656 \ + --hash=sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4 \ + --hash=sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c \ + --hash=sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb \ + --hash=sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d \ + --hash=sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9 \ + --hash=sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e \ + --hash=sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1 \ + --hash=sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc \ + --hash=sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a \ + --hash=sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9 \ + --hash=sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506 \ + --hash=sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b \ + --hash=sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1 \ + --hash=sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d \ + --hash=sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99 \ + --hash=sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3 \ + --hash=sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31 \ + --hash=sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c \ + --hash=sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39 \ + --hash=sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a \ + --hash=sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308 \ + --hash=sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2 \ + --hash=sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228 \ + --hash=sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b \ + --hash=sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9 \ + --hash=sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad # via # -c requirements/main.txt # pydantic @@ -1085,9 +1085,9 @@ requests==2.32.3 \ # scriv # sphinx # sphinxcontrib-youtube -respx==0.21.1 \ - --hash=sha256:05f45de23f0c785862a2c92a3e173916e8ca88e4caad715dd5f68584d6053c20 \ - --hash=sha256:0bd7fe21bfaa52106caa1223ce61224cf30786985f17c63c5d71eff0307ee8af +respx==0.22.0 \ + --hash=sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91 \ + --hash=sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0 # via -r requirements/dev.in rpds-py==0.22.3 \ --hash=sha256:009de23c9c9ee54bf11303a966edf4d9087cd43a6003672e6aa7def643d06518 \ @@ -1197,25 +1197,25 @@ rpds-py==0.22.3 \ # -c requirements/main.txt # jsonschema # referencing -ruff==0.8.3 \ - --hash=sha256:01b14b2f72a37390c1b13477c1c02d53184f728be2f3ffc3ace5b44e9e87b90d \ - --hash=sha256:19048f2f878f3ee4583fc6cb23fb636e48c2635e30fb2022b3a1cd293402f964 \ - --hash=sha256:1ae441ce4cf925b7f363d33cd6570c51435972d697e3e58928973994e56e1452 \ - --hash=sha256:53babd6e63e31f4e96ec95ea0d962298f9f0d9cc5990a1bbb023a6baf2503a82 \ - --hash=sha256:5be450bb18f23f0edc5a4e5585c17a56ba88920d598f04a06bd9fd76d324cb20 \ - --hash=sha256:5e7558304353b84279042fc584a4f4cb8a07ae79b2bf3da1a7551d960b5626d3 \ - --hash=sha256:6567be9fb62fbd7a099209257fef4ad2c3153b60579818b31a23c886ed4147ea \ - --hash=sha256:75fb782f4db39501210ac093c79c3de581d306624575eddd7e4e13747e61ba18 \ - --hash=sha256:7f26bc76a133ecb09a38b7868737eded6941b70a6d34ef53a4027e83913b6502 \ - --hash=sha256:8d5d273ffffff0acd3db5bf626d4b131aa5a5ada1276126231c4174543ce20d6 \ - --hash=sha256:8faeae3827eaa77f5721f09b9472a18c749139c891dbc17f45e72d8f2ca1f8fc \ - --hash=sha256:9c0a60a825e3e177116c84009d5ebaa90cf40dfab56e1358d1df4e29a9a14b13 \ - --hash=sha256:c356e770811858bd20832af696ff6c7e884701115094f427b64b25093d6d932d \ - --hash=sha256:d7c65bc0cadce32255e93c57d57ecc2cca23149edd52714c0c5d6fa11ec328cd \ - --hash=sha256:db503486e1cf074b9808403991663e4277f5c664d3fe237ee0d994d1305bb060 \ - --hash=sha256:e4d66a21de39f15c9757d00c50c8cdd20ac84f55684ca56def7891a025d7e939 \ - --hash=sha256:f7df94f57d7418fa7c3ffb650757e0c2b96cf2501a0b192c18e4fb5571dfada9 \ - --hash=sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936 +ruff==0.8.4 \ + --hash=sha256:0d5f89f254836799af1615798caa5f80b7f935d7a670fad66c5007928e57ace8 \ + --hash=sha256:13e9ec6d6b55f6da412d59953d65d66e760d583dd3c1c72bf1f26435b5bfdbae \ + --hash=sha256:552fb6d861320958ca5e15f28b20a3d071aa83b93caee33a87b471f99a6c0835 \ + --hash=sha256:58072f0c06080276804c6a4e21a9045a706584a958e644353603d36ca1eb8a60 \ + --hash=sha256:6ddf5d654ac0d44389f6bf05cee4caeefc3132a64b58ea46738111d687352296 \ + --hash=sha256:736272574e97157f7edbbb43b1d046125fce9e7d8d583d5d65d0c9bf2c15addf \ + --hash=sha256:8ef06f66f4a05c3ddbc9121a8b0cecccd92c5bf3dd43b5472ffe40b8ca10f0f8 \ + --hash=sha256:9183dd615d8df50defa8b1d9a074053891ba39025cf5ae88e8bcb52edcc4bf08 \ + --hash=sha256:97d9aefef725348ad77d6db98b726cfdb075a40b936c7984088804dfd38268a7 \ + --hash=sha256:9f8402b7c4f96463f135e936d9ab77b65711fcd5d72e5d67597b543bbb43cf3f \ + --hash=sha256:ab78e33325a6f5374e04c2ab924a3367d69a0da36f8c9cb6b894a62017506111 \ + --hash=sha256:bf197b98ed86e417412ee3b6c893f44c8864f816451441483253d5ff22c0e81e \ + --hash=sha256:c41319b85faa3aadd4d30cb1cffdd9ac6b89704ff79f7664b853785b48eccdf3 \ + --hash=sha256:e248b1f0fa2749edd3350a2a342b67b43a2627434c059a063418e3d375cfe643 \ + --hash=sha256:e4e56b3baa9c23d324ead112a4fdf20db9a3f8f29eeabff1355114dd96014604 \ + --hash=sha256:e5fe710ab6061592521f902fca7ebcb9fabd27bc7c57c764298b1c1f15fff720 \ + --hash=sha256:f21a1143776f8656d7f364bd264a9d60f01b7f52243fbe90e7670c0dfe0cf65d \ + --hash=sha256:ffb60904651c00a1e0b8df594591770018a0f04587f7deeb3838344fe3adabac # via -r requirements/dev.in scriv==1.5.1 \ --hash=sha256:30ae9ff8d144f8e0cf394c4e1d379542f1b3823767642955b54ec40dc00b32b6 \ diff --git a/requirements/main.in b/requirements/main.in index 168b0926..b699451f 100644 --- a/requirements/main.in +++ b/requirements/main.in @@ -26,6 +26,7 @@ safir>=9.0.1 shortuuid structlog websockets +sentry-sdk # Uncomment this, change the branch, comment out safir above, and run make # update-deps-no-hashes to test against an unreleased version of Safir. diff --git a/requirements/main.txt b/requirements/main.txt index e5b6cb88..2a1c6d35 100644 --- a/requirements/main.txt +++ b/requirements/main.txt @@ -78,17 +78,17 @@ astropy==7.0.0 \ --hash=sha256:e92d7c9fee86eb3df8714e5dd41bbf9f163d343e1a183d95bf6bd09e4313c940 \ --hash=sha256:f8a00fcb30c2317b111d8b6a99eb60a81e8292c24dce65e986ee4610ea16b8d2 # via pyvo -astropy-iers-data==0.2024.12.9.0.36.21 \ - --hash=sha256:b8c235b5420702a8e28bb43d94e634aa4bead38d9e72dea03fd0a22b1057817d \ - --hash=sha256:f22cc517b5bc164e4d105c14677d918ab732ef10342fe76fc9068fa46755c91f +astropy-iers-data==0.2024.12.16.0.35.48 \ + --hash=sha256:c277670bf5324d9e17a28b011d4d7493acc06ce9dc75e7cfab848bd8c4847ce3 \ + --hash=sha256:dbb95668d66bb160342f058df49953c9d47bac628279169ffd476ca51fe90fac # via astropy async-timeout==5.0.1 \ --hash=sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c \ --hash=sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3 # via aiokafka -attrs==24.2.0 \ - --hash=sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346 \ - --hash=sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2 +attrs==24.3.0 \ + --hash=sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff \ + --hash=sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308 # via # jsonschema # referencing @@ -96,13 +96,14 @@ casefy==1.0.0 \ --hash=sha256:bc99428475c2089c5f6a21297b4cfe4e83dff132cf3bb06655ddcb90632af1ed \ --hash=sha256:c89f96fb0fbd13691073b7a65c1e668e81453247d647479a3db105e86d7b0df9 # via dataclasses-avroschema -certifi==2024.8.30 \ - --hash=sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8 \ - --hash=sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9 +certifi==2024.12.14 \ + --hash=sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56 \ + --hash=sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db # via # httpcore # httpx # requests + # sentry-sdk cffi==1.17.1 \ --hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \ --hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \ @@ -334,38 +335,38 @@ fastapi==0.115.6 \ # via # -r requirements/main.in # safir -fastavro==1.9.7 \ - --hash=sha256:0b2f9bafa167cb4d1c3dd17565cb5bf3d8c0759e42620280d1760f1e778e07fc \ - --hash=sha256:13e11c6cb28626da85290933027cd419ce3f9ab8e45410ef24ce6b89d20a1f6c \ - --hash=sha256:17de68aae8c2525f5631d80f2b447a53395cdc49134f51b0329a5497277fc2d2 \ - --hash=sha256:1d09227d1f48f13281bd5ceac958650805aef9a4ef4f95810128c1f9be1df736 \ - --hash=sha256:2af559f30383b79cf7d020a6b644c42ffaed3595f775fe8f3d7f80b1c43dfdc5 \ - --hash=sha256:2db993ae6cdc63e25eadf9f93c9e8036f9b097a3e61d19dca42536dcc5c4d8b3 \ - --hash=sha256:2fcce036c6aa06269fc6a0428050fcb6255189997f5e1a728fc461e8b9d3e26b \ - --hash=sha256:3b683693c8a85ede496ebebe115be5d7870c150986e34a0442a20d88d7771224 \ - --hash=sha256:4e1289b731214a7315884c74b2ec058b6e84380ce9b18b8af5d387e64b18fc44 \ - --hash=sha256:536f5644737ad21d18af97d909dba099b9e7118c237be7e4bd087c7abde7e4f0 \ - --hash=sha256:56304401d2f4f69f5b498bdd1552c13ef9a644d522d5de0dc1d789cf82f47f73 \ - --hash=sha256:58f76a5c9a312fbd37b84e49d08eb23094d36e10d43bc5df5187bc04af463feb \ - --hash=sha256:6312fa99deecc319820216b5e1b1bd2d7ebb7d6f221373c74acfddaee64e8e60 \ - --hash=sha256:7313def3aea3dacface0a8b83f6d66e49a311149aa925c89184a06c1ef99785d \ - --hash=sha256:76d9d96f98052615ab465c63ba8b76ed59baf2e3341b7b169058db104cbe2aa0 \ - --hash=sha256:7c911366c625d0a997eafe0aa83ffbc6fd00d8fd4543cb39a97c6f3b8120ea87 \ - --hash=sha256:912283ed48578a103f523817fdf0c19b1755cea9b4a6387b73c79ecb8f8f84fc \ - --hash=sha256:919f3549e07a8a8645a2146f23905955c35264ac809f6c2ac18142bc5b9b6022 \ - --hash=sha256:9be089be8c00f68e343bbc64ca6d9a13e5e5b0ba8aa52bcb231a762484fb270e \ - --hash=sha256:9de1fa832a4d9016724cd6facab8034dc90d820b71a5d57c7e9830ffe90f31e4 \ - --hash=sha256:b525c363e267ed11810aaad8fbdbd1c3bd8837d05f7360977d72a65ab8c6e1fa \ - --hash=sha256:b6b2ccdc78f6afc18c52e403ee68c00478da12142815c1bd8a00973138a166d0 \ - --hash=sha256:cc811fb4f7b5ae95f969cda910241ceacf82e53014c7c7224df6f6e0ca97f52f \ - --hash=sha256:d576eccfd60a18ffa028259500df67d338b93562c6700e10ef68bbd88e499731 \ - --hash=sha256:e87d04b235b29f7774d226b120da2ca4e60b9e6fdf6747daef7f13f218b3517a \ - --hash=sha256:eac69666270a76a3a1d0444f39752061195e79e146271a568777048ffbd91a27 \ - --hash=sha256:ec2e96bdabd58427fe683329b3d79f42c7b4f4ff6b3644664a345a655ac2c0a1 \ - --hash=sha256:ec8499dc276c2d2ef0a68c0f1ad11782b2b956a921790a36bf4c18df2b8d4020 \ - --hash=sha256:edc28ab305e3c424de5ac5eb87b48d1e07eddb6aa08ef5948fcda33cc4d995ce \ - --hash=sha256:ee9bf23c157bd7dcc91ea2c700fa3bd924d9ec198bb428ff0b47fa37fe160659 \ - --hash=sha256:fb8749e419a85f251bf1ac87d463311874972554d25d4a0b19f6bdc56036d7cf +fastavro==1.10.0 \ + --hash=sha256:0a678153b5da1b024a32ec3f611b2e7afd24deac588cb51dd1b0019935191a6d \ + --hash=sha256:190e80dc7d77d03a6a8597a026146b32a0bbe45e3487ab4904dc8c1bebecb26d \ + --hash=sha256:1a9fe0672d2caf0fe54e3be659b13de3cad25a267f2073d6f4b9f8862acc31eb \ + --hash=sha256:1fd689724760b17f69565d8a4e7785ed79becd451d1c99263c40cb2d6491f1d4 \ + --hash=sha256:203c17d44cadde76e8eecb30f2d1b4f33eb478877552d71f049265dc6f2ecd10 \ + --hash=sha256:37203097ed11d0b8fd3c004904748777d730cafd26e278167ea602eebdef8eb2 \ + --hash=sha256:47bf41ac6d52cdfe4a3da88c75a802321321b37b663a900d12765101a5d6886f \ + --hash=sha256:4f949d463f9ac4221128a51e4e34e2562f401e5925adcadfd28637a73df6c2d8 \ + --hash=sha256:566c193109ff0ff84f1072a165b7106c4f96050078a4e6ac7391f81ca1ef3efa \ + --hash=sha256:567ff515f2a5d26d9674b31c95477f3e6022ec206124c62169bc2ffaf0889089 \ + --hash=sha256:5bccbb6f8e9e5b834cca964f0e6ebc27ebe65319d3940b0b397751a470f45612 \ + --hash=sha256:6575be7f2b5f94023b5a4e766b0251924945ad55e9a96672dc523656d17fe251 \ + --hash=sha256:67a597a5cfea4dddcf8b49eaf8c2b5ffee7fda15b578849185bc690ec0cd0d8f \ + --hash=sha256:74e517440c824cb65fb29d3e3903a9406f4d7c75490cef47e55c4c82cdc66270 \ + --hash=sha256:82263af0adfddb39c85f9517d736e1e940fe506dfcc35bc9ab9f85e0fa9236d8 \ + --hash=sha256:86baf8c9740ab570d0d4d18517da71626fe9be4d1142bea684db52bd5adb078f \ + --hash=sha256:86dd0410770e0c99363788f0584523709d85e57bb457372ec5c285a482c17fe6 \ + --hash=sha256:8e62d04c65461b30ac6d314e4197ad666371e97ae8cb2c16f971d802f6c7f514 \ + --hash=sha256:9b8227497f71565270f9249fc9af32a93644ca683a0167cfe66d203845c3a038 \ + --hash=sha256:aaef147dc14dd2d7823246178fd06fc5e477460e070dc6d9e07dd8193a6bc93c \ + --hash=sha256:b0132f6b0b53f61a0a508a577f64beb5de1a5e068a9b4c0e1df6e3b66568eec4 \ + --hash=sha256:bf570d63be9155c3fdc415f60a49c171548334b70fff0679a184b69c29b6bc61 \ + --hash=sha256:ca37a363b711202c6071a6d4787e68e15fa3ab108261058c4aae853c582339af \ + --hash=sha256:cf38cecdd67ca9bd92e6e9ba34a30db6343e7a3bedf171753ee78f8bd9f8a670 \ + --hash=sha256:cfe57cb0d72f304bd0dcc5a3208ca6a7363a9ae76f3073307d095c9d053b29d4 \ + --hash=sha256:d183c075f527ab695a27ae75f210d4a86bce660cda2f85ae84d5606efc15ef50 \ + --hash=sha256:d7a95a2c0639bffd7c079b59e9a796bfc3a9acd78acff7088f7c54ade24e4a77 \ + --hash=sha256:e07abb6798e95dccecaec316265e35a018b523d1f3944ad396d0a93cb95e0a08 \ + --hash=sha256:e400d2e55d068404d9fea7c5021f8b999c6f9d9afa1d1f3652ec92c105ffcbdd \ + --hash=sha256:f4dd10e0ed42982122d20cdf1a88aa50ee09e5a9cd9b39abdffb1aa4f5b76435 \ + --hash=sha256:fe471deb675ed2f01ee2aac958fbf8ebb13ea00fa4ce7f87e57710a0bc592208 # via # dataclasses-avroschema # python-schema-registry-client @@ -603,9 +604,9 @@ pycparser==2.22 \ --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc # via cffi -pydantic==2.10.3 \ - --hash=sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d \ - --hash=sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9 +pydantic==2.10.4 \ + --hash=sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d \ + --hash=sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06 # via # -r requirements/main.in # fast-depends @@ -613,107 +614,107 @@ pydantic==2.10.3 \ # pydantic-settings # rubin-nublado-client # safir -pydantic-core==2.27.1 \ - --hash=sha256:00e6424f4b26fe82d44577b4c842d7df97c20be6439e8e685d0d715feceb9fb9 \ - --hash=sha256:029d9757eb621cc6e1848fa0b0310310de7301057f623985698ed7ebb014391b \ - --hash=sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c \ - --hash=sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529 \ - --hash=sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc \ - --hash=sha256:0b3dfe500de26c52abe0477dde16192ac39c98f05bf2d80e76102d394bd13854 \ - --hash=sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d \ - --hash=sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278 \ - --hash=sha256:159cac0a3d096f79ab6a44d77a961917219707e2a130739c64d4dd46281f5c2a \ - --hash=sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c \ - --hash=sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f \ - --hash=sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27 \ - --hash=sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f \ - --hash=sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac \ - --hash=sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2 \ - --hash=sha256:1c39b07d90be6b48968ddc8c19e7585052088fd7ec8d568bb31ff64c70ae3c97 \ - --hash=sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a \ - --hash=sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919 \ - --hash=sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9 \ - --hash=sha256:2d4567c850905d5eaaed2f7a404e61012a51caf288292e016360aa2b96ff38d4 \ - --hash=sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c \ - --hash=sha256:38de0a70160dd97540335b7ad3a74571b24f1dc3ed33f815f0880682e6880131 \ - --hash=sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5 \ - --hash=sha256:3b748c44bb9f53031c8cbc99a8a061bc181c1000c60a30f55393b6e9c45cc5bd \ - --hash=sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089 \ - --hash=sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107 \ - --hash=sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6 \ - --hash=sha256:4228b5b646caa73f119b1ae756216b59cc6e2267201c27d3912b592c5e323b60 \ - --hash=sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf \ - --hash=sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5 \ - --hash=sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08 \ - --hash=sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05 \ - --hash=sha256:46ccfe3032b3915586e469d4972973f893c0a2bb65669194a5bdea9bacc088c2 \ - --hash=sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e \ - --hash=sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c \ - --hash=sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17 \ - --hash=sha256:5897bec80a09b4084aee23f9b73a9477a46c3304ad1d2d07acca19723fb1de62 \ - --hash=sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23 \ - --hash=sha256:5ca038c7f6a0afd0b2448941b6ef9d5e1949e999f9e5517692eb6da58e9d44be \ - --hash=sha256:5f6c8a66741c5f5447e047ab0ba7a1c61d1e95580d64bce852e3df1f895c4067 \ - --hash=sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02 \ - --hash=sha256:5fde892e6c697ce3e30c61b239330fc5d569a71fefd4eb6512fc6caec9dd9e2f \ - --hash=sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235 \ - --hash=sha256:62ba45e21cf6571d7f716d903b5b7b6d2617e2d5d67c0923dc47b9d41369f840 \ - --hash=sha256:64c65f40b4cd8b0e049a8edde07e38b476da7e3aaebe63287c899d2cff253fa5 \ - --hash=sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807 \ - --hash=sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16 \ - --hash=sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c \ - --hash=sha256:6b9af86e1d8e4cfc82c2022bfaa6f459381a50b94a29e95dcdda8442d6d83864 \ - --hash=sha256:6e0bd57539da59a3e4671b90a502da9a28c72322a4f17866ba3ac63a82c4498e \ - --hash=sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a \ - --hash=sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35 \ - --hash=sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737 \ - --hash=sha256:7699b1df36a48169cdebda7ab5a2bac265204003f153b4bd17276153d997670a \ - --hash=sha256:7ccebf51efc61634f6c2344da73e366c75e735960b5654b63d7e6f69a5885fa3 \ - --hash=sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52 \ - --hash=sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05 \ - --hash=sha256:816f5aa087094099fff7edabb5e01cc370eb21aa1a1d44fe2d2aefdfb5599b31 \ - --hash=sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89 \ - --hash=sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de \ - --hash=sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6 \ - --hash=sha256:8f1edcea27918d748c7e5e4d917297b2a0ab80cad10f86631e488b7cddf76a36 \ - --hash=sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c \ - --hash=sha256:98476c98b02c8e9b2eec76ac4156fd006628b1b2d0ef27e548ffa978393fd154 \ - --hash=sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb \ - --hash=sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e \ - --hash=sha256:9a42d6a8156ff78981f8aa56eb6394114e0dedb217cf8b729f438f643608cbcd \ - --hash=sha256:9c10c309e18e443ddb108f0ef64e8729363adbfd92d6d57beec680f6261556f3 \ - --hash=sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f \ - --hash=sha256:9fdcf339322a3fae5cbd504edcefddd5a50d9ee00d968696846f089b4432cf78 \ - --hash=sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960 \ - --hash=sha256:a28af0695a45f7060e6f9b7092558a928a28553366519f64083c63a44f70e618 \ - --hash=sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08 \ - --hash=sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4 \ - --hash=sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c \ - --hash=sha256:a57847b090d7892f123726202b7daa20df6694cbd583b67a592e856bff603d6c \ - --hash=sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330 \ - --hash=sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8 \ - --hash=sha256:ac6c2c45c847bbf8f91930d88716a0fb924b51e0c6dad329b793d670ec5db792 \ - --hash=sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025 \ - --hash=sha256:aee66be87825cdf72ac64cb03ad4c15ffef4143dbf5c113f64a5ff4f81477bf9 \ - --hash=sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f \ - --hash=sha256:b94d4ba43739bbe8b0ce4262bcc3b7b9f31459ad120fb595627eaeb7f9b9ca01 \ - --hash=sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337 \ - --hash=sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4 \ - --hash=sha256:bf99c8404f008750c846cb4ac4667b798a9f7de673ff719d705d9b2d6de49c5f \ - --hash=sha256:c3027001c28434e7ca5a6e1e527487051136aa81803ac812be51802150d880dd \ - --hash=sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51 \ - --hash=sha256:d0165ab2914379bd56908c02294ed8405c252250668ebcb438a55494c69f44ab \ - --hash=sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc \ - --hash=sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676 \ - --hash=sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381 \ - --hash=sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed \ - --hash=sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb \ - --hash=sha256:e9386266798d64eeb19dd3677051f5705bf873e98e15897ddb7d76f477131967 \ - --hash=sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073 \ - --hash=sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae \ - --hash=sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c \ - --hash=sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206 \ - --hash=sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b +pydantic-core==2.27.2 \ + --hash=sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278 \ + --hash=sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50 \ + --hash=sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9 \ + --hash=sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f \ + --hash=sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6 \ + --hash=sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc \ + --hash=sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54 \ + --hash=sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630 \ + --hash=sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9 \ + --hash=sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236 \ + --hash=sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7 \ + --hash=sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee \ + --hash=sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b \ + --hash=sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048 \ + --hash=sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc \ + --hash=sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130 \ + --hash=sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4 \ + --hash=sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd \ + --hash=sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4 \ + --hash=sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7 \ + --hash=sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7 \ + --hash=sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4 \ + --hash=sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e \ + --hash=sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa \ + --hash=sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6 \ + --hash=sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962 \ + --hash=sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b \ + --hash=sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f \ + --hash=sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474 \ + --hash=sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5 \ + --hash=sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459 \ + --hash=sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf \ + --hash=sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a \ + --hash=sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c \ + --hash=sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76 \ + --hash=sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362 \ + --hash=sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4 \ + --hash=sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934 \ + --hash=sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320 \ + --hash=sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118 \ + --hash=sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96 \ + --hash=sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306 \ + --hash=sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046 \ + --hash=sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3 \ + --hash=sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2 \ + --hash=sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af \ + --hash=sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9 \ + --hash=sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67 \ + --hash=sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a \ + --hash=sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27 \ + --hash=sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35 \ + --hash=sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b \ + --hash=sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151 \ + --hash=sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b \ + --hash=sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154 \ + --hash=sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133 \ + --hash=sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef \ + --hash=sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145 \ + --hash=sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15 \ + --hash=sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4 \ + --hash=sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc \ + --hash=sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee \ + --hash=sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c \ + --hash=sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0 \ + --hash=sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5 \ + --hash=sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57 \ + --hash=sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b \ + --hash=sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8 \ + --hash=sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1 \ + --hash=sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da \ + --hash=sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e \ + --hash=sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc \ + --hash=sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993 \ + --hash=sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656 \ + --hash=sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4 \ + --hash=sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c \ + --hash=sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb \ + --hash=sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d \ + --hash=sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9 \ + --hash=sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e \ + --hash=sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1 \ + --hash=sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc \ + --hash=sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a \ + --hash=sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9 \ + --hash=sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506 \ + --hash=sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b \ + --hash=sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1 \ + --hash=sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d \ + --hash=sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99 \ + --hash=sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3 \ + --hash=sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31 \ + --hash=sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c \ + --hash=sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39 \ + --hash=sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a \ + --hash=sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308 \ + --hash=sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2 \ + --hash=sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228 \ + --hash=sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b \ + --hash=sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9 \ + --hash=sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad # via # pydantic # safir @@ -939,16 +940,20 @@ rubin-nublado-client==8.2.0 \ --hash=sha256:1b6fbb357ae2ba7e6a4b559a0a311801e113ef2ae8f0dbb758c3ba05839f762c \ --hash=sha256:6fdcc8701830117d62ed076b3a03b76efaebdea76a08ede0bd55b76624eccf70 # via -r requirements/main.in -safir==9.0.1 \ - --hash=sha256:8235503c7198665417e5645d87639d5c79bebfd9bf8d529154677f622915b97e \ - --hash=sha256:e0e4fb1bb42981ddb1ad5dd8843e4aa3c73cde56500152f2cfdbeaf05bd63617 +safir==9.1.1 \ + --hash=sha256:e1388b745f42030debd951a6f62e70b1fcdb217cb472c835ceffdcb90682f34a \ + --hash=sha256:ea2fa7dbb75f4bf6101ae3e1a7a6beb0522c211fc1ad43321a8b00c09f6d8a2e # via # -r requirements/main.in # rubin-nublado-client -safir-logging==9.0.1 \ - --hash=sha256:775ab8b2c1a62fe5779f8d4504797df7affb5dbebc70fcad00307cda419d637e \ - --hash=sha256:e5dfdfbdafb0a60dd2b8cdd63f8171cb703830cd5d433d60c27028cc9a4044f1 +safir-logging==9.1.1 \ + --hash=sha256:5b268259f282502471fdb6a2087d2e5a010fa88d73cfa71a32fc5c656562830a \ + --hash=sha256:e48e905ca30cb7f2ee95d54b22fd0678511b18b59275203a611368fc0da4ea0c # via safir +sentry-sdk==2.19.2 \ + --hash=sha256:467df6e126ba242d39952375dd816fbee0f217d119bf454a8ce74cf1e7909e8d \ + --hash=sha256:ebdc08228b4d131128e568d696c210d846e5b9d70aa0327dec6b1272d9d40b84 + # via -r requirements/main.in shortuuid==1.0.13 \ --hash=sha256:3bb9cf07f606260584b1df46399c0b87dd84773e7b25912b7e391e30797c5e72 \ --hash=sha256:a482a497300b49b4953e15108a7913244e1bb0d41f9d332f5e9925dba33a3c5a @@ -998,10 +1003,12 @@ uritemplate==4.1.1 \ urllib3==2.2.3 \ --hash=sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac \ --hash=sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9 - # via requests -uvicorn==0.32.1 \ - --hash=sha256:82ad92fd58da0d12af7482ecdb5f2470a04c9c9a53ced65b9bbb4a205377602e \ - --hash=sha256:ee9519c246a72b1c084cea8d3b44ed6026e78a4a309cbedae9c37e4cb9fbb175 + # via + # requests + # sentry-sdk +uvicorn==0.34.0 \ + --hash=sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4 \ + --hash=sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9 # via -r requirements/main.in uvloop==0.21.0 \ --hash=sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0 \ diff --git a/src/mobu/exceptions.py b/src/mobu/exceptions.py index ffe98e49..bfb874ab 100644 --- a/src/mobu/exceptions.py +++ b/src/mobu/exceptions.py @@ -2,45 +2,28 @@ from __future__ import annotations -import datetime -import json import re from pathlib import Path -from typing import Self, override +from typing import Self -import rubin.nublado.client.exceptions as ne from fastapi import status from pydantic import ValidationError from safir.fastapi import ClientRequestError from safir.models import ErrorLocation -from safir.slack.blockkit import ( - SlackBaseBlock, - SlackBaseField, - SlackCodeBlock, - SlackMessage, - SlackTextBlock, - SlackTextField, -) + +from mobu.safir.sentry import SentryException, SentryWebException _ANSI_REGEX = re.compile(r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]") """Regex that matches ANSI escape sequences.""" __all__ = [ - "CodeExecutionError", "ComparisonError", "FlockNotFoundError", "GafaelfawrParseError", "GafaelfawrWebError", "GitHubFileNotFoundError", - "JupyterProtocolError", - "JupyterTimeoutError", - "JupyterWebError", - "MobuMixin", - "MobuSlackException", - "MobuSlackWebException", "MonkeyNotFoundError", "SubprocessError", - "TAPClientError", ] @@ -67,26 +50,7 @@ def _remove_ansi_escapes(string: str) -> str: return _ANSI_REGEX.sub("", string) -class MobuMixin: - """Mixin class to add `event` and `monkey` fields to Exception.""" - - def mobu_init( - self, event: str | None = None, monkey: str | None = None - ) -> None: - """Initialize mobu-specific fields.""" - self.event: str | None = event - self.monkey: str | None = monkey - - def mobu_fields(self) -> list[SlackBaseField]: - fields: list[SlackBaseField] = [] - if self.event: - fields.append(SlackTextField(heading="Event", text=self.event)) - if self.monkey: - fields.append(SlackTextField(heading="Monkey", text=self.monkey)) - return fields - - -class GafaelfawrParseError(ne.NubladoClientSlackException): +class GafaelfawrParseError(SentryException): """Unable to parse the reply from Gafaelfawr. Parameters @@ -123,25 +87,15 @@ def from_exception( def __init__( self, message: str, error: str, user: str | None = None ) -> None: - super().__init__(message, user) - self.error = error + super().__init__(message) + if user: + self.tags["gafaelfawr_user"] = user + self.contexts["validation_info"] = {"error": error} - @override - def to_slack(self) -> SlackMessage: - """Convert to a Slack message for Slack alerting. - - Returns - ------- - SlackMessage - Slack message suitable for posting as an alert. - """ - message = super().to_slack() - block = SlackCodeBlock(heading="Error", code=self.error) - message.blocks.append(block) - return message + self.error = error -class GafaelfawrWebError(ne.NubladoClientSlackWebException): +class GafaelfawrWebError(SentryWebException): """An API call to Gafaelfawr failed.""" @@ -169,305 +123,13 @@ def __init__(self, monkey: str) -> None: super().__init__(msg, ErrorLocation.path, ["monkey"]) -class MobuSlackException(ne.NubladoClientSlackException, MobuMixin): - """Represents an exception that can be reported to Slack. - - This adds some additional fields to `~safir.slack.blockkit.SlackException` - but is otherwise equivalent. It is intended to be subclassed. Subclasses - must override the `to_slack` method. - - Parameters - ---------- - msg - Exception message. - user - User mobu was operating as when the exception happened. - started_at - When the operation started. - failed_at - When the operation failed (defaults to the current time). - - Attributes - ---------- - started_at - When the operation that ended in an exception started. - failed_at - When the operation that ended in an exception failed - (defaults to the current time). - monkey - The running monkey in which the exception happened. - event - Name of the business event that provoked the exception. - annotations - Additional annotations for the running business. - """ - - def __init__( - self, - msg: str, - user: str | None = None, - *, - started_at: datetime.datetime | None = None, - failed_at: datetime.datetime | None = None, - monkey: str | None = None, - event: str | None = None, - ) -> None: - super().__init__(msg, user, failed_at=failed_at, started_at=started_at) - self.mobu_init(monkey=monkey, event=event) - - @classmethod - def from_client_exception( - cls, - exc: ne.NubladoClientSlackException, - *, - started_at: datetime.datetime | None = None, - failed_at: datetime.datetime | None = None, - monkey: str | None = None, - event: str | None = None, - annotations: dict[str, str] | None = None, - ) -> Self: - """ - Add Mobu-specific fields to exception from NubladoClient layer. - - Parameters - ---------- - exc - Original exception - started_at - Timestamp for beginning of operation that caused the exception, - if known. - failed_at - Timestamp for failure of operation that caused the exception, - if known (defaults to the current time). - monkey - Monkey spawning the lab, if known. - event - Event (from mobu's perspective) spawning the lab, if known. - annotations - Additional annotations - - Returns - ------- - MobuSlackException - Converted exception. - """ - new_exc = cls( - msg=exc.message, - user=exc.user, - started_at=started_at or exc.started_at, - failed_at=failed_at or exc.failed_at, - event=event, - monkey=monkey, - ) - new_exc.annotations.update(exc.annotations or {}) - new_exc.annotations.update(annotations or {}) - return new_exc - - @override - def common_fields(self) -> list[SlackBaseField]: - """Return common fields to put in any alert. - - Returns - ------- - list of SlackBaseField - Common fields to add to the Slack message. - """ - fields = super().common_fields() - fields.extend(self.mobu_fields()) - image = self.annotations.get("image") - if image: - fields.append(SlackTextField(heading="Image", text=image)) - return fields - - @override - def to_slack(self) -> SlackMessage: - """Format the error as a Slack Block Kit message. - - This is the generic version that only reports the text of the - exception and common fields. Most classes will want to override it. - - Returns - ------- - SlackMessage - Formatted Slack message. - """ - return SlackMessage( - message=str(self), - blocks=self.common_blocks(), - fields=self.common_fields(), - ) - - -class MobuSlackWebException( - ne.NubladoClientSlackWebException, MobuSlackException -): - """Represents a web exception that can be reported to Slack. - - Similar to `MobuSlackException`, this adds some additional fields to - `~rubin.nublado.client.SlackWebException` but is otherwise equivalent. It - is intended to be subclassed. Subclasses may want to override the - `to_slack` method. - """ - - @override - def common_blocks(self) -> list[SlackBaseBlock]: - blocks = MobuSlackException.common_blocks(self) - if self.url: - text = f"{self.method} {self.url}" if self.method else self.url - blocks.append(SlackTextBlock(heading="URL", text=text)) - return blocks - - -class NotebookRepositoryError(MobuSlackException): +class NotebookRepositoryError(Exception): """The repository containing notebooks to run is not valid.""" -class RepositoryConfigError(MobuSlackException): +class RepositoryConfigError(Exception): """The in-repo mobu.yaml config file is invalid.""" - def __init__( - self, - *, - err: Exception, - user: str, - repo_url: str, - repo_ref: str, - config_file: Path, - ) -> None: - super().__init__(str(err), user) - self.err = err - self.user = user - self.repo_url = repo_url - self.repo_ref = repo_ref - self.config_file = config_file - - @override - def to_slack(self) -> SlackMessage: - message = super().to_slack() - message.blocks += [ - SlackTextBlock(heading="GitHub Repository", text=self.repo_url), - SlackTextBlock(heading="Git Ref", text=self.repo_ref), - ] - message.attachments += [ - SlackCodeBlock( - heading="Error", - code=f"{type(self.err).__name__}: {self.err!s}", - ) - ] - message.message = f"Error parsing config file: {self.config_file}" - return message - - -class CodeExecutionError(ne.CodeExecutionError, MobuMixin): - """Error generated by code execution in a notebook on JupyterLab.""" - - def __init__( - self, - *, - user: str, - code: str | None = None, - code_type: str = "code", - error: str | None = None, - status: str | None = None, - monkey: str | None = None, - event: str | None = None, - started_at: datetime.datetime | None = None, - failed_at: datetime.datetime | None = None, - ) -> None: - super().__init__( - user=user, - code=code, - code_type=code_type, - error=error, - status=status, - started_at=started_at, - failed_at=failed_at, - ) - self.mobu_init(monkey=monkey, event=event) - - def __str__(self) -> str: - if self.annotations.get("notebook"): - notebook = self.annotations["notebook"] - if self.annotations.get("cell"): - cell = self.annotations["cell"] - msg = f"{self.user}: cell {cell} of notebook {notebook} failed" - else: - msg = f"{self.user}: cell of notebook {notebook} failed" - if self.status: - msg += f" (status: {self.status})" - if self.code: - msg += f"\nCode: {self.code}" - elif self.code: - msg = f"{self.user}: running {self.code_type} '{self.code}' failed" - else: - msg = f"{self.user}: running {self.code_type} failed" - if self.error: - msg += f"\nError: {_remove_ansi_escapes(self.error)}" - return msg - - @override - def common_fields(self) -> list[SlackBaseField]: - """Return common fields to put in any alert. - - Returns - ------- - list of SlackBaseField - Common fields to add to the Slack message. - """ - fields = super().common_fields() - fields.extend(self.mobu_fields()) - return fields - - @classmethod - def from_client_exception( - cls, - exc: ne.CodeExecutionError, - monkey: str | None = None, - event: str | None = None, - annotations: dict[str, str] | None = None, - started_at: datetime.datetime | None = None, - failed_at: datetime.datetime | None = None, - ) -> Self: - """ - Add Mobu-specific fields to exception from NubladoClient layer. - - Parameters - ---------- - exc - Original exception - monkey - Monkey spawning the lab, if known. - event - Event (from mobu's perspective) spawning the lab, if known. - annotations - Additional annotations - started_at - Timestamp for beginning of operation that caused the exception, - if known. - failed_at - Timestamp for failure of operation that caused the exception, - if known (defaults to the current time). - - Returns - ------- - CodeExecutionError - Converted exception. - """ - new_exc = cls( - user=exc.user or "", - code=exc.code, - code_type=exc.code_type, - error=exc.error, - status=exc.status, - started_at=started_at or exc.started_at, - failed_at=failed_at or exc.failed_at, - monkey=monkey, - event=event, - ) - new_exc.annotations.update(exc.annotations or {}) - new_exc.annotations.update(annotations or {}) - return new_exc - class GitHubFileNotFoundError(Exception): """Tried to retrieve contents for a non-existent file in a GitHub @@ -475,539 +137,20 @@ class GitHubFileNotFoundError(Exception): """ -class JupyterProtocolError(ne.JupyterProtocolError, MobuMixin): - """Some error occurred when talking to JupyterHub or JupyterLab.""" - - def __init__( - self, - msg: str, - user: str | None = None, - *, - started_at: datetime.datetime | None = None, - failed_at: datetime.datetime | None = None, - monkey: str | None = None, - event: str | None = None, - ) -> None: - super().__init__( - msg=msg, user=user, started_at=started_at, failed_at=failed_at - ) - self.mobu_init(monkey=monkey, event=event) - - @classmethod - def from_client_exception( - cls, - exc: ne.JupyterProtocolError, - event: str | None = None, - monkey: str | None = None, - started_at: datetime.datetime | None = None, - failed_at: datetime.datetime | None = None, - annotations: dict[str, str] | None = None, - ) -> Self: - """ - Add Mobu-specific fields to exception from NubladoClient layer. - - Parameters - ---------- - exc - Original exception - monkey - Monkey spawning the lab, if known. - event - Event (from mobu's perspective) spawning the lab, if known. - annotations - Additional annotations - started_at - Timestamp for beginning of operation that caused the exception, - if known. - failed_at - Timestamp for failure of operation that caused the exception, - if known (defaults to the current time). - - Returns - ------- - JupyterProtocolError - Converted exception. - """ - new_exc = cls( - msg=exc.message, - user=exc.user, - started_at=started_at or exc.started_at, - failed_at=failed_at or exc.failed_at, - monkey=monkey, - event=event, - ) - new_exc.annotations.update(exc.annotations or {}) - new_exc.annotations.update(annotations or {}) - return new_exc - - @override - def common_fields(self) -> list[SlackBaseField]: - """Return common fields to put in any alert. - - Returns - ------- - list of SlackBaseField - Common fields to add to the Slack message. - """ - fields = super().common_fields() - fields.extend(self.mobu_fields()) - return fields - - -class JupyterSpawnError(ne.JupyterSpawnError, MobuMixin): - """The Jupyter Lab pod failed to spawn.""" - - def __init__( - self, - log: str, - user: str, - message: str | None = None, - monkey: str | None = None, - event: str | None = None, - started_at: datetime.datetime | None = None, - failed_at: datetime.datetime | None = None, - ) -> None: - if message: - message = f"Spawning lab failed: {message}" - else: - message = "Spawning lab failed" - super().__init__( - message, user, started_at=started_at, failed_at=failed_at - ) - self.log = log - self.mobu_init(monkey=monkey, event=event) - - @classmethod - def from_client_exception( - cls, - exc: ne.JupyterSpawnError, - monkey: str | None = None, - event: str | None = None, - annotations: dict[str, str] | None = None, - started_at: datetime.datetime | None = None, - failed_at: datetime.datetime | None = None, - ) -> Self: - """ - Add Mobu-specific fields to exception from NubladoClient layer. - - Parameters - ---------- - exc - Original exception - monkey - Monkey spawning the lab, if known. - event - Event (from mobu's perspective) spawning the lab, if known. - annotations - Additional annotations - started_at - Timestamp for beginning of operation that caused the exception, - if known. - failed_at - Timestamp for failure of operation that caused the exception, - if known (defaults to the current time). - - Returns - ------- - JupyterSpawnError - Converted exception. - """ - new_exc = cls( - log=exc.log, - user=exc.user or "", - message=exc.message, - monkey=monkey, - event=event, - started_at=started_at or exc.started_at, - failed_at=failed_at or exc.failed_at, - ) - new_exc.annotations.update(exc.annotations or {}) - new_exc.annotations.update(annotations or {}) - return new_exc - - @classmethod - def from_exception( - cls, - log: str, - exc: Exception, - user: str, - started_at: datetime.datetime | None = None, - failed_at: datetime.datetime | None = None, - *, - monkey: str | None = None, - event: str | None = None, - annotations: dict[str, str] | None = None, - ) -> Self: - """Convert from an arbitrary exception to a spawn error. - - Parameters - ---------- - log - Log of the spawn to this point. - exc - Exception that terminated the spawn attempt. - user - Username of the user spawning the lab. - monkey - Monkey spawning the lab, if known. - event - Event (from mobu's perspective) spawning the lab, if known. - annotations - Additional annotations - started_at - Timestamp for beginning of operation that caused the exception, - if known. - failed_at - Timestamp for failure of operation that caused the exception, - if known (defaults to the current time). - - Returns - ------- - JupyterSpawnError - Converted exception. - """ - client_exc = super().from_exception(log, exc, user) - new_exc = cls.from_client_exception( - client_exc, - monkey=monkey, - event=event, - started_at=started_at or client_exc.started_at, - failed_at=failed_at or client_exc.failed_at, - ) - new_exc.annotations.update(client_exc.annotations or {}) - new_exc.annotations.update(annotations or {}) - return new_exc - - @override - def common_fields(self) -> list[SlackBaseField]: - """Return common fields to put in any alert. - - Returns - ------- - list of SlackBaseField - Common fields to add to the Slack message. - """ - fields = super().common_fields() - fields.extend(self.mobu_fields()) - return fields - - -class JupyterTimeoutError(ne.JupyterTimeoutError, MobuMixin): - """Timed out waiting for the lab to spawn.""" - - def __init__( - self, - msg: str, - user: str, - log: str | None = None, - *, - monkey: str | None = None, - event: str | None = None, - started_at: datetime.datetime | None = None, - failed_at: datetime.datetime | None = None, - ) -> None: - super().__init__(msg, user, started_at=started_at, failed_at=failed_at) - self.log = log - self.mobu_init(monkey=monkey, event=event) - - @override - def common_fields(self) -> list[SlackBaseField]: - """Return common fields to put in any alert. - - Returns - ------- - list of SlackBaseField - Common fields to add to the Slack message. - """ - fields = super().common_fields() - fields.extend(self.mobu_fields()) - return fields - - @classmethod - def from_client_exception( - cls, - exc: ne.JupyterTimeoutError, - monkey: str | None = None, - event: str | None = None, - annotations: dict[str, str] | None = None, - started_at: datetime.datetime | None = None, - failed_at: datetime.datetime | None = None, - ) -> Self: - """ - Add Mobu-specific fields to exception from NubladoClient layer. - - Parameters - ---------- - exc - Original exception - monkey - Monkey spawning the lab, if known. - event - Event (from mobu's perspective) spawning the lab, if known. - annotations - Additional annotations - started_at - Timestamp for beginning of operation that caused the exception, - if known. - failed_at - Timestamp for failure of operation that caused the exception, - if known (defaults to the current time). - - Returns - ------- - JupyterTimeoutError - Converted exception. - """ - new_exc = cls( - log=exc.log, - user=exc.user or "", - msg=exc.message, - monkey=monkey, - event=event, - started_at=started_at or exc.started_at, - failed_at=failed_at or exc.failed_at, - ) - new_exc.annotations.update(exc.annotations or {}) - new_exc.annotations.update(annotations or {}) - return new_exc - - -class JupyterWebError(ne.JupyterWebError, MobuMixin): - """An error occurred when talking to JupyterHub or a Jupyter lab.""" - - def __init__( - self, - msg: str, - user: str | None = None, - *, - monkey: str | None = None, - event: str | None = None, - started_at: datetime.datetime | None = None, - failed_at: datetime.datetime | None = None, - ) -> None: - super().__init__( - message=msg, user=user, started_at=started_at, failed_at=failed_at - ) - self.mobu_init(monkey=monkey, event=event) - - @classmethod - def from_client_exception( - cls, - exc: ne.JupyterWebError, - monkey: str | None = None, - event: str | None = None, - annotations: dict[str, str] | None = None, - started_at: datetime.datetime | None = None, - failed_at: datetime.datetime | None = None, - ) -> Self: - """ - Add Mobu-specific fields to exception from NubladoClient layer. - - Parameters - ---------- - exc - Original exception - monkey - Monkey spawning the lab, if known. - event - Event (from mobu's perspective) spawning the lab, if known. - annotations - Additional annotations - started_at - Timestamp for beginning of operation that caused the exception, - if known. - failed_at - Timestamp for failure of operation that caused the exception, - if known (defaults to the current time). - - Returns - ------- - JupyterWebError - Converted exception. - """ - new_exc = cls( - msg=exc.message, - user=exc.user, - started_at=started_at or exc.started_at, - failed_at=failed_at or exc.failed_at, - monkey=monkey, - event=event, - ) - new_exc.annotations.update(exc.annotations or {}) - new_exc.annotations.update(annotations or {}) - new_exc.event = event - new_exc.method = exc.method - new_exc.url = exc.url - new_exc.body = exc.body - return new_exc - - @override - def common_fields(self) -> list[SlackBaseField]: - """Return common fields to put in any alert. - - Returns - ------- - list of SlackBaseField - Common fields to add to the Slack message. - """ - fields = super().common_fields() - fields.extend(self.mobu_fields()) - return fields - - -class JupyterWebSocketError(ne.JupyterWebSocketError, MobuMixin): - """An error occurred talking to the Jupyter lab WebSocket.""" - - def __init__( - self, - msg: str, - *, - user: str, - code: int | None = None, - reason: str | None = None, - status: int | None = None, - body: bytes | None = None, - monkey: str | None = None, - event: str | None = None, - started_at: datetime.datetime | None = None, - failed_at: datetime.datetime | None = None, - ) -> None: - super().__init__( - msg=msg, - user=user, - code=code, - reason=reason, - status=status, - started_at=started_at, - failed_at=failed_at, - body=body, - ) - self.mobu_init(monkey=monkey, event=event) - - @override - def to_slack(self) -> SlackMessage: - """Format this exception as a Slack notification. - - Returns - ------- - SlackMessage - Formatted message. - """ - message = super().to_slack() - - if self.reason: - reason = self.reason - if self.code: - reason = f"{self.reason} ({self.code})" - else: - reason = self.reason - field = SlackTextField(heading="Reason", text=reason) - message.fields.append(field) - elif self.code: - field = SlackTextField(heading="Code", text=str(self.code)) - message.fields.append(field) - - if self.body: - block = SlackTextBlock(heading="Body", text=self.body) - message.blocks.append(block) - - return message - - @override - def common_fields(self) -> list[SlackBaseField]: - """Return common fields to put in any alert. - - Returns - ------- - list of SlackBaseField - Common fields to add to the Slack message. - """ - fields = super().common_fields() - fields.extend(self.mobu_fields()) - return fields - - @classmethod - def from_client_exception( - cls, - exc: ne.JupyterWebSocketError, - monkey: str | None = None, - event: str | None = None, - annotations: dict[str, str] | None = None, - started_at: datetime.datetime | None = None, - failed_at: datetime.datetime | None = None, - ) -> Self: - """ - Add Mobu-specific fields to exception from NubladoClient layer. - - Parameters - ---------- - exc - Original exception - monkey - Monkey spawning the lab, if known. - event - Event (from mobu's perspective) spawning the lab, if known. - annotations - Additional annotations - started_at - Timestamp for beginning of operation that caused the exception, - if known. - failed_at - Timestamp for failure of operation that caused the exception, - if known (defaults to the current time). - - Returns - ------- - JupyterWebSocketError - Converted exception. - """ - body = exc.body - if body is not None: - body_bytes = body.encode() - new_exc = cls( - msg=exc.message, - user=exc.user or "", - code=exc.code, - reason=exc.reason, - status=exc.status, - body=body_bytes, - monkey=monkey, - event=event, - started_at=started_at or exc.started_at, - failed_at=failed_at or exc.failed_at, - ) - new_exc.annotations.update(exc.annotations or {}) - new_exc.annotations.update(annotations or {}) - return new_exc - - -class TAPClientError(MobuSlackException): - """Creating a TAP client failed.""" - - def __init__(self, exc: Exception, *, user: str) -> None: - if str(exc): - error = f"{type(exc).__name__}: {exc!s}" - else: - error = type(exc).__name__ - msg = f"Unable to create TAP client: {error}" - super().__init__(msg, user) - - -class SubprocessError(MobuSlackException): +class SubprocessError(SentryException): """Running a subprocess failed.""" def __init__( self, msg: str, *, - user: str | None = None, returncode: int | None = None, stdout: str | None = None, stderr: str | None = None, cwd: Path | None = None, env: dict[str, str] | None = None, ) -> None: - super().__init__(msg, user) + super().__init__(msg) self.msg = msg self.returncode = returncode self.stdout = stdout @@ -1015,6 +158,14 @@ def __init__( self.cwd = cwd self.env = env + self.contexts["subprocess_info"] = { + "return_code": str(self.returncode), + "stdout": self.stdout, + "stderr": self.stderr, + "directory": str(self.cwd), + "env": self.env, + } + def __str__(self) -> str: return ( f"{self.msg} with rc={self.returncode};" @@ -1022,74 +173,41 @@ def __str__(self) -> str: f" cwd='{self.cwd}'; env='{self.env}'" ) - @override - def to_slack(self) -> SlackMessage: - """Format this exception as a Slack notification. - - Returns - ------- - SlackMessage - Formatted message. - """ - message = SlackMessage( - message=self.msg, - blocks=self.common_blocks(), - fields=self.common_fields(), - ) - - field = SlackTextField( - heading="Return Code", text=str(self.returncode) - ) - message.fields.append(field) - if self.cwd: - message.fields.append(field) - field = SlackTextField(heading="Directory", text=str(self.cwd)) - if self.stdout: - block = SlackCodeBlock(heading="Stdout", code=self.stdout) - message.blocks.append(block) - if self.stderr: - block = SlackCodeBlock(heading="Stderr", code=self.stderr) - message.blocks.append(block) - if self.env: - block = SlackCodeBlock( - heading="Environment", - code=json.dumps(self.env, sort_keys=True, indent=4), - ) - message.blocks.append(block) - return message - -class ComparisonError(MobuSlackException): +class ComparisonError(SentryException): """Comparing two strings failed.""" def __init__( self, - user: str | None = None, *, expected: str, received: str, ) -> None: - super().__init__("Comparison failed", user) + super().__init__("Comparison failed") self.expected = expected self.received = received + self.contexts["comparison_info"] = { + "expected": self.expected, + "received": self.received, + } + def __str__(self) -> str: return ( f"Comparison failed: expected '{self.expected}', but" f" received '{self.received}'" ) - def to_slack(self) -> SlackMessage: - """Format this exception as a Slack notification. - Returns - ------- - SlackMessage - Formatted message. - """ - message = super().to_slack() - field = SlackTextField(heading="Expected", text=self.expected) - message.fields.append(field) - field = SlackTextField(heading="Received", text=self.received) - message.fields.append(field) - return message +class JupyterSpawnTimeoutError(Exception): + """Timed out waiting for the lab to spawn.""" + + +class JupyterDeleteTimeoutError(Exception): ... + + +class JupyterSpawnError(Exception): + """The Jupyter Lab pod failed to spawn.""" + + +class NotebookCellExecutionError(Exception): ... diff --git a/src/mobu/main.py b/src/mobu/main.py index 0c0b4dca..c06b14af 100644 --- a/src/mobu/main.py +++ b/src/mobu/main.py @@ -10,11 +10,13 @@ from __future__ import annotations import json +import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager from datetime import timedelta from importlib.metadata import metadata, version +import sentry_sdk import structlog from fastapi import FastAPI from fastapi.openapi.utils import get_openapi @@ -33,11 +35,22 @@ api_router as github_refresh_app_router, ) from .handlers.internal import internal_router +from .safir.sentry import before_send_handler from .status import post_status __all__ = ["create_app", "lifespan"] +sentry_sdk.init( + before_send=before_send_handler, + # This comes directly from the env, rather than from the + # config file, because we want sentry initialized as + # early in the app as possible, even before we attempt + # to load the config file. + traces_sample_rate=float(os.environ.get("SENTRY_TRACES_SAMPLE_RATE", "0")), +) + + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Set up and tear down the the base application.""" diff --git a/src/mobu/models/business/base.py b/src/mobu/models/business/base.py index f2e36825..f230032f 100644 --- a/src/mobu/models/business/base.py +++ b/src/mobu/models/business/base.py @@ -6,8 +6,6 @@ from pydantic import BaseModel, ConfigDict, Field, PlainSerializer from safir.logging import LogLevel -from ..timings import StopwatchData - __all__ = [ "BusinessConfig", "BusinessData", @@ -89,6 +87,4 @@ class BusinessData(BaseModel): ..., title="If the business is currently in the process of refreshing" ) - timings: list[StopwatchData] = Field(..., title="Timings of events") - model_config = ConfigDict(extra="forbid") diff --git a/src/mobu/safir/__init__.py b/src/mobu/safir/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/mobu/safir/sentry.py b/src/mobu/safir/sentry.py new file mode 100644 index 00000000..98051c5e --- /dev/null +++ b/src/mobu/safir/sentry.py @@ -0,0 +1,199 @@ +"""Sentry helpers.""" + +from datetime import timedelta +from typing import Any, Self + +from httpx import HTTPError, HTTPStatusError +from safir.datetime import current_datetime +from sentry_sdk.tracing import Span +from sentry_sdk.types import Event, Hint + +__all__ = [ + "SentryException", + "SentryWebException", + "before_send_handler", + "duration", + "enrich", + "fingerprint_env", +] + + +def duration(span: Span) -> timedelta: + """Return the time spent in a span (to the present if not finished).""" + if span.timestamp is None: + timestamp = current_datetime(microseconds=True) + else: + timestamp = span.timestamp + + return timestamp - span.start_timestamp + + +def fingerprint_env(event: Event, _: Hint) -> Event: + """Add the environment to the event fingerprint. + + Without doing this, Sentry groups events from all environments into the + same issue, and alerts that notify on new issues won't notify on a prod + event if an issue has already been created from an event from another env + :( + + https://github.com/getsentry/sentry/issues/64354 + """ + env = event.get("environment", "no environment") + fingerprint = event.get("fingerprint", []) + event["fingerprint"] = [ + "{{ default }}", + *fingerprint, + env, + ] + return event + + +def enrich(event: Event, hint: Hint) -> Event: + """Add tags and context from ``SentryException``s.""" + if exc_info := hint.get("exc_info"): + exc = exc_info[1] + if isinstance(exc, SentryException): + exc.enrich(event) + return event + + +def before_send_handler(event: Event, hint: Hint) -> Event: + """Add the env to the fingerprint, and enrich from ``SentryException``s.""" + event = fingerprint_env(event, hint) + return enrich(event, hint) + + +class SentryException(Exception): + """Enriches the Sentry context when paired with the ``enrich`` handler.""" + + def __init__( + self, + message: str, + ) -> None: + # Do not call the parent Exception constructor here, because calling + # it with a different number of arguments than the constructor + # argument of derived exceptions breaks pickling. See the end of + # https://github.com/python/cpython/issues/44791. This requires + # implementing __str__ rather than relying on the default behavior. + # + # Arguably, this is a bug in the __reduce__ method of BaseException + # and its interaction with constructors, but it appears to be hard to + # fix. See https://github.com/python/cpython/issues/76877. + self.message = message + self.tags: dict[str, str] = {} + self.contexts: dict[str, dict[str, Any]] = {} + + def __str__(self) -> str: + return self.message + + def enrich(self, event: Event) -> Event: + """Merge our tags and contexts into the event's.""" + event["tags"] = event.setdefault("tags", {}) + event["tags"].update(self.tags) + event["contexts"] = event.get("contexts", {}) | self.contexts + return event + + +class SentryWebException(SentryException): + """Parent class of exceptions arising from HTTPX_ failures. + + Captures additional information from any HTTPX_ exception. Intended to be + subclassed. + + Parameters + ---------- + message + Exception string value, which is the default Slack message. + method + Method of request. + url + URL of the request. + user + Username on whose behalf the request is being made. + status + Status code of failure, if any. + body + Body of failure message, if any. + """ + + @classmethod + def from_exception(cls, exc: HTTPError, user: str | None = None) -> Self: + """Create an exception from an HTTPX_ exception. + + Parameters + ---------- + exc + Exception from HTTPX. + user + User on whose behalf the request is being made, if known. + + Returns + ------- + SlackWebException + Newly-constructed exception. + """ + if isinstance(exc, HTTPStatusError): + status = exc.response.status_code + method = exc.request.method + message = f"Status {status} from {method} {exc.request.url}" + return cls( + message, + method=exc.request.method, + url=str(exc.request.url), + user=user, + status=status, + body=exc.response.text, + ) + else: + message = f"{type(exc).__name__}: {exc!s}" + + # All httpx.HTTPError exceptions have a slot for the request, + # initialized to None and then sometimes added by child + # constructors or during exception processing. The request + # property is a property method that raises RuntimeError if + # request has not been set, so we can't just check for None. + # Hence this approach of attempting to use the request and falling + # back on reporting less data if that raised any exception. + try: + return cls( + message, + method=exc.request.method, + url=str(exc.request.url), + user=user, + ) + except Exception: + return cls(message, user=user) + + def __init__( + self, + message: str, + *, + method: str | None = None, + url: str | None = None, + user: str | None = None, + status: int | None = None, + body: str | None = None, + ) -> None: + super().__init__(message) + self.method = method + self.url = url + self.status = status + self.body = body + self.user = user + + if self.method: + self.tags["httpx_request_method"] = self.method + if self.user: + self.tags["gafaelfaw_user"] = self.user + if self.url: + self.tags["httpx_request_url"] = self.url + if self.status: + self.tags["httpx_request_status"] = str(self.status) + if self.body: + self.contexts["httpx_request_info"] = {"body": self.body} + + def __str__(self) -> str: + result = self.message + if self.body: + result += f"\nBody:\n{self.body}\n" + return result diff --git a/src/mobu/sentry.py b/src/mobu/sentry.py new file mode 100644 index 00000000..183c12bb --- /dev/null +++ b/src/mobu/sentry.py @@ -0,0 +1,53 @@ +"""Helpers for sentry instrumentation.""" + +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +from typing import Any + +import sentry_sdk +from sentry_sdk.tracing import Span +from sentry_sdk.types import Event, Hint + +from .safir.sentry import before_send_handler + +__all__ = [ + "before_send", + "start_span", +] + + +def before_send(event: Event, hint: Hint) -> Event: + """Add tags to fingerprint so that distinct issues are created.""" + username = str(event.get("user", {}).get("username", "no username")) + fingerprint = event.get("fingerprint", []) + event["fingerprint"] = [ + *fingerprint, + username, + "{{ tags.flock }}", + "{{ tags.business }}", + "{{ tags.notebook }}", + ] + return before_send_handler(event, hint) + + +@contextmanager +def start_span(op: str, **kwargs: Any) -> Generator[Span]: + """Start a span and set/unset the op and start time in the event context. + + This will propogate the op and start time to any error events that get sent + to Sentry, event if the trace does not get sent. + """ + with sentry_sdk.start_span(op=op, **kwargs) as span: + sentry_sdk.set_context( + "phase", {"phase": op, "started_at": span.start_timestamp} + ) + + # You can't see the time a span started in the Sentry UI, only the time + # the entire transaction started + span.set_tag("started_at", span.start_timestamp) + + yield span + + sentry_sdk.set_context("phase", {}) diff --git a/src/mobu/services/business/base.py b/src/mobu/services/business/base.py index 2a890c56..c2d95925 100644 --- a/src/mobu/services/business/base.py +++ b/src/mobu/services/business/base.py @@ -10,6 +10,7 @@ from enum import Enum from typing import Generic, TypedDict, TypeVar +import sentry_sdk from httpx import AsyncClient from safir.datetime import current_datetime from structlog.stdlib import BoundLogger @@ -18,7 +19,7 @@ from ...events import Events from ...models.business.base import BusinessData, BusinessOptions from ...models.user import AuthenticatedUser -from ..timings import Timings +from ...sentry import start_span T = TypeVar("T", bound="BusinessOptions") U = TypeVar("U") @@ -113,7 +114,6 @@ def __init__( self.logger = logger self.success_count = 0 self.failure_count = 0 - self.timings = Timings() self.control: Queue[BusinessCommand] = Queue() self.stopping = False self.refreshing = False @@ -188,18 +188,22 @@ async def run_once(self) -> None: Calls `startup`, `execute`, `shutdown`, and `close`. """ self.logger.info("Starting up...") - try: - await self.startup() - await self.execute() - self.logger.info("Shutting down...") - await self.shutdown() - finally: - await self.close() + with sentry_sdk.start_transaction( + name=self.__class__.__name__, + op=self.__class__.__name__, + ): + try: + await self.startup() + await self.execute() + self.logger.info("Shutting down...") + await self.shutdown() + finally: + await self.close() async def idle(self) -> None: """Pause at the end of each business loop.""" self.logger.info("Idling...") - with self.timings.start("idle"): + with start_span(op="idle"): await self.pause(self.options.idle_time) async def error_idle(self) -> None: @@ -324,7 +328,6 @@ def dump(self) -> BusinessData: failure_count=self.failure_count, success_count=self.success_count, refreshing=self.refreshing, - timings=self.timings.dump(), ) def common_event_attrs(self) -> CommonEventAttrs: diff --git a/src/mobu/services/business/gitlfs.py b/src/mobu/services/business/gitlfs.py index c36874d7..51c628a4 100644 --- a/src/mobu/services/business/gitlfs.py +++ b/src/mobu/services/business/gitlfs.py @@ -14,6 +14,8 @@ from ...exceptions import ComparisonError from ...models.business.gitlfs import GitLFSBusinessOptions from ...models.user import AuthenticatedUser +from ...safir.sentry import duration +from ...sentry import start_span from ...storage.git import Git from .base import Business @@ -78,12 +80,13 @@ async def execute(self) -> None: self.logger.info("Running Git-LFS check...") event = GitLfsCheck(success=False, **self.common_event_attrs()) try: - with self.timings.start("execute git-lfs check") as sw: + with start_span(op="exectue git-lfs check") as span: await self._git_lfs_check() - elapsed = sw.elapsed.total_seconds() + span_duration = duration(span) + elapsed = span_duration.total_seconds() self.logger.info(f"...Git-LFS check finished after {elapsed}s") - event.duration = sw.elapsed + event.duration = span_duration event.success = True except: event.success = False @@ -107,26 +110,26 @@ async def _git_lfs_check(self) -> None: self._uuid = uuid.uuid4().hex with tempfile.TemporaryDirectory() as working_dir: self._working_dir = Path(working_dir) - with self.timings.start("create origin repo"): + with start_span(op="create origin repo"): await self._create_origin_repo() - with self.timings.start("populate origin repo"): + with start_span(op="populate origin repo"): await self._populate_origin_repo() - with self.timings.start("create checkout repo"): + with start_span(op="create checkout repo"): await self._create_checkout_repo() - with self.timings.start("add LFS-tracked assets"): + with start_span(op="add LFS-tracked assets"): await self._add_lfs_assets() git = self._git(repo=Path(self._working_dir / "checkout")) - with self.timings.start("add git credentials"): + with start_span(op="add git credentials"): await self._add_credentials(git) - with self.timings.start("push LFS-tracked assets"): + with start_span(op="push LFS-tracked assets"): await git.push("origin", "main") - with self.timings.start("remove git credentials"): + with start_span(op="remove git credentials"): Path(self._working_dir / ".git_credentials").unlink() - with self.timings.start("verify origin contents"): + with start_span(op="verify origin contents"): await self._verify_origin_contents() - with self.timings.start("create clone repo with asset"): + with start_span(op="create clone repo with asset"): await self._create_clone_repo() - with self.timings.start("verify asset contents"): + with start_span(op="verify asset contents"): await self._verify_asset_contents() async def _create_origin_repo(self) -> None: @@ -179,9 +182,9 @@ async def _populate_origin_repo(self) -> None: async def _add_lfs_assets(self) -> None: checkout_path = Path(self._working_dir / "checkout") git = self._git(repo=checkout_path) - with self.timings.start("install git lfs to checkout repo"): + with start_span(op="install git lfs to checkout repo"): await self._install_git_lfs(git) - with self.timings.start("add lfs data to checkout repo"): + with start_span(op="add lfs data to checkout repo"): await self._add_git_lfs_data(git) asset_path = Path(checkout_path / "assets") asset_path.mkdir() @@ -208,7 +211,7 @@ async def _install_git_lfs(self, git: Git, scope: str = "--local") -> None: async def _add_git_lfs_data(self, git: Git) -> None: if git.repo is None: raise ValueError("Git client repository cannot be 'None'") - with self.timings.start("git attribute installation"): + with start_span(op="git attribute installation"): shutil.copyfile( Path(self._package_data / "gitattributes"), Path(git.repo / ".gitattributes"), diff --git a/src/mobu/services/business/notebookrunner.py b/src/mobu/services/business/notebookrunner.py index dadf7559..c3ca0744 100644 --- a/src/mobu/services/business/notebookrunner.py +++ b/src/mobu/services/business/notebookrunner.py @@ -20,12 +20,17 @@ from httpx import AsyncClient from rubin.nublado.client import JupyterLabSession from rubin.nublado.client.models import CodeContext +from sentry_sdk import set_context, set_tag, start_transaction from structlog.stdlib import BoundLogger from ...constants import GITHUB_REPO_CONFIG_PATH from ...dependencies.config import config_dependency from ...events import Events, NotebookCellExecution, NotebookExecution -from ...exceptions import NotebookRepositoryError, RepositoryConfigError +from ...exceptions import ( + NotebookCellExecutionError, + NotebookRepositoryError, + RepositoryConfigError, +) from ...models.business.notebookrunner import ( ListNotebookRunnerOptions, NotebookFilterResults, @@ -35,6 +40,8 @@ ) from ...models.repo import RepoConfig from ...models.user import AuthenticatedUser +from ...safir.sentry import duration +from ...sentry import start_span from ...services.business.base import CommonEventAttrs from ...storage.git import Git from .nublado import NubladoBusiness @@ -103,14 +110,6 @@ def __init__( case ListNotebookRunnerOptions(notebooks_to_run=notebooks_to_run): self._notebooks_to_run = notebooks_to_run - def annotations(self, cell_id: str | None = None) -> dict[str, str]: - result = super().annotations() - if self._notebook: - result["notebook"] = self._notebook.name - if cell_id: - result["cell"] = cell_id - return result - async def startup(self) -> None: await self.initialize() await super().startup() @@ -132,6 +131,15 @@ async def initialize(self) -> None: await self.clone_repo() repo_config_path = self._repo_dir / GITHUB_REPO_CONFIG_PATH + set_context( + "repo_info", + { + "repo_url": self.options.repo_url, + "repo_ref": self.options.repo_ref, + "repo_hash": self._repo_hash, + "repo_config_file": GITHUB_REPO_CONFIG_PATH, + }, + ) if repo_config_path.exists(): try: repo_config = RepoConfig.model_validate( @@ -139,11 +147,7 @@ async def initialize(self) -> None: ) except Exception as err: raise RepositoryConfigError( - err=err, - user=self.user.username, - config_file=GITHUB_REPO_CONFIG_PATH, - repo_url=self.options.repo_url, - repo_ref=self.options.repo_ref, + f"Error parsing config file: {GITHUB_REPO_CONFIG_PATH}" ) from err else: repo_config = RepoConfig() @@ -151,6 +155,9 @@ async def initialize(self) -> None: exclude_dirs = repo_config.exclude_dirs self._exclude_paths = {self._repo_dir / path for path in exclude_dirs} self._notebooks = self.find_notebooks() + set_context( + "notebook_filter_info", self._notebooks.model_dump(mode="json") + ) self.logger.info("Repository cloned and ready") async def shutdown(self) -> None: @@ -166,7 +173,7 @@ async def refresh(self) -> None: async def clone_repo(self) -> None: url = self.options.repo_url ref = self.options.repo_ref - with self.timings.start("clone_repo"): + with start_span(op="clone_repo"): self._git.repo = self._repo_dir await self._git.clone(url, str(self._repo_dir)) await self._git.checkout(ref) @@ -196,7 +203,7 @@ def missing_services(self, notebook: Path) -> bool: return False def find_notebooks(self) -> NotebookFilterResults: - with self.timings.start("find_notebooks"): + with start_span(op="find_notebooks"): if self._repo_dir is None: raise NotebookRepositoryError( "Repository directory must be set", self.user.username @@ -260,9 +267,7 @@ def next_notebook(self) -> Path: def read_notebook_metadata(self, notebook: Path) -> NotebookMetadata: """Extract mobu-specific metadata from a notebook.""" - with self.timings.start( - "read_notebook_metadata", {"notebook": notebook.name} - ): + with start_span(op="read_notebook_metadata"): try: notebook_text = notebook.read_text() notebook_json = json.loads(notebook_text) @@ -273,7 +278,7 @@ def read_notebook_metadata(self, notebook: Path) -> NotebookMetadata: raise NotebookRepositoryError(msg, self.user.username) from e def read_notebook(self, notebook: Path) -> list[dict[str, Any]]: - with self.timings.start("read_notebook", {"notebook": notebook.name}): + with start_span(op="read_notebook"): try: notebook_text = notebook.read_text() cells = json.loads(notebook_text)["cells"] @@ -316,20 +321,32 @@ async def execute_code(self, session: JupyterLabSession) -> None: return self._notebook = self.next_notebook() - + relative_notebook = str( + self._notebook.relative_to(self._repo_dir or "/") + ) iteration = f"{count + 1}/{num_executions}" msg = f"Notebook {self._notebook.name} iteration {iteration}" self.logger.info(msg) - with self.timings.start( - "execute_notebook", self.annotations(self._notebook.name) - ) as sw: + set_tag("notebook", relative_notebook) + set_context( + "notebook_info", + { + "notebook": relative_notebook, + "iteration": iteration, + }, + ) + + with start_transaction( + name="execute_notebook", + op="execute_notebook", + ) as span: try: for cell in self.read_notebook(self._notebook): code = "".join(cell["source"]) cell_id = cell.get("id") or cell["_index"] ctx = CodeContext( - notebook=self._notebook.name, + notebook=relative_notebook, path=str(self._notebook), cell=cell_id, cell_number=f"#{cell['_index']}", @@ -340,13 +357,13 @@ async def execute_code(self, session: JupyterLabSession) -> None: break except: await self._publish_notebook_event( - duration=sw.elapsed, success=False + duration=duration(span), success=False ) raise self.logger.info(f"Success running notebook {self._notebook.name}") await self._publish_notebook_event( - duration=sw.elapsed, success=True + duration=duration(span), success=True ) if not self._notebook_paths: self.logger.info("Done with this cycle of notebooks") @@ -397,23 +414,34 @@ async def execute_cell( if not self._notebook: raise RuntimeError("Executing a cell without a notebook") self.logger.info(f"Executing cell {cell_id}:\n{code}\n") - with self.timings.start( - "execute_cell", self.annotations(cell_id) - ) as sw: + set_tag("cell", cell_id) + cell_info = {"code": code} + set_context("cell_info", cell_info) + with start_span(op="execute_cell") as span: + # The scope context only appears on the transaction, and not on + # individual spans. Since the cell info will be different for + # different spans, we need to set this data directly on the span. + # We have to set it in the context too so that it shows up in any + # exception events. Unfortuantely, span data is not included in + # exception events. + span.set_data("cell_info", cell_info) self._running_code = code try: reply = await session.run_python(code, context=context) - except: + except Exception as err: await self._publish_cell_event( cell_id=cell_id, - duration=sw.elapsed, + duration=duration(span), success=False, ) - raise + raise NotebookCellExecutionError( + f"{getattr(context, 'notebook', ' NotebookRunnerData: diff --git a/src/mobu/services/business/nublado.py b/src/mobu/services/business/nublado.py index cc12a864..7372efbc 100644 --- a/src/mobu/services/business/nublado.py +++ b/src/mobu/services/business/nublado.py @@ -10,21 +10,20 @@ from random import SystemRandom from typing import Generic, TypeVar -import rubin.nublado.client.exceptions as ne +import sentry_sdk from httpx import AsyncClient from rubin.nublado.client import JupyterLabSession, NubladoClient from safir.datetime import current_datetime, format_datetime_for_logging -from safir.slack.blockkit import SlackException +from sentry_sdk import set_context, set_tag +from sentry_sdk.tracing import Span from structlog.stdlib import BoundLogger from ...dependencies.config import config_dependency from ...events import Events, NubladoDeleteLab, NubladoSpawnLab from ...exceptions import ( - CodeExecutionError, - JupyterProtocolError, + JupyterDeleteTimeoutError, JupyterSpawnError, - JupyterTimeoutError, - JupyterWebError, + JupyterSpawnTimeoutError, ) from ...models.business.nublado import ( NubladoBusinessData, @@ -32,7 +31,8 @@ RunningImage, ) from ...models.user import AuthenticatedUser -from ...services.timings import Stopwatch +from ...safir.sentry import duration +from ...sentry import start_span from .base import Business T = TypeVar("T", bound="NubladoBusinessOptions") @@ -157,57 +157,31 @@ async def execute_code(self, session: JupyterLabSession) -> None: async def close(self) -> None: await self._client.close() - def annotations(self) -> dict[str, str]: - """Timer annotations to use. - - Subclasses should override this to add more annotations based on - current business state. They should call ``super().annotations()`` - and then add things to the resulting dictionary. - """ - result = {} - if self._image: - result["image"] = ( - self._image.description - or self._image.reference - or "" - ) - if self._node: - result["node"] = self._node - return result - async def startup(self) -> None: - if self.options.jitter: - with self.timings.start("pre_login_delay"): - max_delay = self.options.jitter.total_seconds() - delay = self._random.uniform(0, max_delay) - if not await self.pause(timedelta(seconds=delay)): - return - await self.hub_login() - if not await self._client.is_lab_stopped(): - try: - await self.delete_lab() - except JupyterTimeoutError: - msg = "Unable to delete pre-existing lab, continuing anyway" - self.logger.warning(msg) + with sentry_sdk.start_transaction( + name=f"{self.__class__.__name__} - startup", + op=f"{self.__class__.__name__} - startup", + ): + if self.options.jitter: + with start_span(op="pre_login_delay"): + max_delay = self.options.jitter.total_seconds() + delay = self._random.uniform(0, max_delay) + if not await self.pause(timedelta(seconds=delay)): + return + await self.hub_login() + if not await self._client.is_lab_stopped(): + try: + await self.delete_lab() + except JupyterDeleteTimeoutError: + msg = ( + "Unable to delete pre-existing lab, continuing anyway" + ) + self.logger.warning(msg) async def execute(self) -> None: - try: - await self._execute() - except Exception as exc: - monkey = getattr(exc, "monkey", None) - event = getattr(exc, "event", "execute_code") - if isinstance(exc, ne.CodeExecutionError): - raise CodeExecutionError.from_client_exception( - exc, - monkey=monkey, - event=event, - annotations=self.annotations(), - ) from exc - raise - - async def _execute(self) -> None: if self.options.delete_lab or await self._client.is_lab_stopped(): self._image = None + set_tag("image", None) if not await self.spawn_lab(): return await self.lab_login() @@ -224,17 +198,21 @@ async def execution_idle(self) -> bool: subclasses in `execute_code` in between each block of code that is executed. """ - with self.timings.start("execution_idle"): + with start_span(op="execution_idle"): return await self.pause(self.options.execution_idle_time) async def shutdown(self) -> None: - await self.hub_login() - await self.delete_lab() + with sentry_sdk.start_transaction( + name=f"{self.__class__.__name__} - shutdown", + op=f"{self.__class__.__name__} - shutdown", + ): + await self.hub_login() + await self.delete_lab() async def idle(self) -> None: if self.options.jitter: self.logger.info("Idling...") - with self.timings.start("idle"): + with start_span(op="idle"): extra_delay = self._random.uniform(0, self.options.jitter) await self.pause(self.options.idle_time + extra_delay) else: @@ -242,58 +220,34 @@ async def idle(self) -> None: async def hub_login(self) -> None: self.logger.info("Logging in to hub") - with self.timings.start("hub_login", self.annotations()) as sw: - try: - await self._client.auth_to_hub() - except ne.JupyterProtocolError as exc: - raise JupyterProtocolError.from_client_exception( - exc, - event=sw.event, - annotations=sw.annotations, - started_at=sw.start_time, - ) from exc - except ne.JupyterWebError as exc: - raise JupyterWebError.from_client_exception( - exc, - event=sw.event, - annotations=sw.annotations, - started_at=sw.start_time, - ) from exc + with start_span(op="hub_login"): + await self._client.auth_to_hub() async def spawn_lab(self) -> bool: - with self.timings.start("spawn_lab", self.annotations()) as sw: + with start_span(op="spawn_lab") as span: try: - result = await self._spawn_lab(sw) + result = await self._spawn_lab(span) except: await self.events.nublado_spawn_lab.publish( NubladoSpawnLab( success=False, - duration=sw.elapsed, + duration=duration(span), **self.common_event_attrs(), ) ) raise await self.events.nublado_spawn_lab.publish( NubladoSpawnLab( - success=True, duration=sw.elapsed, **self.common_event_attrs() + success=True, + duration=duration(span), + **self.common_event_attrs(), ) ) return result - async def _spawn_lab(self, sw: Stopwatch) -> bool: # noqa: C901 - # Ruff says this method is too complex, and it is, but it will become - # less complex when we refactor and potentially Sentry-fy the slack - # error reporting + async def _spawn_lab(self, span: Span) -> bool: timeout = self.options.spawn_timeout - try: - await self._client.spawn_lab(self.options.image) - except ne.JupyterWebError as exc: - raise JupyterWebError.from_client_exception( - exc, - event=sw.event, - annotations=sw.annotations, - started_at=sw.start_time, - ) from exc + await self._client.spawn_lab(self.options.image) # Pause before using the progress API, since otherwise it may not # have attached to the spawner and will not return a full stream @@ -310,69 +264,28 @@ async def _spawn_lab(self, sw: Stopwatch) -> bool: # noqa: C901 log_messages.append(ProgressLogMessage(message.message)) if message.ready: return True - except TimeoutError: + except: log = "\n".join([str(m) for m in log_messages]) - raise JupyterSpawnError( - log, - self.user.username, - event=sw.event, - started_at=sw.start_time, - ) from None - except ne.JupyterWebError as exc: - raise JupyterWebError.from_client_exception( - exc, - event=sw.event, - annotations=sw.annotations, - started_at=sw.start_time, - ) from exc - except SlackException: + set_context("spawn_info", {"log": log}) raise - except Exception as e: - log = "\n".join([str(m) for m in log_messages]) - user = self.user.username - raise JupyterSpawnError.from_exception( - log, - e, - user, - event=sw.event, - annotations=sw.annotations, - started_at=sw.start_time, - ) from e # We only fall through if the spawn failed, timed out, or if we're # stopping the business. if self.stopping: return False log = "\n".join([str(m) for m in log_messages]) - if sw.elapsed > timeout: - elapsed_seconds = round(sw.elapsed.total_seconds()) + set_context("spawn_info", {"log": log}) + spawn_duration = duration(span) + if spawn_duration > timeout: + elapsed_seconds = round(spawn_duration.total_seconds()) msg = f"Lab did not spawn after {elapsed_seconds}s" - raise JupyterTimeoutError( - msg, - self.user.username, - log, - event=sw.event, - started_at=sw.start_time, - ) - raise JupyterSpawnError( - log, - self.user.username, - event=sw.event, - started_at=sw.start_time, - ) + raise JupyterSpawnTimeoutError(msg) + raise JupyterSpawnError async def lab_login(self) -> None: self.logger.info("Logging in to lab") - with self.timings.start("lab_login", self.annotations()) as sw: - try: - await self._client.auth_to_lab() - except ne.JupyterProtocolError as exc: - raise JupyterProtocolError.from_client_exception( - exc, - event=sw.event, - annotations=sw.annotations, - started_at=sw.start_time, - ) from exc + with start_span(op="lab_login"): + await self._client.auth_to_lab() @asynccontextmanager async def open_session( @@ -380,19 +293,20 @@ async def open_session( ) -> AsyncIterator[JupyterLabSession]: self.logger.info("Creating lab session") opts = {"max_websocket_size": self.options.max_websocket_message_size} - stopwatch = self.timings.start("create_session", self.annotations()) + create_session_cm = start_span(op="create_session") + create_session_cm.__enter__() async with self._client.open_lab_session(notebook, **opts) as session: - stopwatch.stop() - with self.timings.start("execute_setup", self.annotations()): + create_session_cm.__exit__(None, None, None) + with start_span(op="execute_setup"): await self.setup_session(session) yield session await self.lab_login() self.logger.info("Deleting lab session") - stopwatch = self.timings.start( - "delete_session", self.annotations() - ) - stopwatch.stop() + delete_session_cm = start_span(op="delete_session") + delete_session_cm.__enter__() + delete_session_cm.__exit__(None, None, None) self._node = None + set_tag("node", None) async def setup_session(self, session: JupyterLabSession) -> None: image_data = await session.run_python(_GET_IMAGE) @@ -409,8 +323,15 @@ async def setup_session(self, session: JupyterLabSession) -> None: reference=reference.strip() if reference else None, description=description.strip() if description else None, ) + set_tag( + "image", + self._image.description + or self._image.reference + or "", + ) if self.options.get_node: self._node = await session.run_python(_GET_NODE) + set_tag("node", self._node) self.logger.info(f"Running on node {self._node}") if self.options.working_directory: path = self.options.working_directory @@ -419,14 +340,14 @@ async def setup_session(self, session: JupyterLabSession) -> None: await session.run_python(code) async def delete_lab(self) -> None: - with self.timings.start("delete_lab", self.annotations()) as sw: + with start_span(op="delete_lab") as span: try: - result = await self._delete_lab(sw) + result = await self._delete_lab() except: await self.events.nublado_delete_lab.publish( NubladoDeleteLab( success=False, - duration=sw.elapsed, + duration=duration(span), **self.common_event_attrs(), ) ) @@ -437,19 +358,14 @@ async def delete_lab(self) -> None: await self.events.nublado_delete_lab.publish( NubladoDeleteLab( success=True, - duration=sw.elapsed, + duration=duration(span), **self.common_event_attrs(), ) ) - async def _delete_lab(self, sw: Stopwatch) -> bool: + async def _delete_lab(self) -> bool: """Delete a lab. - Parameters - ---------- - sw - A Stopwatch to time the lab deletion - Returns ------- bool @@ -457,15 +373,7 @@ async def _delete_lab(self, sw: Stopwatch) -> bool: didn't wait to find out if the lab was successfully deleted. """ self.logger.info("Deleting lab") - try: - await self._client.stop_lab() - except ne.JupyterWebError as exc: - raise JupyterWebError.from_client_exception( - exc, - event=sw.event, - annotations=sw.annotations, - started_at=sw.start_time, - ) from exc + await self._client.stop_lab() if self.stopping: return False @@ -479,14 +387,7 @@ async def _delete_lab(self, sw: Stopwatch) -> bool: if elapsed > self.options.delete_timeout: if not await self._client.is_lab_stopped(log_running=True): msg = f"Lab not deleted after {elapsed_seconds}s" - jte = JupyterTimeoutError( - msg, - self.user.username, - started_at=start, - event=sw.event, - ) - jte.annotations["image"] = self.options.image.description - raise jte + raise JupyterDeleteTimeoutError(msg) msg = f"Waiting for lab deletion ({elapsed_seconds}s elapsed)" self.logger.info(msg) if not await self.pause(timedelta(seconds=2)): @@ -494,6 +395,7 @@ async def _delete_lab(self, sw: Stopwatch) -> bool: self.logger.info("Lab successfully deleted") self._image = None + set_tag("image", None) return True def dump(self) -> NubladoBusinessData: diff --git a/src/mobu/services/business/nubladopythonloop.py b/src/mobu/services/business/nubladopythonloop.py index 0bb43a65..44793bb4 100644 --- a/src/mobu/services/business/nubladopythonloop.py +++ b/src/mobu/services/business/nubladopythonloop.py @@ -15,6 +15,8 @@ from ...events import Events, NubladoPythonExecution from ...models.business.nubladopythonloop import NubladoPythonLoopOptions from ...models.user import AuthenticatedUser +from ...safir.sentry import duration +from ...sentry import start_span from .nublado import NubladoBusiness __all__ = ["NubladoPythonLoop"] @@ -59,14 +61,14 @@ def __init__( async def execute_code(self, session: JupyterLabSession) -> None: code = self.options.code for _count in range(self.options.max_executions): - with self.timings.start("execute_code", self.annotations()) as sw: + with start_span(op="execute_code") as span: try: reply = await session.run_python(code) except: await self._publish_failure(code=code) raise self.logger.info(f"{code} -> {reply}") - await self._publish_success(code=code, duration=sw.elapsed) + await self._publish_success(code=code, duration=duration(span)) if not await self.execution_idle(): break diff --git a/src/mobu/services/business/tap.py b/src/mobu/services/business/tap.py index 3d51953f..16053da1 100644 --- a/src/mobu/services/business/tap.py +++ b/src/mobu/services/business/tap.py @@ -10,13 +10,15 @@ import pyvo import requests from httpx import AsyncClient +from sentry_sdk import set_context from structlog.stdlib import BoundLogger from ...dependencies.config import config_dependency from ...events import Events, TapQuery -from ...exceptions import CodeExecutionError, TAPClientError from ...models.business.tap import TAPBusinessData, TAPBusinessOptions from ...models.user import AuthenticatedUser +from ...safir.sentry import duration +from ...sentry import start_span from .base import Business T = TypeVar("T", bound="TAPBusinessOptions") @@ -69,7 +71,7 @@ def __init__( self._pool = ThreadPoolExecutor(max_workers=1) async def startup(self) -> None: - with self.timings.start("make_client"): + with start_span(op="make_client"): self._client = self._make_client(self.user.token) @abstractmethod @@ -84,7 +86,11 @@ def get_next_query(self) -> str: async def execute(self) -> None: query = self.get_next_query() - with self.timings.start("execute_query", {"query": query}) as sw: + with start_span(op="execute_query") as span: + set_context( + "query_info", + {"query": query, "started_at": span.start_timestamp}, + ) self._running_query = query success = False @@ -94,27 +100,18 @@ async def execute(self) -> None: else: await self.run_async_query(query) success = True - except Exception as e: - raise CodeExecutionError( - user=self.user.username, - code=query, - code_type="TAP query", - event="execute_query", - started_at=sw.start_time, - error=f"{type(e).__name__}: {e!s}", - ) from e finally: await self.events.tap_query.publish( payload=TapQuery( success=success, - duration=sw.elapsed, + duration=duration(span), sync=self.options.sync, **self.common_event_attrs(), ) ) self._running_query = None - elapsed = sw.elapsed.total_seconds() + elapsed = duration(span).total_seconds() self.logger.info(f"Query finished after {elapsed} seconds") @@ -173,15 +170,12 @@ def _make_client(self, token: str) -> pyvo.dal.TAPService: if not config.environment_url: raise RuntimeError("environment_url not set") tap_url = str(config.environment_url).rstrip("/") + "/api/tap" - try: - s = requests.Session() - s.headers["Authorization"] = "Bearer " + token - auth = pyvo.auth.AuthSession() - auth.credentials.set("lsst-token", s) - auth.add_security_method_for_url(tap_url, "lsst-token") - auth.add_security_method_for_url(tap_url + "/sync", "lsst-token") - auth.add_security_method_for_url(tap_url + "/async", "lsst-token") - auth.add_security_method_for_url(tap_url + "/tables", "lsst-token") - return pyvo.dal.TAPService(tap_url, auth) - except Exception as e: - raise TAPClientError(e, user=self.user.username) from e + s = requests.Session() + s.headers["Authorization"] = "Bearer " + token + auth = pyvo.auth.AuthSession() + auth.credentials.set("lsst-token", s) + auth.add_security_method_for_url(tap_url, "lsst-token") + auth.add_security_method_for_url(tap_url + "/sync", "lsst-token") + auth.add_security_method_for_url(tap_url + "/async", "lsst-token") + auth.add_security_method_for_url(tap_url + "/tables", "lsst-token") + return pyvo.dal.TAPService(tap_url, auth) diff --git a/src/mobu/services/monkey.py b/src/mobu/services/monkey.py index 5bd70cd2..d2306523 100644 --- a/src/mobu/services/monkey.py +++ b/src/mobu/services/monkey.py @@ -6,18 +6,16 @@ import sys from tempfile import NamedTemporaryFile, _TemporaryFileWrapper +import sentry_sdk import structlog from aiojobs import Job, Scheduler from httpx import AsyncClient -from safir.datetime import current_datetime, format_datetime_for_logging from safir.logging import Profile -from safir.slack.blockkit import SlackException, SlackMessage, SlackTextField from safir.slack.webhook import SlackWebhookClient from structlog.stdlib import BoundLogger from ..dependencies.config import config_dependency from ..events import Events -from ..exceptions import MobuMixin from ..models.business.base import BusinessConfig from ..models.business.empty import EmptyLoopConfig from ..models.business.gitlfs import GitLFSConfig @@ -170,36 +168,7 @@ async def alert(self, exc: Exception) -> None: state = self._state.name self._logger.info(f"Not sending alert because state is {state}") return - if not self._slack: - self._logger.info("Alert hook isn't set, so not sending to Slack") - return - monkey = f"{self._flock}/{self._name}" if self._flock else self._name - if isinstance(exc, MobuMixin): - # Add the monkey info if it is not already set. - if not exc.monkey: - exc.monkey = monkey - if isinstance(exc, SlackException): - # Avoid post_exception here since it adds the application name, - # but mobu (unusually) uses a dedicated web hook and therefore - # doesn't need to label its alerts. - await self._slack.post(exc.to_slack()) - else: - now = current_datetime(microseconds=True) - date = format_datetime_for_logging(now) - name = type(exc).__name__ - error = f"{name}: {exc!s}" - message = SlackMessage( - message=f"Unexpected exception {error}", - fields=[ - SlackTextField(heading="Exception type", text=name), - SlackTextField(heading="Failed at", text=date), - SlackTextField(heading="Monkey", text=monkey), - SlackTextField(heading="User", text=self._user.username), - ], - ) - await self._slack.post(message) - - self._global_logger.info("Sent alert to Slack") + sentry_sdk.capture_exception(exc) def logfile(self) -> str: """Get the log file for a monkey's log.""" @@ -216,15 +185,18 @@ async def run_once(self) -> str | None: """ self._state = MonkeyState.RUNNING error = None - try: - await self.business.run_once() - self._state = MonkeyState.FINISHED - except Exception as e: - msg = "Exception thrown while doing monkey business" - self._logger.exception(msg) - error = str(e) - self._state = MonkeyState.ERROR - return error + with sentry_sdk.isolation_scope(): + sentry_sdk.set_user({"username": self._user.username}) + sentry_sdk.set_tag("business", self.business.__class__.__name__) + try: + await self.business.run_once() + self._state = MonkeyState.FINISHED + except Exception as e: + msg = "Exception thrown while doing monkey business" + self._logger.exception(msg) + error = str(e) + self._state = MonkeyState.ERROR + return error async def start(self, scheduler: Scheduler) -> None: """Start the monkey.""" @@ -240,35 +212,40 @@ async def _runner(self) -> None: run = True while run: - try: - self._state = MonkeyState.RUNNING - await self.business.run() - run = False - except Exception as e: - msg = "Exception thrown while doing monkey business" - if self._flock: - monkey = f"{self._flock}/{self._name}" - else: - monkey = self._name - if isinstance(e, MobuMixin): - e.monkey = monkey - - await self.alert(e) - self._logger.exception(msg) - - run = self._restart and self._state == MonkeyState.RUNNING - if run: - self._state = MonkeyState.ERROR - await self.business.error_idle() - if self._state == MonkeyState.STOPPING: + with sentry_sdk.isolation_scope() as scope: + scope.set_tag("flock", self._flock) + scope.set_user({"username": self._user.username}) + scope.set_tag("business", self.business.__class__.__name__) + + with sentry_sdk.start_transaction( + name=self.business.__class__.__name__, + op=self.business.__class__.__name__, + ): + try: + self._state = MonkeyState.RUNNING + await self.business.run() run = False - else: - self._state = MonkeyState.STOPPING - msg = "Shutting down monkey due to error" - self._global_logger.warning(msg) - - await self.business.close() - self.state = MonkeyState.FINISHED + except Exception as e: + msg = "Exception thrown while doing monkey business" + await self.alert(e) + self._logger.exception(msg) + + run = ( + self._restart + and self._state == MonkeyState.RUNNING + ) + if run: + self._state = MonkeyState.ERROR + await self.business.error_idle() + if self._state == MonkeyState.STOPPING: + run = False + else: + self._state = MonkeyState.STOPPING + msg = "Shutting down monkey due to error" + self._global_logger.warning(msg) + + await self.business.close() + self.state = MonkeyState.FINISHED async def stop(self) -> None: """Stop the monkey.""" diff --git a/src/mobu/services/timings.py b/src/mobu/services/timings.py deleted file mode 100644 index e296168b..00000000 --- a/src/mobu/services/timings.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Holds timing information for mobu events.""" - -from __future__ import annotations - -from datetime import datetime, timedelta -from types import TracebackType -from typing import Literal, Self - -from safir.datetime import current_datetime - -from ..exceptions import MobuSlackException -from ..models.timings import StopwatchData - - -class Timings: - """Holds a collection of timings. - - The underlying data structure is a list of `Stopwatch` objects with some - machinery to start and stop timers. - """ - - def __init__(self) -> None: - self._last: Stopwatch | None = None - self._stopwatches: list[Stopwatch] = [] - - def start( - self, event: str, annotations: dict[str, str] | None = None - ) -> Stopwatch: - """Start a stopwatch. - - Examples - -------- - This should normally be used as a context manager: - - .. code-block:: python - - with timings.start("event", annotation): - ... - """ - if not annotations: - annotations = {} - stopwatch = Stopwatch(event, annotations, self._last) - self._stopwatches.append(stopwatch) - self._last = stopwatch - return stopwatch - - def dump(self) -> list[StopwatchData]: - """Convert the stored timings to a dictionary.""" - return [s.dump() for s in self._stopwatches] - - -class Stopwatch: - """Container for time data. - - A metric container for time data and its serialization. Normally, this - should be used as a context manager and it will automatically close the - timer when the context manager is exited. It can instead be stored in a - variable and stopped explicitly with ``stop`` in cases where a context - manager isn't appropriate. - - Parameters - ---------- - event - The name of the event. - annotation - Arbitrary annotations. - previous - The previous stopwatch, used to calculate the idle time between - timed events. - """ - - def __init__( - self, - event: str, - annotations: dict[str, str], - previous: Stopwatch | None = None, - ) -> None: - self.event = event - self.annotations = annotations - self.start_time = current_datetime(microseconds=True) - self.stop_time: datetime | None = None - self.failed = False - self._previous = previous - - def __enter__(self) -> Self: - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> Literal[False]: - self.stop_time = current_datetime(microseconds=True) - if exc_val: - self.failed = True - if exc_val and isinstance(exc_val, MobuSlackException): - exc_val.started_at = self.start_time - exc_val.event = self.event - exc_val.annotations = self.annotations - return False - - @property - def elapsed(self) -> timedelta: - """Return the total time (to the present if not stopped).""" - if self.stop_time: - return self.stop_time - self.start_time - else: - return current_datetime(microseconds=True) - self.start_time - - def dump(self) -> StopwatchData: - """Convert to a Pydantic model.""" - elapsed = self.stop_time - self.start_time if self.stop_time else None - return StopwatchData( - event=self.event, - annotations=self.annotations, - start=self.start_time, - stop=self.stop_time, - elapsed=elapsed, - failed=self.failed, - ) - - def stop(self) -> None: - """Explicitly stop the stopwatch, outside of a context manager.""" - self.stop_time = current_datetime(microseconds=True)