diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 201868a..a998802 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,7 +6,10 @@ version: 2 updates: - package-ecosystem: "pip" # See documentation for possible values - directory: "/" # Location of package manifests + # Location of package manifests: the project's own at the root, and the + # hash-locked tool sets the workflows install (ci.txt, publish.txt). + # Dependabot does not look that deep from the root, so the directory is named. + directories: ["/", "/.github/requirements"] # Updates belong on dev: main is the released branch and merging into it # publishes, so a PR opened against main just sits there. target-branch: "dev" diff --git a/.github/requirements/publish.in b/.github/requirements/publish.in new file mode 100644 index 0000000..ae75a0b --- /dev/null +++ b/.github/requirements/publish.in @@ -0,0 +1,9 @@ +# What the publish job installs; publish.txt is generated from this file (the command is in publish.yml). +# The job holds the PyPI token: build makes the distributions and twine uploads them. Its version bump +# uses only the standard library. +build +twine +# The build backend. The job runs `python -m build --no-isolation`, so the backend is this locked one +# and not whatever is newest on PyPI when the job runs. It must satisfy `build-system.requires` in +# pyproject.toml. +setuptools diff --git a/.github/requirements/publish.txt b/.github/requirements/publish.txt new file mode 100644 index 0000000..f28394a --- /dev/null +++ b/.github/requirements/publish.txt @@ -0,0 +1,472 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile .github/requirements/publish.in --generate-hashes --python-version 3.12 --python-platform x86_64-manylinux_2_28 --only-binary :all: --exclude-newer 2026-09-24 -o .github/requirements/publish.txt +build==1.6.1 \ + --hash=sha256:51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53 \ + --hash=sha256:ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7 + # via -r .github/requirements/publish.in +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via requests +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via cryptography +charset-normalizer==3.5.1 \ + --hash=sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45 \ + --hash=sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d \ + --hash=sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5 \ + --hash=sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b \ + --hash=sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f \ + --hash=sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f \ + --hash=sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5 \ + --hash=sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22 \ + --hash=sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5 \ + --hash=sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac \ + --hash=sha256:13e3afe97712e8887cd516e960c63f0b93122971e5b5e4b2622fe7701771e838 \ + --hash=sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90 \ + --hash=sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626 \ + --hash=sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4 \ + --hash=sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369 \ + --hash=sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b \ + --hash=sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e \ + --hash=sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee \ + --hash=sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1 \ + --hash=sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102 \ + --hash=sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8 \ + --hash=sha256:29880d17a8eb0b5cfdfd8944b468322928059aa35f1f5fa8ff22b149ec0b42f8 \ + --hash=sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9 \ + --hash=sha256:2e9cf9253119d8e5d111f05d71626786fd3d6193817316eab1ca088cdb8593cf \ + --hash=sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0 \ + --hash=sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031 \ + --hash=sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e \ + --hash=sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235 \ + --hash=sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072 \ + --hash=sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb \ + --hash=sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c \ + --hash=sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950 \ + --hash=sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2 \ + --hash=sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb \ + --hash=sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e \ + --hash=sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6 \ + --hash=sha256:3e5e1224c0a6a90e05843e07adfec669edebec17801c67072f51e59561d63c0b \ + --hash=sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2 \ + --hash=sha256:433c5a81eade63b47e522303bad236f59dba55ea6951746f5558355eeed8c75d \ + --hash=sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa \ + --hash=sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2 \ + --hash=sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818 \ + --hash=sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032 \ + --hash=sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71 \ + --hash=sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96 \ + --hash=sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687 \ + --hash=sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8 \ + --hash=sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3 \ + --hash=sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61 \ + --hash=sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9 \ + --hash=sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1 \ + --hash=sha256:55261ac0d2941c42f196dd576f543d87a8ee03cd6f5e30dfb4d807b2e3b9121a \ + --hash=sha256:56490c595a28b1bb27dfc583e816152a9767721ef58b2c03b13f954d2f707420 \ + --hash=sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4 \ + --hash=sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65 \ + --hash=sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663 \ + --hash=sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f \ + --hash=sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591 \ + --hash=sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a \ + --hash=sha256:5ca0555312ae2fe82715cada7fac375530c2f3349e1eaa1bcb33d0283ac79a18 \ + --hash=sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e \ + --hash=sha256:5e2d0e146dcb57034f8b97dc58d2d512cb90aba253960ce449f695fec6a82c6f \ + --hash=sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7 \ + --hash=sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3 \ + --hash=sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c \ + --hash=sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3 \ + --hash=sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7 \ + --hash=sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96 \ + --hash=sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486 \ + --hash=sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3 \ + --hash=sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6 \ + --hash=sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b \ + --hash=sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731 \ + --hash=sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959 \ + --hash=sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9 \ + --hash=sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf \ + --hash=sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8 \ + --hash=sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e \ + --hash=sha256:789b8982559ae28dad2356519f841655756cdcd96616410590ae0b17454ee64f \ + --hash=sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885 \ + --hash=sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0 \ + --hash=sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506 \ + --hash=sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2 \ + --hash=sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0 \ + --hash=sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e \ + --hash=sha256:85de3134b5379856e323ba37c19c9256d39425f7b76a63af52b09fb4664c2e8f \ + --hash=sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e \ + --hash=sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491 \ + --hash=sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a \ + --hash=sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20 \ + --hash=sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449 \ + --hash=sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af \ + --hash=sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c \ + --hash=sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712 \ + --hash=sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7 \ + --hash=sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a \ + --hash=sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20 \ + --hash=sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f \ + --hash=sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3 \ + --hash=sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9 \ + --hash=sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e \ + --hash=sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5 \ + --hash=sha256:994e883d17c559cdfd38c84003c8b27d25424a1077272a17e7cd27bfe0bf57b2 \ + --hash=sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36 \ + --hash=sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263 \ + --hash=sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4 \ + --hash=sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11 \ + --hash=sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a \ + --hash=sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3 \ + --hash=sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375 \ + --hash=sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa \ + --hash=sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d \ + --hash=sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5 \ + --hash=sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99 \ + --hash=sha256:a951ad59cad9145664a730d3036b40b844e74d2d3683da40111463cd3a83845d \ + --hash=sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c \ + --hash=sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488 \ + --hash=sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6 \ + --hash=sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc \ + --hash=sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b \ + --hash=sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f \ + --hash=sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00 \ + --hash=sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10 \ + --hash=sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598 \ + --hash=sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6 \ + --hash=sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962 \ + --hash=sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c \ + --hash=sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08 \ + --hash=sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab \ + --hash=sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573 \ + --hash=sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90 \ + --hash=sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5 \ + --hash=sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18 \ + --hash=sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d \ + --hash=sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af \ + --hash=sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea \ + --hash=sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c \ + --hash=sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b \ + --hash=sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6 \ + --hash=sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8 \ + --hash=sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774 \ + --hash=sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004 \ + --hash=sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a \ + --hash=sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a \ + --hash=sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2 \ + --hash=sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2 \ + --hash=sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa \ + --hash=sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe \ + --hash=sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3 \ + --hash=sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc \ + --hash=sha256:e06efa066f7dbadbc84ebc126a97c452a6451dfcf589d89d788484949e1cf795 \ + --hash=sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d \ + --hash=sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc \ + --hash=sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893 \ + --hash=sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef \ + --hash=sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d \ + --hash=sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda \ + --hash=sha256:eb12fb2ba69ffa05f8695f61c69e591dc4b4a12ac3757ac8af8adb259bf56d17 \ + --hash=sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30 \ + --hash=sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7 \ + --hash=sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5 \ + --hash=sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182 \ + --hash=sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f \ + --hash=sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9 \ + --hash=sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada \ + --hash=sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876 \ + --hash=sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a \ + --hash=sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348 \ + --hash=sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3 \ + --hash=sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f \ + --hash=sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0 \ + --hash=sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f + # via requests +cryptography==50.0.1 \ + --hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \ + --hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \ + --hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \ + --hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \ + --hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \ + --hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \ + --hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \ + --hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \ + --hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \ + --hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \ + --hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \ + --hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \ + --hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \ + --hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \ + --hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \ + --hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \ + --hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \ + --hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \ + --hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \ + --hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \ + --hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \ + --hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \ + --hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \ + --hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \ + --hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \ + --hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \ + --hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \ + --hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \ + --hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \ + --hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \ + --hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \ + --hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \ + --hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \ + --hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \ + --hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \ + --hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \ + --hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \ + --hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \ + --hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \ + --hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \ + --hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \ + --hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \ + --hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \ + --hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \ + --hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \ + --hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef + # via secretstorage +docutils==0.23 \ + --hash=sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea \ + --hash=sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e + # via readme-renderer +id==1.6.1 \ + --hash=sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069 \ + --hash=sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca + # via twine +idna==3.20 \ + --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ + --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c + # via requests +jaraco-classes==3.4.0 \ + --hash=sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd \ + --hash=sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790 + # via keyring +jaraco-context==6.1.2 \ + --hash=sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535 \ + --hash=sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3 + # via keyring +jaraco-functools==4.6.0 \ + --hash=sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280 \ + --hash=sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30 + # via keyring +jeepney==0.9.0 \ + --hash=sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683 \ + --hash=sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732 + # via + # keyring + # secretstorage +keyring==25.7.0 \ + --hash=sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f \ + --hash=sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b + # via twine +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a + # via rich +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba + # via markdown-it-py +more-itertools==11.1.0 \ + --hash=sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d \ + --hash=sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192 + # via + # jaraco-classes + # jaraco-functools +nh3==0.3.7 \ + --hash=sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877 \ + --hash=sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62 \ + --hash=sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102 \ + --hash=sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086 \ + --hash=sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32 \ + --hash=sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8 \ + --hash=sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d \ + --hash=sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc \ + --hash=sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af \ + --hash=sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5 \ + --hash=sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59 \ + --hash=sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42 \ + --hash=sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848 \ + --hash=sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a \ + --hash=sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946 \ + --hash=sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba \ + --hash=sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac \ + --hash=sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174 \ + --hash=sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493 \ + --hash=sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa \ + --hash=sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd \ + --hash=sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563 \ + --hash=sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506 \ + --hash=sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b \ + --hash=sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a \ + --hash=sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479 \ + --hash=sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac + # via readme-renderer +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via + # build + # twine +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \ + --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c + # via + # readme-renderer + # rich +pyproject-hooks==1.3.3 \ + --hash=sha256:5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b \ + --hash=sha256:defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43 + # via build +readme-renderer==46.0 \ + --hash=sha256:af3e964914f6310a33ff67b72a4bdd940bed8d7c3bdecd2d14f40edf284bfe90 \ + --hash=sha256:d0dae1f74bb273b534770cb4cccb6bb78735540afdb03c2146f4e19dcd412560 + # via twine +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed + # via + # requests-toolbelt + # twine +requests-toolbelt==1.0.0 \ + --hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \ + --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06 + # via twine +rfc3986==2.0.0 \ + --hash=sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd \ + --hash=sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c + # via twine +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ + --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 + # via twine +secretstorage==3.5.0 \ + --hash=sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137 \ + --hash=sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be + # via keyring +setuptools==84.0.0 \ + --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \ + --hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73 + # via -r .github/requirements/publish.in +twine==7.0.0 \ + --hash=sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177 \ + --hash=sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7 + # via -r .github/requirements/publish.in +urllib3==2.8.0 \ + --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \ + --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63 + # via + # id + # requests + # twine diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 214e395..0b2de77 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,10 +27,16 @@ jobs: with: python-version: "3.12" - - name: Install build tools + # This job holds the PyPI token, so its tools and its build backend are wheels only at locked hashes + # and nothing else is installed. Regenerate publish.txt with + # uv pip compile .github/requirements/publish.in --generate-hashes --python-version 3.12 \ + # --python-platform x86_64-manylinux_2_28 --only-binary :all: --exclude-newer <7 days ago> \ + # -o .github/requirements/publish.txt + # --python-version is the one set up above. --exclude-newer keeps the 7-day wait Dependabot uses: + # nothing uploaded in the last week. + - name: Install build tools (hash-locked, from .github/requirements/publish.in) run: | - python -m pip install --upgrade pip - pip install build twine + python -m pip install --require-hashes --only-binary :all: -r .github/requirements/publish.txt - name: Bump patch version in pyproject.toml id: bump @@ -60,8 +66,9 @@ jobs: print(f"Bumped version -> {new_version}") EOF + # --no-isolation: the backend is the setuptools locked in publish.txt, not a fresh download. - name: Build distribution - run: python -m build + run: python -m build --no-isolation - name: Publish to PyPI env: diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c57498e --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +# The source distribution carries no tests. setuptools adds test*/test*.py to an sdist by default; +# this line takes them out again (test/test_sdist_manifest.py). +prune test diff --git a/README.md b/README.md index 465640a..dd06b45 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,31 @@ executor.execute_action({"my_tool": [["MY_add", [1, 2]], ["MY_add", {"a": 3, "b" | `file_listing` | `get_dir_files_as_list`: action files under a directory | — | | `socket_server`, `socket_auth` | `start_action_socket_server` and the request handlers (plain, secret header, JSON-envelope token) | executor, payload check, errors answered, framing, TLS, reply templates, secret | | `builtins_policy` | `SAFE_BUILTINS`: the builtins an action list may call | — | +| `request_record`, `request_context` | Versioned request-result validation, JSON serialization/schema and isolated `RunContext` storage | source, phase, engine, worker and run identities | `__all__` in `je_action_core/__init__.py` is the supported import surface. +## Request results + +HTTP `request_method`: uppercase ASCII token; schema and Python validation enforce the same rule. + +Run identities are validated when the context is constructed. Retries compare JSON content with object keys sorted, distinguishing booleans from numbers. Assertion messages must be strings when present, and failed assertions require a message. Integer values beyond the portable JSON conversion limit and unrepresentable measurement values raise field-located contract errors. + +Request results are separate from executor action records. `RequestRecord` v1 gives functional, +load and synthetic runs the same method/URL, numeric status, nullable measurements, structured error, +assertions and run/worker/step identities. `validate_request_record` returns a detached JSON-safe result; +`serialize_request_record` exports it and `request_record_schema` describes the contract. +Bytes become base64 objects, datetimes become epoch seconds and timedeltas become milliseconds. +Unknown versions, invalid fields, non-finite measurements and opaque objects raise `RequestRecordError`. + +`RunContext(source="loaddensity", phase="load", engine="asyncio")` owns one run's records. +Use `capture(fields)` for a new result, `append(record)` for a validated retry, `snapshot()` for a detached +copy and `to_json()` for an array export. Identical record IDs deduplicate; conflicting retries and foreign +run identities raise an error. `use_run_context(context)` activates it for a thread/task scope and restores +the previous context on exit; `get_run_context()` returns the active context or `None`. +Storage retains the complete run; long-lived monitors should create a new context per iteration and persist results. +The contract records the runner's judgement and does not decide whether a status code is successful. + ## Package gate `PackageManager.add_package_to_executor` imports a package and registers its members as commands. An action list @@ -121,6 +143,10 @@ the projects this package is for. `architecture.md` §6 lists which of them have settings each one uses. All four run their TCP servers on `socket_server`: LoadDensity with the JSON-envelope token, framing and TLS, FileAutomation with the `AUTH` header and its ACL. +[WebRunner](https://github.com/Integration-Automation/WebRunner) (`WR_`) runs its action executor on it as well. Its +own action format (`[name, [args], {kwargs}]`) is a parser, and its retries and action span wrap `attempt`. +Failure screenshots go into `failure_record`, and repeated actions are numbered (`DuplicateKeys.NUMBER`). + n and an ACL. ## Development diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 1e64506..d5ace2c 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -73,9 +73,31 @@ executor.execute_action({"my_tool": [["MY_add", [1, 2]], ["MY_add", {"a": 3, "b" | `file_listing` | `get_dir_files_as_list`:找出目录下的 action 文件 | — | | `socket_server`、`socket_auth` | `start_action_socket_server` 与请求处理器(无认证、密钥标头、JSON 信封令牌) | 执行器、内容检查、要回复的错误、分帧、TLS、回复模板、密钥 | | `builtins_policy` | `SAFE_BUILTINS`:action 列表可以调用的内置函数 | — | +| `request_record`、`request_context` | 版本化 request 结果验证、JSON 序列化/schema 与隔离的 `RunContext` 存储 | 来源、阶段、引擎、worker 与 run 标识 | `je_action_core/__init__.py` 里的 `__all__` 是正式支持的导入接口。 +## Request 结果 + +HTTP `request_method`: uppercase ASCII token; schema and Python validation enforce the same rule. + +Run identity 在 context 创建时即验证。重送以排序对象键后的 JSON 内容比较,区分布尔值与数字。 +断言信息存在时必须是字符串,失败断言必须带信息。超过可移植 JSON 转换限制的整数及无法表示的测量值 +会产生包含字段位置的契约错误。 + +Request 结果与 executor 的 action 记录分开。`RequestRecord` v1 让功能、负载与合成监控使用 +相同的方法/URL、数字状态码、可为 null 的测量值、结构化错误、assertion 与 run/worker/step 标识。 +`validate_request_record` 返回隔离且可序列化的结果;`serialize_request_record` 输出 JSON, +`request_record_schema` 描述契约。bytes 转为 base64 对象、datetime 转为 epoch 秒、timedelta 转为毫秒。 +未知版本、非法字段、非有限测量值与不支持的对象会引发 `RequestRecordError`。 + +`RunContext(source="loaddensity", phase="load", engine="asyncio")` 保存一次运行的记录。 +`capture(fields)` 添加结果,`append(record)` 接收已验证的重发,`snapshot()` 获取隔离副本, +`to_json()` 输出数组。同 ID 的相同结果不重复计数;冲突结果与其他 run 的标识会引发错误。 +`use_run_context(context)` 在线程/task 范围启用它,退出时恢复原 context; +`get_run_context()` 返回当前 context 或 `None`。存储保留整次运行,长期监控应每轮创建新 context 并持久化结果。 +契约保存 runner 的判断,不自行决定状态码是否成功。 + ## 包闸门 `PackageManager.add_package_to_executor` 会导入一个包,并把它的成员注册成命令。所以只要 action 列表写得出 @@ -113,6 +135,10 @@ package_manager.set_allow_arbitrary_packages(False) # 其他包在导入前就 FileAutomation(`FA_`)而做的。`architecture.md` §6 列出其中哪些已经改用它,以及各自用了哪些部件与配置。 四个项目的 TCP 服务器都运行在 `socket_server` 上:LoadDensity 用 JSON 信封令牌、分帧与 TLS,FileAutomation 用 `AUTH` 标头与它的访问控制列表。 +[WebRunner](https://github.com/Integration-Automation/WebRunner)(`WR_`)的 action 执行器也运行在它上面。它自己的 action 格式 +(`[name, [args], {kwargs}]`)是一个解析器,重试与 action span 包在 `attempt` 外面。失败截图放进 `failure_record`, +重复的 action 会编号(`DuplicateKeys.NUMBER`)。 + ## 开发 ```bash diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 5bd446b..d5f6dd5 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -73,9 +73,31 @@ executor.execute_action({"my_tool": [["MY_add", [1, 2]], ["MY_add", {"a": 3, "b" | `file_listing` | `get_dir_files_as_list`:找出目錄底下的 action 檔 | — | | `socket_server`、`socket_auth` | `start_action_socket_server` 與請求處理器(無驗證、祕密標頭、JSON 信封權杖) | 執行器、內容檢查、要回覆的錯誤、分框、TLS、回覆範本、祕密 | | `builtins_policy` | `SAFE_BUILTINS`:action 清單可以呼叫的內建函式 | — | +| `request_record`、`request_context` | 版本化 request 結果驗證、JSON 序列化/schema 與隔離的 `RunContext` 儲存 | 來源、階段、引擎、worker 與 run 識別 | `je_action_core/__init__.py` 裡的 `__all__` 是正式支援的匯入介面。 +## Request 結果 + +HTTP `request_method`: uppercase ASCII token; schema and Python validation enforce the same rule. + +Run identity 在 context 建立時即驗證。重送以排序物件鍵後的 JSON 內容比較,區分布林值與數字。 +斷言訊息存在時必須是字串,失敗斷言必須帶訊息。超過可攜式 JSON 轉換限制的整數及無法表示的測量值 +會產生包含欄位位置的契約錯誤。 + +Request 結果與 executor 的 action 紀錄分開。`RequestRecord` v1 讓功能、負載與合成監控使用 +相同的方法/URL、數字狀態碼、可為 null 的量測值、結構化錯誤、assertion 與 run/worker/step 識別。 +`validate_request_record` 回傳隔離且可序列化的結果;`serialize_request_record` 輸出 JSON, +`request_record_schema` 描述契約。bytes 轉成 base64 物件、datetime 轉成 epoch 秒、timedelta 轉成毫秒。 +未知版本、非法欄位、非有限量測值與不支援的物件會引發 `RequestRecordError`。 + +`RunContext(source="loaddensity", phase="load", engine="asyncio")` 保存一次執行的紀錄。 +`capture(fields)` 新增結果,`append(record)` 接收已驗證的重送,`snapshot()` 取得隔離副本, +`to_json()` 輸出陣列。同 ID 的相同結果不重複計數;衝突結果與其他 run 的識別會引發錯誤。 +`use_run_context(context)` 在執行緒/task 範圍啟用它,離開時還原原 context; +`get_run_context()` 回傳目前 context 或 `None`。儲存保留整次執行,長期監控應每輪建立新 context 並持久化結果。 +契約保存 runner 的判定,不自行決定狀態碼是否成功。 + ## 套件閘門 `PackageManager.add_package_to_executor` 會匯入一個套件,並把它的成員註冊成命令。所以只要 action 清單寫得出 @@ -113,6 +135,10 @@ package_manager.set_allow_arbitrary_packages(False) # 其他套件在匯入前 FileAutomation(`FA_`)而做的。`architecture.md` §6 列出其中哪些已經改用它,以及各自用了哪些元件與設定。 四個專案的 TCP 伺服器都跑在 `socket_server` 上:LoadDensity 用 JSON 信封權杖、分框與 TLS,FileAutomation 用 `AUTH` 標頭與它的存取控制清單。 +[WebRunner](https://github.com/Integration-Automation/WebRunner)(`WR_`)的 action 執行器也跑在它上面。它自己的 action 格式 +(`[name, [args], {kwargs}]`)是一個解析器,重試與 action span 包在 `attempt` 外面。失敗截圖放進 `failure_record`, +重複的 action 會編號(`DuplicateKeys.NUMBER`)。 + ## 開發 ```bash diff --git a/architecture.md b/architecture.md index 413c6c6..2dfd7c5 100644 --- a/architecture.md +++ b/architecture.md @@ -26,7 +26,8 @@ settings: command prefix, document key, exceptions, messages and reporting. The | `je_action_core/socket_server.py` | `ActionTCPServer` (`close_flag`, `close_event`, `request_stop`), the template `ActionRequestHandler`, `SocketServerSettings` (framing, TLS, `ReplyMessages`, secret), `server_tls_context`, `start_action_socket_server` | | `je_action_core/socket_auth.py` | `SecretHeaderRequestHandler` (`` first line) and `EnvelopeTokenRequestHandler` (JSON envelope token) | | `je_action_core/builtins_policy.py` | `SAFE_BUILTINS`, `safe_builtin_commands()` | -| `test/` | One test module per piece, plus the workflow pinning checks | +| `test/` | One test module per piece, plus the workflow checks (pinned actions, Dependabot, the publish job's hash-locked tools and build backend) and the sdist manifest check (`MANIFEST.in` keeps `test/` out of the sdist) | +| `.github/requirements/` | The hash-locked tool sets the workflows install, each generated from the `.in` beside it: `ci.txt` (the test job) and `publish.txt` (`build`, `twine` and the build backend `setuptools`, the only install of the job that holds the PyPI token; it runs `python -m build --no-isolation`, so the build uses that locked backend instead of downloading the newest) | Nothing in the package imports a project; the projects import it. @@ -85,15 +86,25 @@ Nothing in the package imports a project; the projects import it. ## 6. Cross-project boundaries +RunContext rejects invalid identities during construction and compares sorted JSON for retries; booleans and numbers are distinct. Assertion schema constraints and uppercase ASCII HTTP method tokens match the Python validator. Measurements must be representable as finite numbers; integers must fit the portable JSON conversion limit. + +`request_record.py` owns the separate RequestRecord v1 contract (TypedDict, validator, JSON serializer +and JSON Schema); `request_context.py` owns explicit `RunContext` storage and ContextVar scopes. +They use only the standard library and do not alter executor action records. Framework adapters remain +in the consumers. Contexts retain complete runs; consumers must rotate/persist long-lived monitoring runs. +New consumers are developed against isolated checkouts until the core release exposes these APIs; +their existing package imports must remain usable with the previously published dependency floor. + **Used by.** Each project that moves adds a row here; the same round updates that project's own -`architecture.md` §6. The projects install it from PyPI (`je_action_core>=0.0.1`); a change they need is released -first and their minimum version raised in the same round. +`architecture.md` §6. The projects install it from PyPI (`je_action_core>=0.0.1` to `>=0.0.3`, each the release that +has what it uses); a change they need is released first and their minimum version raised in the same round. | Project | Pieces | Settings | |---|---|---| | APITestka (`AT_`, `api_testka`) | executor, registry, package manager, callback executor, JSON files, file listing, socket server (9939) | `LegacyActionParser`, plain record keys, `LoggingReporter`, `strip_runner_metadata` as `prepare`; functions-only registry; gate on, prefixed members, every load error logged; callback returns `None` on failure; socket reads the prefix and answers every error | | MailThunder (`MT_`, `mail_thunder`, legacy `auto_control`) | executor, registry, package manager, JSON files, file listing, socket server (9942) | `LegacyActionParser`, `EmptyListPolicy.RETURN_EMPTY`, plain record keys, `LoggingReporter` with its empty message, `SAFE_BUILTINS`; functions-only registry; gate on, identifier-path names, import and attribute errors logged; `from_document` in its payload check; socket rejects oversized payloads and answers `ValueError`, `OSError`, `TypeError` | | LoadDensity (`LD_`, `load_density`) | executor, registry, package manager, callback executor, JSON files, socket server (9940) | `LegacyActionParser`, plain record keys, `PrintReporter`, `SAFE_BUILTINS`; functions-only registry; gate on, bare member names, functions only, ASCII names, errors printed; callback raises after printing; JSON wraps every error; `EnvelopeTokenRequestHandler`, raw or `LENGTH_PREFIX` framing, optional TLS, `Error: ` replies, size-only log line, run under gevent. Its file listing stays in LoadDensity | +| WebRunner (`WR_`, `webdriver_wrapper`) | executor, registry | its own `[name, [args], {kwargs}]` parser and list rules (`action_list_of`: an empty list runs nothing, its error logged); functions-only registry; `PrintReporter` with `on_start` / `on_failure` logged; `failure_record` = the error text plus failure screenshot and trace; `DuplicateKeys.NUMBER`; refused commands and the script gate in `_execute_event`, retries and the action span in `attempt`; `collect_action_results` for MCP and the async executor. Its package manager, callback executor, JSON files and socket server stay in WebRunner (`progress.md` #4) | | FileAutomation (`FA_`, `auto_control`) | registry, executor pipeline, package loader, callback executor, JSON files, TCP server (9943) | any-callable registry (`" is not callable"`); `StrictActionParser`, `indexed_record_key`, its three list messages; tracing through `invoke`; `check_and_add` for the member count, gate off, `ImportError` logged; strict callback, errors raised; JSON wraps `JSONDecodeError` / `OSError` on read and `OSError` / `TypeError` on write; `SecretHeaderRequestHandler` (`AUTH `), the ACL as `validate`, ` -> ` records and prefixed error replies. Dry run, validate, substitute, parallel runs, metrics and its HTTP server stay in FileAutomation | **What the projects rely on here.** They rely on these, and none may change without changing the projects in @@ -101,8 +112,10 @@ the same round: - the names in `__all__`, and the settings fields they pass; - `event_dict` staying the live mapping of a registry, executor and callback executor; -- the record formats `"execute: "` and `"execute[]: "`, and `repr(error)` as a failed - action's record; +- the record formats `"execute: "` and `"execute[]: "`, `repr(error)` as a failed + action's default record, and `" #2"`, `" #3"` … for numbered repeats; +- `attempt` and `_execute_event` as the override points WebRunner uses, and the order `on_start`, then + `rules.extract`, then per action `attempt` with `on_failure` before `failure_record`; - the error texts of `LegacyActionParser` (`f"{message} {action}"`) and of `StrictActionParser` (FileAutomation's messages); - the socket protocol: `Return_Data_Over_JE`, `quit_server`, one line per value. diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 96623a7..358241b 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -83,3 +83,92 @@ - **Docs**: README (three languages) `executor` row; `architecture.md` §2, §4 (execute flow) and §5 (wrapping every action). - **Result / numbers**: 114 tests passed (`test/test_executor_hooks.py` is new). `ruff check` and `mypy` are clean. - **Files**: `je_action_core/executor.py`, `je_action_core/reporting.py`, `je_action_core/callback.py`, `je_action_core/__init__.py`, `test/test_executor_hooks.py`, `README.md` + `README/README_zh-{TW,CN}.md`, `architecture.md`. + +## U-20261001-09 · 2026-10-01 · WebRunner's executor runs on the core · #done #executor #webrunner + +- **What**: `progress.md` #3. With 0.0.3's hooks (U-20261001-08), WebRunner's `Executor` is an `ActionExecutor` (WebRunner U-20261001-47, `af17e71`; its characterization tests are `ebe53a1`). What stays WebRunner's own is configured, not copied: + - its action format, as a parser; + - its list rules, as an `extract` object; + - its failure text with screenshots and traces, as `failure_record`; + - numbered keys; + - the gates in `_execute_event`, and retries and the span in `attempt`. +- **Jeffrey_RPA**: it imports WebRunner's working tree through `sys.path`, so both of its interpreters got `je_action_core` 0.0.3 installed before the change was pushed. Its `test/test_je_facade.py` passes against the new tree on both. +- **Docs**: §6 gets a WebRunner row, the minimum versions per project, and the override points and call order WebRunner relies on. The README's "Who uses it" (three languages) adds WebRunner. +- **Next**: WebRunner's package manager, callback executor, JSON files and socket server are still its own copies. They are the new `progress.md` #4. +- **Files**: `architecture.md`, `README.md` + `README/README_zh-{TW,CN}.md`, `progress.md`. + +## U-20261001-10 · 2026-10-01 · The source distribution carries no tests · #done #packaging #X-13 + +- **What**: workspace X-13, decided by the owner: the source distribution carries no tests. The wheel installs only `je_action_core` (`include = ["je_action_core*"]` in `pyproject.toml`), but setuptools adds `test*/test*.py` to an sdist by default, so the published 0.0.3 sdist shipped the 12 files of the suite. A new `MANIFEST.in` holds one command, `prune test`. `pyproject.toml` and everything under `je_action_core/` are unchanged. +- **Tests**: `test/test_sdist_manifest.py` (2 cases) reads `MANIFEST.in`: it prunes the directory the test file lives in, and no `include`, `recursive-include`, `global-include` or `graft` follows the prune. setuptools applies the commands in order, so a later one could bring files back. Nothing is built and the package is not imported. +- **Result / numbers**: built before and after in a throwaway worktree with `python -m build`, which builds the wheel from the sdist. There is one set of metadata, `pyproject.toml` (0.0.0 on `dev`; the publish job sets the version on `main`). + - sdist before: 35 files, 39,307 bytes: 14 under `je_action_core/`, 12 under `test/`, 4 under `*.egg-info/`, 5 at the root (`LICENSE`, `PKG-INFO`, `README.md`, `pyproject.toml`, `setup.cfg`). The published 0.0.3 sdist has the same member names. + - sdist after: 24 files, 26,095 bytes: 14 under `je_action_core/`, none under `test/`, 4 under `*.egg-info/`, 6 at the root. The 12 `test/` members are gone and `MANIFEST.in` is added; `SOURCES.txt` lists that difference and every other member is byte-identical. + - Wheel: all 19 members (14 under `je_action_core/`, 5 under `dist-info/`) are byte-identical before and after, `RECORD` included. + - `twine check` passes for the four files. Full suite: 116 passed. +- **Docs**: `architecture.md` §2's `test/` row names the new check. `CLAUDE.md` and the three READMEs do not say what the sdist contains, so none changed. +- **Files**: `MANIFEST.in` (new), `test/test_sdist_manifest.py` (new), `architecture.md`. +- **Open items**: none. The new sdist goes out with the next release from `main`. + +## U-20261001-11 · 2026-10-01 · The publish job installs hash-locked build tooling · #done #ci #security #X-13 + +- **What**: workspace X-13, decided by the owner: the job that holds the PyPI token installs its tools from a hash-locked file. `publish` (`publish.yml`, on a push to `main`) ran `python -m pip install --upgrade pip` and `pip install build twine`, so it took whatever was newest on PyPI that day. + - New `.github/requirements/publish.in` names `build` and `twine`. The version bump imports only `os`, `pathlib` and `re`, so nothing else is needed. `publish.txt` is generated from it with `uv pip compile`: wheels only, with hashes, for Python 3.12 on `x86_64-manylinux_2_28`. The command is in a comment above the install step of `publish.yml` (as `ci.yml` does for `ci.txt`) and in the header of `publish.txt`. + - The job now runs one install, `python -m pip install --require-hashes --only-binary :all: -r .github/requirements/publish.txt`, and no longer upgrades pip. The trigger, the other steps and the secret are unchanged. + - The lock was resolved with `--exclude-newer 2026-09-24`, the 7-day wait Dependabot uses. It holds back two releases that are newer than that: `cryptography` 50.0.2 and `charset-normalizer` 3.5.2. + - `.github/dependabot.yml`: the `pip` entry now names `/` and `/.github/requirements` (`directories`). From `/` Dependabot does not reach that directory, so `ci.txt` was not being kept current either. Target branch and cooldown are unchanged. + - Not covered: `python -m build` still installs the build backend (`setuptools>=82.0.1`, `[build-system] requires`) unpinned into its isolated environment. +- **Tests**: `test/test_workflow_actions.py` gains 6 cases, read from the files as text: + - the only job that reads `secrets.PYPI_API_TOKEN` is `publish.yml:publish`; + - it runs exactly one `pip install`, the locked one. An unpinned install, a second install, a pip upgrade, or the locked one without `--require-hashes` or `--only-binary :all:` fails it; + - what the job runs with `python -m`, the commands that start its shell lines, and what its inline script imports, less pip, the standard library and the runner's own `python`, `git` and `gh`, is exactly what `publish.in` names; + - every name in `publish.in` is pinned in `publish.txt`; + - the `--python-version` in the lock's header is the one the job sets up; + - Dependabot's `pip` entry covers `/` and `/.github/requirements`. +- **Result / numbers**: 29 pins: `build` 1.6.1, `twine` 7.0.0 and 27 dependencies (among them `cryptography` 50.0.1, `requests` 2.34.2, `urllib3` 2.8.0, `keyring` 25.7.0, `packaging` 26.3). + - `pip download --no-deps --require-hashes --only-binary :all:` for CPython 3.12 on manylinux x86_64 (glibc up to 2.28) fetches all 29 wheels with matching hashes. `--no-deps` because pip on Windows evaluates dependency markers for Windows and then asks for `colorama`. + - The dependency closure was checked apart from that: the `Requires-Dist` of the 29 wheels, with markers evaluated for Linux and CPython 3.12, give 34 requirements, all met by the pins. + - The job itself runs only on `main`, so it has not run with the lock yet. + - Full suite: 122 passed. +- **Docs**: `architecture.md` §2 gets a `.github/requirements/` row and the `test/` row names the new checks. `CLAUDE.md` and the three READMEs do not describe the publish job's tools, so none changed. +- **Files**: `.github/requirements/publish.in` (new), `.github/requirements/publish.txt` (new, generated), `.github/workflows/publish.yml`, `.github/dependabot.yml`, `test/test_workflow_actions.py`, `architecture.md`. +- **Open items**: none. `publish.yml` takes effect once `dev` is merged into `main`. + +## U-20261001-12 · 2026-10-01 · The publish job builds with the locked setuptools instead of downloading the newest · #change #ci #security #X-13 + +- **What**: workspace X-13, decided by the owner. The `publish` job (`publish.yml`, on a push to `main`) installed its tools from the hash-locked `publish.txt` (U-20261001-11), but `python -m build` then made an isolated environment and downloaded the newest `setuptools` that `build-system.requires` (`setuptools>=82.0.1`) allows, outside the lock, in the job that uploads with the PyPI token. + - `publish.in` now names `setuptools`, the only entry of `build-system.requires`. `publish.txt` was regenerated with the command in its header (Python 3.12, `x86_64-manylinux_2_28`, `--only-binary :all:`, `--exclude-newer 2026-09-24`): it adds `setuptools` 84.0.0 with its two hashes, and the other 29 pins are unchanged. + - The job runs `python -m build --no-isolation`, so the backend is the locked one. The trigger, the other steps and the secret are unchanged, and so are `pyproject.toml` and everything under `je_action_core/`. +- **Tests**: `test/test_workflow_actions.py`, read from the files as text: + - the job's `python -m build` carries `--no-isolation`; + - every entry of `build-system.requires` in `pyproject.toml` is pinned in `publish.txt` at or above the floor written there. `--no-isolation` checks the requirement instead of installing it, so a floor raised without regenerating the lock (Dependabot edits `pyproject.toml`) fails here, not in the publish job. An entry that is neither a bare name nor `name>=version` fails too; + - the existing check that `publish.in` names exactly what the job runs now requires the build backend as well. +- **Result / numbers**: built in a throwaway worktree with Python 3.12, `build` 1.6.1 and `setuptools` 84.0.0, after the job's own version bump (0.0.0 to 0.0.1). + - `python -m build --no-isolation` builds the sdist (24 files) and the wheel (19 members: 14 under `je_action_core/`, 5 under `dist-info/`), and `twine check` passes for both. + - Against an isolated `python -m build` of the same tree, every member of both files has the same name and the same bytes. + - With the floor raised to `setuptools>=85.0.0`, `--no-isolation` stops with "Unmet dependencies" before building anything. + - Each new check was seen to fail: the build without `--no-isolation`, a floor of 84.0.1, `setuptools` removed from `publish.in` or from `publish.txt`, a second entry (`wheel`), and `setuptools~=82.0`. + - The job runs only on a push to `main` and there is no dev publish job, so it has not run on a runner with this change yet. + - Full suite: 124 passed. +- **Docs**: `architecture.md` §2's `.github/requirements/` and `test/` rows. `CLAUDE.md` and the three READMEs do not describe how the job builds, so none changed. +- **Files**: `.github/requirements/publish.in`, `.github/requirements/publish.txt` (generated), `.github/workflows/publish.yml`, `test/test_workflow_actions.py`, `architecture.md`. +- **Open items**: none. `publish.yml` takes effect once `dev` is merged into `main`. + +## U-20261002-01 · 2026-10-02 · Add versioned request records and isolated run contexts · #done #records + +- **What**: added RequestRecord v1 validation, JSON-safe serialization/schema and explicit RunContext storage. Contexts isolate runs and task scopes; retry IDs deduplicate and conflicting/foreign records are refused. Legacy action-executor records are untouched and dependencies remain empty. +- **Validation**: 172 passed, including 48 new record/context tests; ruff E/F/W/C90/I passed for the new modules. Baseline was 124 passed. +- **Files**: je_action_core/request_record.py, request_context.py, public exports, new tests, three READMEs and architecture.md. +- **Release**: feature branch only; consumer features stay opt-in until the core API is published. No version number was edited. + +## U-20261002-02 · 2026-10-02 · Validate run identities and JSON retry semantics · #done #records + +Run contexts reject invalid identities before requests run. Retry comparison distinguishes boolean and numeric JSON values. Assertion messages and passed-outcome constraints now agree between schema and validator. Extreme integer inputs produce field-located errors. Regressions cover all review findings, including optional Draft 2020-12 schema validation. + +## U-20261002-03 · 2026-10-02 · Align HTTP method schema validation · #done #records + +The exported schema now enforces the same uppercase ASCII HTTP method-token rule as Python validation. Paired tests reject lowercase, whitespace and non-ASCII method names. + +## U-20261002-04 · 2026-10-02 · Reject trailing newlines in HTTP method schema · #done #records + +The JSON Schema HTTP method pattern now uses a strict end guard, so a trailing newline cannot bypass the token constraint. A paired schema/validator regression covers this regular-expression edge case. diff --git a/docs/updates/README.md b/docs/updates/README.md index 909fc5e..7066680 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,14 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261002-04 | 2026-10-02 | Reject trailing newlines in HTTP method schema | #done #records | [2026-10](2026-10.md) | +| U-20261002-03 | 2026-10-02 | Align HTTP method schema validation | #done #records | [2026-10](2026-10.md) | +| U-20261002-02 | 2026-10-02 | Validate run identities and JSON retry semantics | #done #records | [2026-10](2026-10.md) | +| U-20261002-01 | 2026-10-02 | Add versioned request records and isolated run contexts | #done #records | [2026-10](2026-10.md) | +| U-20261001-12 | 2026-10-01 | The publish job builds with the locked setuptools instead of downloading the newest | #change #ci #security #X-13 | [2026-10](2026-10.md) | +| U-20261001-11 | 2026-10-01 | Publish job installs hash-locked build tooling | #done #ci #security #X-13 | [2026-10](2026-10.md) | +| U-20261001-10 | 2026-10-01 | The sdist carries no tests | #done #packaging #X-13 | [2026-10](2026-10.md) | +| U-20261001-09 | 2026-10-01 | WebRunner's executor runs on the core | #done #executor #webrunner | [2026-10](2026-10.md) | | U-20261001-08 | 2026-10-01 | Executor hooks for WebRunner: attempt, failure records, numbered keys, collect | #executor #webrunner | [2026-10](2026-10.md) | | U-20261001-07 | 2026-10-01 | LoadDensity's and FileAutomation's servers run on the core | #done #socket-server | [2026-10](2026-10.md) | | U-20261001-06 | 2026-10-01 | Socket server: template handler, framing, TLS, two auth dialects | #change #socket-server | [2026-10](2026-10.md) | @@ -71,4 +79,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 8 | +| [2026-10.md](2026-10.md) | 2026-10 | 12 | diff --git a/je_action_core/__init__.py b/je_action_core/__init__.py index 5ffcf5b..6df9605 100644 --- a/je_action_core/__init__.py +++ b/je_action_core/__init__.py @@ -52,6 +52,15 @@ ) from je_action_core.registry import Command, CommandPolicy, CommandRegistry from je_action_core.reporting import ExecutionReporter, LoggingReporter, PrintReporter +from je_action_core.request_context import RunContext, get_run_context, use_run_context +from je_action_core.request_record import ( + RecordError, + RequestRecord, + RequestRecordError, + request_record_schema, + serialize_request_record, + validate_request_record, +) from je_action_core.socket_auth import EnvelopeTokenRequestHandler, SecretHeaderRequestHandler, secret_matches from je_action_core.socket_server import ( END_MARKER, @@ -88,4 +97,7 @@ "FailureStage", "Framing", "OversizePolicy", "ReplyMessages", "SocketServerSettings", "server_tls_context", "start_action_socket_server", "EnvelopeTokenRequestHandler", "SecretHeaderRequestHandler", "secret_matches", + "RecordError", "RequestRecord", "RequestRecordError", "request_record_schema", + "serialize_request_record", "validate_request_record", + "RunContext", "get_run_context", "use_run_context", ] diff --git a/je_action_core/request_context.py b/je_action_core/request_context.py new file mode 100644 index 0000000..9a4a76a --- /dev/null +++ b/je_action_core/request_context.py @@ -0,0 +1,82 @@ +"""Explicit run-scoped request storage shared by synchronous and asynchronous runners.""" + +from __future__ import annotations + +import copy +import json +from collections.abc import Iterator, Mapping +from contextlib import contextmanager +from contextvars import ContextVar +from threading import RLock +from uuid import uuid4 + +from je_action_core.request_record import ( + RequestRecord, + RequestRecordError, + _validate_run_identity, + validate_request_record, +) + + +class RunContext: + """Own request identities and records for one run; snapshots never expose mutable storage.""" + + def __init__(self, source: str, phase: str, engine: str, worker_id: str | None = None, + run_id: str | None = None) -> None: + self.run_id = str(uuid4()) if run_id is None else run_id + self._identity = {"run_id": self.run_id, "source": source, "phase": phase, + "engine": engine, "worker_id": worker_id} + _validate_run_identity(self._identity) + self._records: dict[str, RequestRecord] = {} + self._lock = RLock() + + def capture(self, fields: Mapping[str, object]) -> RequestRecord: + """Build and append a measured result with this run's identity and a unique record ID.""" + record = {"schema_version": 1, "record_id": str(uuid4()), "scenario_id": None, "step_id": None, + "assertions": [], "extensions": {}, **self._identity, **fields} + validated = validate_request_record(record) + self.append(validated) + return validated + + def append(self, record: Mapping[str, object]) -> bool: + """Append once, returning False for identical retries and rejecting conflicts or foreign runs.""" + validated = validate_request_record(record) + for field, expected in self._identity.items(): + if validated[field] != expected: + raise RequestRecordError(f"{field}: does not belong to this run context") + identifier = validated["record_id"] + with self._lock: + previous = self._records.get(identifier) + if previous is not None: + if json.dumps(previous, sort_keys=True) != json.dumps(validated, sort_keys=True): + raise RequestRecordError("record_id: conflicting retry of an existing record") + return False + self._records[identifier] = validated + return True + + def snapshot(self) -> list[RequestRecord]: + """Return a detached, insertion-ordered copy of all records in this run.""" + with self._lock: + return copy.deepcopy(list(self._records.values())) + + def to_json(self) -> str: + """Export this run's canonical records as a JSON array.""" + return json.dumps(self.snapshot(), ensure_ascii=False, allow_nan=False) + + +_active_context: ContextVar[RunContext | None] = ContextVar("request_run_context", default=None) + + +def get_run_context() -> RunContext | None: + """Return the request context active in this thread/task, if explicitly selected.""" + return _active_context.get() + + +@contextmanager +def use_run_context(context: RunContext) -> Iterator[RunContext]: + """Activate a context for a scope, restoring the prior scope even when the runner fails.""" + token = _active_context.set(context) + try: + yield context + finally: + _active_context.reset(token) diff --git a/je_action_core/request_record.py b/je_action_core/request_record.py new file mode 100644 index 0000000..2f8af1d --- /dev/null +++ b/je_action_core/request_record.py @@ -0,0 +1,285 @@ +"""Versioned request results, independent of action-executor records and transports.""" + +from __future__ import annotations + +import base64 +import json +import math +import re +from collections.abc import Mapping +from datetime import datetime, timedelta +from typing import TypedDict, cast + +from je_action_core.exceptions import ActionCoreException + + +class RecordError(TypedDict): + """A transport, assertion or runner failure without a Python exception object.""" + + kind: str + message: str + + +class RequestRecord(TypedDict): + """Required fields of the request-result v1 contract.""" + + schema_version: int + record_id: str + run_id: str + source: str + phase: str + engine: str + worker_id: str | None + scenario_id: str | None + step_id: str | None + protocol: str + request_method: str + request_url: str + name: str + status_code: int | None + start_time: float | None + end_time: float | None + response_time_ms: float | None + response_length: int | None + outcome: str + error: RecordError | None + assertions: list[dict[str, object]] + extensions: dict[str, object] + + +class RequestRecordError(ActionCoreException, ValueError): + """A request result is malformed; the message includes its field location.""" + + +_TEXT_FIELDS = ( + "record_id", "run_id", "source", "phase", "engine", "protocol", "request_method", "request_url", "name", +) +_OPTIONAL_IDS = ("worker_id", "scenario_id", "step_id") +_MEASUREMENTS = ("start_time", "end_time", "response_time_ms") +_CHOICES = { + "source": ("apitestka", "loaddensity", "webrunner"), + "phase": ("functional", "load", "synthetic"), + "outcome": ("passed", "failed"), +} +_PAYLOAD_FIELDS = ("text", "headers", "content_base64", "request_body") +_REQUIRED = tuple(RequestRecord.__annotations__) +_MAX_JSON_INTEGER = 10**4300 # Portable default integer conversion limit in supported Python versions. +_HTTP_METHOD_PATTERN = r"^[A-Z!#$%&'*+.^_`|~0-9-]+$(?![\s\S])" + + +def _fail(location: str, reason: str) -> None: + raise RequestRecordError(f"{location}: {reason}") + + +def _json_integer(value: int, location: str) -> int: + if abs(value) >= _MAX_JSON_INTEGER: + _fail(location, "integer exceeds the portable JSON conversion limit") + try: + str(value) + except ValueError as error: + raise RequestRecordError(f"{location}: integer exceeds the JSON conversion limit") from error + return value + + +def _json_value(value: object, location: str, ancestors: frozenset[int] = frozenset()) -> object: + if value is None or isinstance(value, (str, bool)): + return value + if isinstance(value, int): + return _json_integer(value, location) + if isinstance(value, float): + if not math.isfinite(value): + _fail(location, "must be finite") + return value + if isinstance(value, datetime): + return value.timestamp() + if isinstance(value, timedelta): + return value.total_seconds() * 1000.0 + if isinstance(value, bytes): + return {"encoding": "base64", "data": base64.b64encode(value).decode("ascii")} + if id(value) in ancestors: + _fail(location, "circular value") + nested = ancestors | {id(value)} + return _json_container(value, location, nested) + + +def _json_container(value: object, location: str, ancestors: frozenset[int]) -> object: + if isinstance(value, Mapping): + converted = {} + for key, item in value.items(): + if not isinstance(key, str): + _fail(location, "object keys must be strings") + converted[key] = _json_value(item, f"{location}.{key}", ancestors) + return converted + if isinstance(value, (list, tuple)): + return [_json_value(item, f"{location}[{index}]", ancestors) for index, item in enumerate(value)] + _fail(location, f"unsupported value type {type(value).__name__}") + + +def _validate_strings(record: Mapping[str, object], fields: tuple[str, ...]) -> None: + for field in fields: + if not isinstance(record[field], str) or not record[field]: + _fail(field, "must be a non-empty string") + + +def _validate_ids(record: Mapping[str, object], fields: tuple[str, ...]) -> None: + for field in fields: + if record[field] is not None and not isinstance(record[field], str): + _fail(field, "must be a string or null") + + +def _validate_choices(record: Mapping[str, object], fields: tuple[str, ...]) -> None: + for field in fields: + choices = _CHOICES[field] + if record[field] not in choices: + _fail(field, f"must be one of {choices}") + + +def _validate_run_identity(identity: Mapping[str, object]) -> None: + _validate_strings(identity, ("run_id", "source", "phase", "engine")) + _validate_ids(identity, ("worker_id",)) + _validate_choices(identity, ("source", "phase")) + + +def _validate_text(record: Mapping[str, object]) -> None: + _validate_strings(record, _TEXT_FIELDS) + _validate_ids(record, _OPTIONAL_IDS) + _validate_choices(record, tuple(_CHOICES)) + method = cast(str, record["request_method"]) + if record["protocol"] == "http" and re.fullmatch(_HTTP_METHOD_PATTERN, method) is None: + _fail("request_method", "HTTP methods must be uppercase ASCII tokens") + + +def _validate_integer(record: Mapping[str, object], field: str, minimum: int | None = None) -> None: + value = record[field] + if value is None: + return + if type(value) is not int: + _fail(field, "must be an integer or null") + if minimum is not None and cast(int, value) < minimum: + _fail(field, f"must be at least {minimum}") + + +def _is_finite_number(value: object) -> bool: + if type(value) not in (int, float): + return False + try: + return math.isfinite(cast(float, value)) + except OverflowError: + return False + + +def _validate_measurements(record: Mapping[str, object]) -> None: + for field in _MEASUREMENTS: + value = record[field] + if value is not None and not _is_finite_number(value): + _fail(field, "must be a finite number or null") + elapsed = record["response_time_ms"] + if elapsed is not None and cast(float, elapsed) < 0: + _fail("response_time_ms", "must be non-negative") + start, end = record["start_time"], record["end_time"] + if start is not None and end is not None and cast(float, end) < cast(float, start): + _fail("end_time", "must not precede start_time") + _validate_integer(record, "status_code") + _validate_integer(record, "response_length", minimum=0) + + +def _validate_outcome(record: Mapping[str, object]) -> None: + error = record["error"] + if record["outcome"] == "passed": + if error is not None: + _fail("error", "passed records must have null error") + else: + if not isinstance(error, dict): + _fail("error", "failed records require a structured error") + for field in ("kind", "message"): + if not isinstance(error.get(field), str) or not error[field]: + _fail(f"error.{field}", "must be a non-empty string") + _validate_assertions(record) + if not isinstance(record["extensions"], dict): + _fail("extensions", "must be an object") + + +def _validate_assertions(record: Mapping[str, object]) -> None: + assertions = record["assertions"] + if not isinstance(assertions, list): + _fail("assertions", "must be an array") + for index, assertion in enumerate(assertions): + location = f"assertions[{index}]" + if not isinstance(assertion, dict) or not isinstance(assertion.get("type"), str): + _fail(location, "must have a string type") + if type(assertion.get("passed")) is not bool: + _fail(location, "passed must be a boolean") + if "message" in assertion and not isinstance(assertion["message"], str): + _fail(location, "message must be a string") + if not assertion["passed"] and not isinstance(assertion.get("message"), str): + _fail(location, "failed assertion must have a message") + if not assertion["passed"] and record["outcome"] == "passed": + _fail(location, "a failed assertion cannot have a passed outcome") + + +def _validate_payload(record: Mapping[str, object]) -> None: + allowed_types = {"text": (str, type(None)), "headers": (dict,), "content_base64": (str,)} + for field, types in allowed_types.items(): + if field in record and not isinstance(record[field], types): + _fail(field, "invalid payload type") + + +def validate_request_record(record: Mapping[str, object]) -> RequestRecord: + """Validate and detach a v1 record, converting supported payloads to JSON values.""" + if not isinstance(record, Mapping): + _fail("record", "must be an object") + for field in _REQUIRED: + if field not in record: + _fail(field, "required field is missing") + unknown = set(record) - set(_REQUIRED) - set(_PAYLOAD_FIELDS) + if unknown: + _fail("record", f"unknown fields: {sorted(unknown, key=str)}; use extensions") + if type(record["schema_version"]) is not int or record["schema_version"] != 1: + _fail("schema_version", "only integer version 1 is supported") + converted = cast(dict[str, object], _json_value(record, "record")) + _validate_text(converted) + _validate_measurements(converted) + _validate_outcome(converted) + _validate_payload(converted) + return cast(RequestRecord, converted) + + +def serialize_request_record(record: Mapping[str, object]) -> str: + """Serialize a validated request result without arbitrary-object stringification.""" + return json.dumps(validate_request_record(record), ensure_ascii=False, allow_nan=False) + + +def request_record_schema() -> dict[str, object]: + """Return the JSON Schema for request results, separate from action records.""" + properties: dict[str, object] = {field: {"type": "string", "minLength": 1} for field in _TEXT_FIELDS} + properties.update({field: {"type": ["string", "null"]} for field in _OPTIONAL_IDS}) + properties.update({field: {"type": ["number", "null"]} for field in _MEASUREMENTS}) + properties.update({field: {"enum": list(values)} for field, values in _CHOICES.items()}) + properties.update({ + "schema_version": {"const": 1, "type": "integer"}, + "status_code": {"type": ["integer", "null"]}, + "response_length": {"type": ["integer", "null"], "minimum": 0}, + "response_time_ms": {"type": ["number", "null"], "minimum": 0}, + "error": {"anyOf": [{"type": "null"}, {"type": "object", "required": ["kind", "message"], + "properties": {field: {"type": "string", "minLength": 1} + for field in ("kind", "message")}}]}, + "assertions": {"type": "array", "items": {"type": "object", "required": ["type", "passed"], + "properties": {"type": {"type": "string"}, + "passed": {"type": "boolean"}, + "message": {"type": "string"}}, + "allOf": [{"if": {"properties": {"passed": {"const": False}}}, + "then": {"required": ["message"]}}]}}, + "extensions": {"type": "object"}, + "text": {"type": ["string", "null"]}, "headers": {"type": "object"}, + "content_base64": {"type": "string"}, "request_body": {}, + }) + return {"$schema": "https://json-schema.org/draft/2020-12/schema", "title": "RequestRecord v1", + "type": "object", "required": list(_REQUIRED), "properties": properties, + "additionalProperties": False, + "allOf": [{"if": {"properties": {"outcome": {"const": "passed"}}}, + "then": {"properties": {"error": {"type": "null"}, + "assertions": {"items": {"properties": { + "passed": {"const": True}}}}}}, + "else": {"properties": {"error": {"type": "object"}}}}, + {"if": {"properties": {"protocol": {"const": "http"}}}, + "then": {"properties": {"request_method": {"pattern": _HTTP_METHOD_PATTERN}}}}]} diff --git a/progress.md b/progress.md index d5a95db..6a908a5 100644 --- a/progress.md +++ b/progress.md @@ -6,4 +6,11 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: L- ## Open -- **#3** [DECIDE] Move WebRunner's executor onto this package too. It already has the package gate and `SAFE_BUILTINS`, but differs in `[cmd, [args], {kwargs}]` actions, `restricted()`, retries and spans. +- **#4** Move WebRunner's other copies onto this package, as its executor was (WebRunner U-20261001-47): + - `utils/package_manager/package_manager_class.py`: gate on by default, with an allowlist (`allow_packages`). + - `utils/callback/callback_function_executor.py`: a separate `event_dict`. + - `utils/json/json_file/json_file.py`. + - `utils/socket_server/web_runner_socket_server.py`: 459 lines. + + Each needs characterization tests first, as LoadDensity's and FileAutomation's servers had. Jeffrey_RPA loads + WebRunner's working tree live, so check `AI_CONTEXT.md` §5 before each step. diff --git a/test/test_request_context.py b/test/test_request_context.py new file mode 100644 index 0000000..73ef43e --- /dev/null +++ b/test/test_request_context.py @@ -0,0 +1,101 @@ +import asyncio +import json + +import pytest + +from je_action_core.request_context import RunContext, get_run_context, use_run_context +from je_action_core.request_record import RequestRecordError + + +def fields(**changes): + result = {"protocol": "http", "request_method": "GET", "request_url": "http://localhost/", + "name": "GET /", "status_code": 200, "start_time": 100.0, "end_time": 100.01, + "response_time_ms": 10.0, "response_length": 2, "outcome": "passed", "error": None} + result.update(changes) + return result + + +def context(): + return RunContext(source="loaddensity", phase="load", engine="asyncio") + + +def test_runs_have_distinct_ids_and_detached_records(): + first, second = context(), context() + assert first.run_id != second.run_id + first.capture(fields(extensions={"nested": [1]})) + assert second.snapshot() == [] + snapshot = first.snapshot() + snapshot[0]["extensions"]["nested"].append(2) + assert first.snapshot()[0]["extensions"] == {"nested": [1]} + assert json.loads(first.to_json())[0]["response_time_ms"] == 10.0 + + +def test_identical_retry_deduplicates_but_conflicting_result_fails(): + run = context() + record = run.capture(fields()) + assert run.append(record) is False + assert len(run.snapshot()) == 1 + with pytest.raises(RequestRecordError, match="record_id"): + run.append({**record, "status_code": 201}) + + +def test_record_from_other_run_is_rejected(): + first, second = context(), context() + record = first.capture(fields()) + with pytest.raises(RequestRecordError, match="run_id"): + second.append(record) + + +def test_nested_context_restores_after_exception(): + first, second = context(), context() + assert get_run_context() is None + with use_run_context(first): + assert get_run_context() is first + with pytest.raises(ValueError), use_run_context(second): + assert get_run_context() is second + raise ValueError("probe") + assert get_run_context() is first + assert get_run_context() is None + + +def test_async_task_contexts_do_not_cross_contaminate(): + async def worker(run, status): + with use_run_context(run): + await asyncio.sleep(0) + get_run_context().capture(fields(status_code=status)) + + async def execute(): + first, second = context(), context() + await asyncio.gather(worker(first, 200), worker(second, 201)) + return first, second + + first, second = asyncio.run(execute()) + assert [record["status_code"] for record in first.snapshot()] == [200] + assert [record["status_code"] for record in second.snapshot()] == [201] + + +def test_capture_rejects_wrong_source_instead_of_overriding_identity(): + with pytest.raises(RequestRecordError, match="source"): + context().capture(fields(source="apitestka")) + + +@pytest.mark.parametrize("value,retry", [(1, True), ([{"flag": 1}], [{"flag": True}])]) +def test_boolean_and_numeric_retry_payloads_conflict(value, retry): + run = context() + record = run.capture(fields(extensions={"value": value})) + with pytest.raises(RequestRecordError, match="record_id"): + run.append({**record, "extensions": {"value": retry}}) + + +@pytest.mark.parametrize("field,value", [("source", "invalid"), ("phase", "invalid"), ("engine", ""), + ("worker_id", 1), ("run_id", ""), ("run_id", 1)]) +def test_invalid_identity_fails_at_construction(field, value): + settings = {"source": "loaddensity", "phase": "load", "engine": "asyncio", field: value} + with pytest.raises(RequestRecordError, match=field): + RunContext(**settings) + + +def test_retry_with_different_object_key_order_is_identical(): + run = context() + record = run.capture(fields(extensions={"first": 1, "second": 2})) + assert run.append({**record, "extensions": {"second": 2, "first": 1}}) is False diff --git a/test/test_request_record.py b/test/test_request_record.py new file mode 100644 index 0000000..3860a21 --- /dev/null +++ b/test/test_request_record.py @@ -0,0 +1,162 @@ +import json +from datetime import datetime, timedelta, timezone + +import pytest + +from je_action_core.request_record import ( + RequestRecordError, + request_record_schema, + serialize_request_record, + validate_request_record, +) + + +def sample(**changes): + record = { + "schema_version": 1, "record_id": "r-1", "run_id": "run-1", "source": "loaddensity", + "phase": "load", "engine": "locust", "worker_id": None, "scenario_id": None, "step_id": None, + "protocol": "http", "request_method": "GET", "request_url": "http://localhost/health", "name": "health", + "status_code": 200, "start_time": 100.0, "end_time": 100.025, "response_time_ms": 25.0, + "response_length": 12, "outcome": "passed", "error": None, "assertions": [], "extensions": {}, + } + record.update(changes) + return record + + +def test_success_record_round_trips_without_altering_values(): + record = sample() + assert json.loads(serialize_request_record(record)) == record + assert validate_request_record(record)["response_time_ms"] == 25.0 + + +def test_failure_preserves_status_and_assertion_result(): + record = sample(status_code=500, outcome="failed", error={"kind": "http_status", "message": "HTTP 500"}, + assertions=[{"type": "status_code", "passed": False, "message": "wanted 200"}]) + result = json.loads(serialize_request_record(record)) + assert result["status_code"] == 500 + assert result["outcome"] == "failed" + assert result["assertions"][0]["passed"] is False + + +def test_transport_failure_allows_unknown_measurements(): + result = validate_request_record(sample(status_code=None, start_time=None, end_time=None, response_time_ms=None, + response_length=None, outcome="failed", + error={"kind": "connection", "message": "refused"})) + assert result["status_code"] is None + assert result["response_time_ms"] is None + + +@pytest.mark.parametrize("field,value", [ + ("schema_version", 2), ("schema_version", True), ("run_id", ""), ("record_id", ""), + ("source", "unknown"), ("phase", "unknown"), ("engine", ""), ("protocol", ""), + ("request_method", "get"), ("status_code", "200"), ("status_code", True), + ("response_time_ms", -1), ("response_time_ms", float("nan")), ("start_time", float("inf")), + ("response_length", -1), ("response_length", 1.5), ("response_length", True), + ("outcome", "success"), ("error", {"kind": "timeout", "message": "oops"}), + ("assertions", [{"type": "status_code", "passed": "false"}]), ("assertions", [{}]), + ("extensions", []), ("worker_id", 1), ("end_time", 99.0), +]) +def test_malformed_record_is_rejected_with_field_location(field, value): + with pytest.raises(RequestRecordError, match=field): + validate_request_record(sample(**{field: value})) + + +@pytest.mark.parametrize("error", [None, "oops", {}, {"kind": "timeout"}, {"kind": "", "message": "oops"}]) +def test_failed_record_requires_structured_error(error): + with pytest.raises(RequestRecordError, match="error"): + validate_request_record(sample(outcome="failed", error=error)) + + +def test_missing_required_field_is_reported(): + record = sample() + del record["step_id"] + with pytest.raises(RequestRecordError, match="step_id"): + validate_request_record(record) + + +def test_payload_converts_supported_python_values_without_stringifying_objects(): + record = sample(extensions={"bytes": b"\x00\xff", "when": datetime(2026, 1, 1, tzinfo=timezone.utc), + "elapsed": timedelta(milliseconds=1500), "label": "測試"}) + payload = json.loads(serialize_request_record(record)) + assert payload["extensions"] == { + "bytes": {"encoding": "base64", "data": "AP8="}, "when": 1767225600.0, + "elapsed": 1500.0, "label": "測試", + } + with pytest.raises(RequestRecordError, match="extensions"): + serialize_request_record(sample(extensions={"opaque": object()})) + + +def test_nested_nan_and_non_string_keys_are_rejected(): + with pytest.raises(RequestRecordError, match="extensions"): + serialize_request_record(sample(extensions={"nested": [float("nan")]})) + with pytest.raises(RequestRecordError, match="extensions"): + serialize_request_record(sample(extensions={1: "bad key"})) + + +def test_validation_returns_a_detached_snapshot(): + original = sample(extensions={"nested": [1]}) + result = validate_request_record(original) + original["extensions"]["nested"].append(2) + assert result["extensions"] == {"nested": [1]} + + +def test_schema_covers_required_keys_and_nullable_transport_status(): + schema = request_record_schema() + assert set(schema["required"]) == set(sample()) + assert schema["properties"]["schema_version"] == {"const": 1, "type": "integer"} + assert schema["properties"]["status_code"]["type"] == ["integer", "null"] + + +@pytest.mark.parametrize("field,value", [("text", 1), ("headers", []), ("content_base64", 1)]) +def test_payload_types_match_schema(field, value): + with pytest.raises(RequestRecordError, match=field): + validate_request_record(sample(**{field: value})) + + +def test_failed_assertion_cannot_be_marked_as_passed_request(): + with pytest.raises(RequestRecordError, match="assertions"): + validate_request_record(sample(assertions=[{"type": "status_code", "passed": False, "message": "bad"}])) + + +def test_circular_payload_is_rejected_with_location(): + extensions = {} + extensions["cycle"] = extensions + with pytest.raises(RequestRecordError, match="extensions.cycle"): + serialize_request_record(sample(extensions=extensions)) + + +def test_passed_assertion_message_must_be_text_when_present(): + with pytest.raises(RequestRecordError, match="assertions"): + validate_request_record(sample(assertions=[{"type": "status", "passed": True, "message": 1}])) + + +@pytest.mark.parametrize("field,value", [("start_time", 10**1000), ("response_length", 10**5000)], + ids=["huge-timestamp", "huge-length"]) +def test_extreme_integer_errors_keep_the_field_location(field, value): + with pytest.raises(RequestRecordError, match=field): + serialize_request_record(sample(**{field: value})) + + +@pytest.mark.parametrize("assertion,outcome", [ + ({"type": "status", "passed": False}, "failed"), + ({"type": "status", "passed": True, "message": 1}, "passed"), + ({"type": "status", "passed": False, "message": "bad"}, "passed"), +]) +def test_schema_and_validator_reject_the_same_assertion_errors(assertion, outcome): + jsonschema = pytest.importorskip("jsonschema") + record = sample(assertions=[assertion], outcome=outcome, + error={"kind": "assertion", "message": "bad"} if outcome == "failed" else None) + with pytest.raises(jsonschema.ValidationError): + jsonschema.Draft202012Validator(request_record_schema()).validate(record) + with pytest.raises(RequestRecordError): + validate_request_record(record) + + +@pytest.mark.parametrize("method", ["get", "BAD METHOD", "GÉT", "GET\n"]) +def test_http_methods_use_the_same_uppercase_token_rule_in_schema(method): + jsonschema = pytest.importorskip("jsonschema") + record = sample(request_method=method) + with pytest.raises(jsonschema.ValidationError): + jsonschema.Draft202012Validator(request_record_schema()).validate(record) + with pytest.raises(RequestRecordError, match="request_method"): + validate_request_record(record) diff --git a/test/test_sdist_manifest.py b/test/test_sdist_manifest.py new file mode 100644 index 0000000..ccdf1bf --- /dev/null +++ b/test/test_sdist_manifest.py @@ -0,0 +1,33 @@ +"""The source distribution carries no tests. + +setuptools adds ``test*/test*.py`` to an sdist by default, so the ``je_action_core`` 0.0.3 sdist +shipped 12 files of this suite, although the wheel installs only the package +(``[tool.setuptools.packages.find]`` in ``pyproject.toml``). ``MANIFEST.in`` prunes the directory. +Nothing is built here: the template is read and its commands are checked in the order setuptools +applies them. +""" +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_DIRECTORY = Path(__file__).resolve().parent.name +PRUNE = ["prune", TEST_DIRECTORY] +# The template commands that add files; one of them after the prune could bring tests back. +ADDING = {"include", "recursive-include", "global-include", "graft"} + + +def _commands() -> list[list[str]]: + """Return the commands of ``MANIFEST.in`` in order, each split into words.""" + lines = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8").splitlines() + return [line.split() for line in lines if line.strip() and not line.lstrip().startswith("#")] + + +def test_manifest_prunes_the_test_directory(): + assert PRUNE in _commands() + + +def test_nothing_after_the_prune_adds_files_back(): + commands = _commands() + following = commands[commands.index(PRUNE) + 1:] + assert [command for command in following if command[0] in ADDING] == [] diff --git a/test/test_workflow_actions.py b/test/test_workflow_actions.py index ace67ef..9a870e9 100644 --- a/test/test_workflow_actions.py +++ b/test/test_workflow_actions.py @@ -5,10 +5,15 @@ full 40-hex commit and carries the release it corresponds to as a comment, which is what Dependabot reads and updates. Pinning also keeps Node 20 actions from lingering unnoticed: GitHub removed Node 20 from its runners on 2026-09-23. + +The rest of the workflow supply chain is guarded here too: Dependabot's +settings, checkout credentials, job timeouts, and the hash-locked tooling and +build backend of the job that holds the PyPI token. """ from __future__ import annotations import re +import sys from pathlib import Path import pytest @@ -75,6 +80,17 @@ def test_dependabot_waits_a_week_before_proposing_a_release(): assert blocks and all(match and int(match.group(1)) >= 7 for match in days) +def test_dependabot_watches_the_hash_locked_requirements(): + # From "/" Dependabot does not reach .github/requirements/, so the directory + # has to be named or its locks are never updated. + text = (_ROOT / ".github" / "dependabot.yml").read_text(encoding="utf-8") + blocks = re.split(r"^\s*-\s*package-ecosystem:", text, flags=re.MULTILINE)[1:] + pip = next(block for block in blocks if block.split()[0].strip("\"'") == "pip") + settings = "\n".join(line for line in pip.splitlines() if not line.lstrip().startswith("#")) + assert re.search(r"^\s*directories:", settings, re.MULTILINE) + assert set(re.findall(r"\"(/[^\"]*)\"", settings)) == {"/", "/.github/requirements"} + + def _checkout_steps(path: Path) -> list[tuple[int, str]]: """Return ``(line number, step text)`` for each ``actions/checkout`` step.""" lines = path.read_text(encoding="utf-8").splitlines() @@ -122,3 +138,131 @@ def test_every_job_has_a_timeout(workflow): bad = [name for name, body in _jobs(workflow) if "runs-on:" in body and not re.search(r"^\s*timeout-minutes:", body, re.MULTILINE)] assert bad == [] + + +_REQUIREMENTS = _ROOT / ".github" / "requirements" +_LOCKED_INSTALL = "python -m pip install --require-hashes --only-binary :all: -r .github/requirements/publish.txt" +_PIP_INSTALL = re.compile(r"(?:python3? -m )?\bpip3? install\b.*") +_HEREDOC = re.compile(r"<<-?'?(\w+)'?\n(.*?)\n\s*\1$", re.DOTALL | re.MULTILINE) +_MODULE = re.compile(r"python3? -m ([A-Za-z_]\w*)") +_COMMAND = re.compile(r"^\s*(?:-\s*)?(?:run:\s*)?([a-z][\w.-]*)(?=\s|$)", re.MULTILINE) +_IMPORT = re.compile(r"^\s*(?:import|from) ([A-Za-z_]\w*)", re.MULTILINE) +# Commands the runner image supplies; any other command a job runs has to come from the lock. +_RUNNER_COMMANDS = {"python", "git", "gh"} + + +def _publish_jobs() -> list[tuple[str, str]]: + """Return ``(workflow:job, job text without comment lines)`` for each job that reads the PyPI token.""" + found = [] + for workflow in _WORKFLOWS: + for name, body in _jobs(workflow): + if "secrets.PYPI_API_TOKEN" in body: + code = [line for line in body.splitlines() if not line.lstrip().startswith("#")] + found.append((f"{workflow.name}:{name}", "\n".join(code))) + return found + + +_PUBLISH_JOBS = _publish_jobs() + + +def _distribution(name: str) -> str: + """Return a module, command or requirement name the way PyPI spells a distribution.""" + return name.lower().replace("_", "-") + + +def _tools(body: str) -> set[str]: + """Return the distributions a job needs besides pip, the standard library and the runner's commands. + + They are what it runs with ``python -m``, the commands that start a shell line, and what its + inline scripts (heredocs) import. + """ + shell = body.replace("\\\n", " ") + scripts = "\n".join(match.group(2) for match in _HEREDOC.finditer(shell)) + shell = _HEREDOC.sub("", shell) + named = {*_MODULE.findall(shell), *_COMMAND.findall(shell), *_IMPORT.findall(scripts)} + return {_distribution(name) for name in named - _RUNNER_COMMANDS - {"pip"} - set(sys.stdlib_module_names)} + + +def _requirements(name: str) -> set[str]: + """Return the distributions a file in ``.github/requirements`` names, one at the start of a line.""" + text = (_REQUIREMENTS / name).read_text(encoding="utf-8") + return {_distribution(found) for found in re.findall(r"^([A-Za-z0-9][\w.-]*)", text, re.MULTILINE)} + + +def test_the_job_that_holds_the_pypi_token_is_the_publish_job(): + assert [name for name, _body in _PUBLISH_JOBS] == ["publish.yml:publish"] + + +@pytest.mark.parametrize("body", [body for _name, body in _PUBLISH_JOBS], ids=[name for name, _body in _PUBLISH_JOBS]) +def test_publish_job_installs_only_the_hash_locked_tooling(body): + # Whatever this job installs runs next to the PyPI token. An unpinned + # "pip install build twine", or upgrading pip first, takes the newest upload + # of that day; the lock allows only wheels whose hashes were recorded. + assert [command.strip() for command in _PIP_INSTALL.findall(body)] == [_LOCKED_INSTALL] + + +_BUILD = re.compile(r"\bpython3? -m build\b[^\n]*") +_BUILD_REQUIRES = re.compile(r"^\[build-system\]\n(?:(?!\[).*\n)*?requires\s*=\s*\[([^\]]*)\]", re.MULTILINE) +_REQUIREMENT = re.compile(r"([A-Za-z0-9][\w.-]*)\s*(?:>=\s*(\d+(?:\.\d+)*))?") + + +def _version(text: str) -> tuple[int, ...]: + """Return a dotted release number as a tuple that compares in version order (``82.0`` equals ``82``).""" + return tuple(int(part) for part in re.sub(r"(\.0+)+$", "", text).split(".")) + + +def _build_requires() -> dict[str, tuple[int, ...]]: + """Return ``{distribution: lowest version allowed}`` for ``build-system.requires`` in ``pyproject.toml``. + + An entry is a bare name or ``name>=version``. Any other form fails here, so the comparison with + the lock gets extended before such an entry is relied on. + """ + text = (_ROOT / "pyproject.toml").read_text(encoding="utf-8") + entries = re.findall(r"[\"']([^\"']+)[\"']", _BUILD_REQUIRES.search(text).group(1)) + matches = [_REQUIREMENT.fullmatch(entry.strip()) for entry in entries] + assert all(matches), entries + return {_distribution(match.group(1)): _version(match.group(2) or "0") for match in matches} + + +def _locked_version(distribution: str) -> tuple[int, ...]: + """Return the version ``publish.txt`` pins for ``distribution``.""" + lock = (_REQUIREMENTS / "publish.txt").read_text(encoding="utf-8") + pin = re.search(rf"^{re.escape(distribution)}==(\d+(?:\.\d+)*)\s", lock, re.MULTILINE) + return _version(pin.group(1)) + + +def test_publish_in_lists_exactly_the_tools_the_job_runs_and_the_build_backend(): + # A tool the job starts using has to be locked first, or the release fails at that step. The job + # never names the build backend: python -m build --no-isolation imports the one installed here. + used = set().union(*(_tools(body) for _name, body in _PUBLISH_JOBS)) + assert used | set(_build_requires()) == _requirements("publish.in") + + +@pytest.mark.parametrize("body", [body for _name, body in _PUBLISH_JOBS], ids=[name for name, _body in _PUBLISH_JOBS]) +def test_publish_job_builds_with_the_locked_backend(body): + # An isolated build downloads the newest release that build-system.requires allows, outside the + # lock, and runs it in the job that is about to upload with the token. + builds = _BUILD.findall(body) + assert builds and all("--no-isolation" in command.split() for command in builds) + + +def test_the_locked_build_backend_satisfies_build_system_requires(): + # --no-isolation checks build-system.requires instead of installing it, so a floor raised without + # regenerating the lock (Dependabot edits pyproject.toml) has to fail here, not in the publish job. + required = _build_requires() + assert required and set(required) <= _requirements("publish.txt") + assert {name: floor for name, floor in required.items() if _locked_version(name) < floor} == {} + + +def test_publish_lock_pins_every_tool_of_publish_in(): + # publish.txt is generated; editing publish.in alone changes nothing the job installs. + assert _requirements("publish.in") <= _requirements("publish.txt") + + +def test_publish_lock_is_resolved_for_the_python_the_job_sets_up(): + # The lock holds the wheels of one Python version; a job on another one may find none that match. + header = (_REQUIREMENTS / "publish.txt").read_text(encoding="utf-8").splitlines()[1] + locked_for = re.search(r"--python-version (\S+)", header).group(1) + set_up = {version for _name, body in _PUBLISH_JOBS + for version in re.findall(r"python-version:\s*\"([^\"]+)\"", body)} + assert set_up == {locked_for}