diff --git a/docs/git-draft.adoc b/docs/git-draft.adoc index bdbfe9b..9afdfff 100644 --- a/docs/git-draft.adoc +++ b/docs/git-draft.adoc @@ -89,7 +89,7 @@ Otherwise if no template is specified and stdin is a TTY, `$EDITOR` will be open --quit:: Go back to the draft's origin branch, keeping the working directory's current state. This will delete the draft branch and its upstream. -Generated commits and the draft branch's final state remain available via `ref/drafts`. +Generated commits and the draft branch's final state remain available via `refs/drafts`. -T:: --templates:: diff --git a/poetry.lock b/poetry.lock index 82a3c09..bfe7e09 100644 --- a/poetry.lock +++ b/poetry.lock @@ -434,46 +434,105 @@ files = [ {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, ] +[[package]] +name = "msgspec" +version = "0.19.0" +description = "A fast serialization and validation library, with builtin support for JSON, MessagePack, YAML, and TOML." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "msgspec-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d8dd848ee7ca7c8153462557655570156c2be94e79acec3561cf379581343259"}, + {file = "msgspec-0.19.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0553bbc77662e5708fe66aa75e7bd3e4b0f209709c48b299afd791d711a93c36"}, + {file = "msgspec-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe2c4bf29bf4e89790b3117470dea2c20b59932772483082c468b990d45fb947"}, + {file = "msgspec-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e87ecfa9795ee5214861eab8326b0e75475c2e68a384002aa135ea2a27d909"}, + {file = "msgspec-0.19.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3c4ec642689da44618f68c90855a10edbc6ac3ff7c1d94395446c65a776e712a"}, + {file = "msgspec-0.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2719647625320b60e2d8af06b35f5b12d4f4d281db30a15a1df22adb2295f633"}, + {file = "msgspec-0.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:695b832d0091edd86eeb535cd39e45f3919f48d997685f7ac31acb15e0a2ed90"}, + {file = "msgspec-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa77046904db764b0462036bc63ef71f02b75b8f72e9c9dd4c447d6da1ed8f8e"}, + {file = "msgspec-0.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:047cfa8675eb3bad68722cfe95c60e7afabf84d1bd8938979dd2b92e9e4a9551"}, + {file = "msgspec-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e78f46ff39a427e10b4a61614a2777ad69559cc8d603a7c05681f5a595ea98f7"}, + {file = "msgspec-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c7adf191e4bd3be0e9231c3b6dc20cf1199ada2af523885efc2ed218eafd011"}, + {file = "msgspec-0.19.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f04cad4385e20be7c7176bb8ae3dca54a08e9756cfc97bcdb4f18560c3042063"}, + {file = "msgspec-0.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45c8fb410670b3b7eb884d44a75589377c341ec1392b778311acdbfa55187716"}, + {file = "msgspec-0.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:70eaef4934b87193a27d802534dc466778ad8d536e296ae2f9334e182ac27b6c"}, + {file = "msgspec-0.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f98bd8962ad549c27d63845b50af3f53ec468b6318400c9f1adfe8b092d7b62f"}, + {file = "msgspec-0.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:43bbb237feab761b815ed9df43b266114203f53596f9b6e6f00ebd79d178cdf2"}, + {file = "msgspec-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cfc033c02c3e0aec52b71710d7f84cb3ca5eb407ab2ad23d75631153fdb1f12"}, + {file = "msgspec-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d911c442571605e17658ca2b416fd8579c5050ac9adc5e00c2cb3126c97f73bc"}, + {file = "msgspec-0.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:757b501fa57e24896cf40a831442b19a864f56d253679f34f260dcb002524a6c"}, + {file = "msgspec-0.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5f0f65f29b45e2816d8bded36e6b837a4bf5fb60ec4bc3c625fa2c6da4124537"}, + {file = "msgspec-0.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:067f0de1c33cfa0b6a8206562efdf6be5985b988b53dd244a8e06f993f27c8c0"}, + {file = "msgspec-0.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f12d30dd6266557aaaf0aa0f9580a9a8fbeadfa83699c487713e355ec5f0bd86"}, + {file = "msgspec-0.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82b2c42c1b9ebc89e822e7e13bbe9d17ede0c23c187469fdd9505afd5a481314"}, + {file = "msgspec-0.19.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19746b50be214a54239aab822964f2ac81e38b0055cca94808359d779338c10e"}, + {file = "msgspec-0.19.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60ef4bdb0ec8e4ad62e5a1f95230c08efb1f64f32e6e8dd2ced685bcc73858b5"}, + {file = "msgspec-0.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac7f7c377c122b649f7545810c6cd1b47586e3aa3059126ce3516ac7ccc6a6a9"}, + {file = "msgspec-0.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5bc1472223a643f5ffb5bf46ccdede7f9795078194f14edd69e3aab7020d327"}, + {file = "msgspec-0.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:317050bc0f7739cb30d257ff09152ca309bf5a369854bbf1e57dffc310c1f20f"}, + {file = "msgspec-0.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15c1e86fff77184c20a2932cd9742bf33fe23125fa3fcf332df9ad2f7d483044"}, + {file = "msgspec-0.19.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3b5541b2b3294e5ffabe31a09d604e23a88533ace36ac288fa32a420aa38d229"}, + {file = "msgspec-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f5c043ace7962ef188746e83b99faaa9e3e699ab857ca3f367b309c8e2c6b12"}, + {file = "msgspec-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca06aa08e39bf57e39a258e1996474f84d0dd8130d486c00bec26d797b8c5446"}, + {file = "msgspec-0.19.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e695dad6897896e9384cf5e2687d9ae9feaef50e802f93602d35458e20d1fb19"}, + {file = "msgspec-0.19.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3be5c02e1fee57b54130316a08fe40cca53af92999a302a6054cd451700ea7db"}, + {file = "msgspec-0.19.0-cp39-cp39-win_amd64.whl", hash = "sha256:0684573a821be3c749912acf5848cce78af4298345cb2d7a8b8948a0a5a27cfe"}, + {file = "msgspec-0.19.0.tar.gz", hash = "sha256:604037e7cd475345848116e89c553aa9a233259733ab51986ac924ab1b976f8e"}, +] + +[package.extras] +dev = ["attrs", "coverage", "eval-type-backport ; python_version < \"3.10\"", "furo", "ipython", "msgpack", "mypy", "pre-commit", "pyright", "pytest", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "tomli ; python_version < \"3.11\"", "tomli_w"] +doc = ["furo", "ipython", "sphinx", "sphinx-copybutton", "sphinx-design"] +test = ["attrs", "eval-type-backport ; python_version < \"3.10\"", "msgpack", "pytest", "pyyaml", "tomli ; python_version < \"3.11\"", "tomli_w"] +toml = ["tomli ; python_version < \"3.11\"", "tomli_w"] +yaml = ["pyyaml"] + [[package]] name = "mypy" -version = "1.16.1" +version = "1.18.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "mypy-1.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4f0fed1022a63c6fec38f28b7fc77fca47fd490445c69d0a66266c59dd0b88a"}, - {file = "mypy-1.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86042bbf9f5a05ea000d3203cf87aa9d0ccf9a01f73f71c58979eb9249f46d72"}, - {file = "mypy-1.16.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea7469ee5902c95542bea7ee545f7006508c65c8c54b06dc2c92676ce526f3ea"}, - {file = "mypy-1.16.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352025753ef6a83cb9e7f2427319bb7875d1fdda8439d1e23de12ab164179574"}, - {file = "mypy-1.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff9fa5b16e4c1364eb89a4d16bcda9987f05d39604e1e6c35378a2987c1aac2d"}, - {file = "mypy-1.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:1256688e284632382f8f3b9e2123df7d279f603c561f099758e66dd6ed4e8bd6"}, - {file = "mypy-1.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:472e4e4c100062488ec643f6162dd0d5208e33e2f34544e1fc931372e806c0cc"}, - {file = "mypy-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea16e2a7d2714277e349e24d19a782a663a34ed60864006e8585db08f8ad1782"}, - {file = "mypy-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08e850ea22adc4d8a4014651575567b0318ede51e8e9fe7a68f25391af699507"}, - {file = "mypy-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d76a63a42619bfb90122889b903519149879ddbf2ba4251834727944c8baca"}, - {file = "mypy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c7ce0662b6b9dc8f4ed86eb7a5d505ee3298c04b40ec13b30e572c0e5ae17c4"}, - {file = "mypy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:211287e98e05352a2e1d4e8759c5490925a7c784ddc84207f4714822f8cf99b6"}, - {file = "mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d"}, - {file = "mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9"}, - {file = "mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79"}, - {file = "mypy-1.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ab5eca37b50188163fa7c1b73c685ac66c4e9bdee4a85c9adac0e91d8895e15"}, - {file = "mypy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb6229b2c9086247e21a83c309754b9058b438704ad2f6807f0d8227f6ebdd"}, - {file = "mypy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:1f0435cf920e287ff68af3d10a118a73f212deb2ce087619eb4e648116d1fe9b"}, - {file = "mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438"}, - {file = "mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536"}, - {file = "mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f"}, - {file = "mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359"}, - {file = "mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be"}, - {file = "mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee"}, - {file = "mypy-1.16.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7fc688329af6a287567f45cc1cefb9db662defeb14625213a5b7da6e692e2069"}, - {file = "mypy-1.16.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e198ab3f55924c03ead626ff424cad1732d0d391478dfbf7bb97b34602395da"}, - {file = "mypy-1.16.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09aa4f91ada245f0a45dbc47e548fd94e0dd5a8433e0114917dc3b526912a30c"}, - {file = "mypy-1.16.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13c7cd5b1cb2909aa318a90fd1b7e31f17c50b242953e7dd58345b2a814f6383"}, - {file = "mypy-1.16.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:58e07fb958bc5d752a280da0e890c538f1515b79a65757bbdc54252ba82e0b40"}, - {file = "mypy-1.16.1-cp39-cp39-win_amd64.whl", hash = "sha256:f895078594d918f93337a505f8add9bd654d1a24962b4c6ed9390e12531eb31b"}, - {file = "mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37"}, - {file = "mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab"}, + {file = "mypy-1.18.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2761b6ae22a2b7d8e8607fb9b81ae90bc2e95ec033fd18fa35e807af6c657763"}, + {file = "mypy-1.18.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5b10e3ea7f2eec23b4929a3fabf84505da21034a4f4b9613cda81217e92b74f3"}, + {file = "mypy-1.18.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:261fbfced030228bc0f724d5d92f9ae69f46373bdfd0e04a533852677a11dbea"}, + {file = "mypy-1.18.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4dc6b34a1c6875e6286e27d836a35c0d04e8316beac4482d42cfea7ed2527df8"}, + {file = "mypy-1.18.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1cabb353194d2942522546501c0ff75c4043bf3b63069cb43274491b44b773c9"}, + {file = "mypy-1.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:738b171690c8e47c93569635ee8ec633d2cdb06062f510b853b5f233020569a9"}, + {file = "mypy-1.18.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6c903857b3e28fc5489e54042684a9509039ea0aedb2a619469438b544ae1961"}, + {file = "mypy-1.18.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2a0c8392c19934c2b6c65566d3a6abdc6b51d5da7f5d04e43f0eb627d6eeee65"}, + {file = "mypy-1.18.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f85eb7efa2ec73ef63fc23b8af89c2fe5bf2a4ad985ed2d3ff28c1bb3c317c92"}, + {file = "mypy-1.18.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82ace21edf7ba8af31c3308a61dc72df30500f4dbb26f99ac36b4b80809d7e94"}, + {file = "mypy-1.18.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a2dfd53dfe632f1ef5d161150a4b1f2d0786746ae02950eb3ac108964ee2975a"}, + {file = "mypy-1.18.1-cp311-cp311-win_amd64.whl", hash = "sha256:320f0ad4205eefcb0e1a72428dde0ad10be73da9f92e793c36228e8ebf7298c0"}, + {file = "mypy-1.18.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:502cde8896be8e638588b90fdcb4c5d5b8c1b004dfc63fd5604a973547367bb9"}, + {file = "mypy-1.18.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7509549b5e41be279afc1228242d0e397f1af2919a8f2877ad542b199dc4083e"}, + {file = "mypy-1.18.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5956ecaabb3a245e3f34100172abca1507be687377fe20e24d6a7557e07080e2"}, + {file = "mypy-1.18.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8750ceb014a96c9890421c83f0db53b0f3b8633e2864c6f9bc0a8e93951ed18d"}, + {file = "mypy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fb89ea08ff41adf59476b235293679a6eb53a7b9400f6256272fb6029bec3ce5"}, + {file = "mypy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:2657654d82fcd2a87e02a33e0d23001789a554059bbf34702d623dafe353eabf"}, + {file = "mypy-1.18.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d70d2b5baf9b9a20bc9c730015615ae3243ef47fb4a58ad7b31c3e0a59b5ef1f"}, + {file = "mypy-1.18.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8367e33506300f07a43012fc546402f283c3f8bcff1dc338636affb710154ce"}, + {file = "mypy-1.18.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:913f668ec50c3337b89df22f973c1c8f0b29ee9e290a8b7fe01cc1ef7446d42e"}, + {file = "mypy-1.18.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a0e70b87eb27b33209fa4792b051c6947976f6ab829daa83819df5f58330c71"}, + {file = "mypy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c378d946e8a60be6b6ede48c878d145546fb42aad61df998c056ec151bf6c746"}, + {file = "mypy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:2cd2c1e0f3a7465f22731987fff6fc427e3dcbb4ca5f7db5bbeaff2ff9a31f6d"}, + {file = "mypy-1.18.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ba24603c58e34dd5b096dfad792d87b304fc6470cbb1c22fd64e7ebd17edcc61"}, + {file = "mypy-1.18.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ed36662fb92ae4cb3cacc682ec6656208f323bbc23d4b08d091eecfc0863d4b5"}, + {file = "mypy-1.18.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:040ecc95e026f71a9ad7956fea2724466602b561e6a25c2e5584160d3833aaa8"}, + {file = "mypy-1.18.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:937e3ed86cb731276706e46e03512547e43c391a13f363e08d0fee49a7c38a0d"}, + {file = "mypy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1f95cc4f01c0f1701ca3b0355792bccec13ecb2ec1c469e5b85a6ef398398b1d"}, + {file = "mypy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:e4f16c0019d48941220ac60b893615be2f63afedaba6a0801bdcd041b96991ce"}, + {file = "mypy-1.18.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e37763af63a8018308859bc83d9063c501a5820ec5bd4a19f0a2ac0d1c25c061"}, + {file = "mypy-1.18.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:51531b6e94f34b8bd8b01dee52bbcee80daeac45e69ec5c36e25bce51cbc46e6"}, + {file = "mypy-1.18.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbfdea20e90e9c5476cea80cfd264d8e197c6ef2c58483931db2eefb2f7adc14"}, + {file = "mypy-1.18.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99f272c9b59f5826fffa439575716276d19cbf9654abc84a2ba2d77090a0ba14"}, + {file = "mypy-1.18.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8c05a7f8c00300a52f3a4fcc95a185e99bf944d7e851ff141bae8dcf6dcfeac4"}, + {file = "mypy-1.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:2fbcecbe5cf213ba294aa8c0b8c104400bf7bb64db82fb34fe32a205da4b3531"}, + {file = "mypy-1.18.1-py3-none-any.whl", hash = "sha256:b76a4de66a0ac01da1be14ecc8ae88ddea33b8380284a9e3eae39d57ebcbe26e"}, + {file = "mypy-1.18.1.tar.gz", hash = "sha256:9e988c64ad3ac5987f43f5154f884747faf62141b7f842e87465b45299eea5a9"}, ] [package.dependencies] @@ -1005,4 +1064,4 @@ openai = ["openai"] [metadata] lock-version = "2.1" python-versions = ">=3.12,<4" -content-hash = "d1259663992e8eb4f5c06cb9a59bd459dfd68b8d8b7a3cfddad4d76ab67b2c5a" +content-hash = "8b1dd15da8afb0d483831751e52e9ea00dfdd9528e99ea6ebcc42ebca2ca4c24" diff --git a/pyproject.toml b/pyproject.toml index d252d51..406fd46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ requires-python = ">=3.12" dependencies = [ "docopt-ng (>=0.9,<0.10)", "jinja2 (>=3.1.5,<4)", + "msgspec (>=0.19.0,<0.20.0)", "prettytable (>=3.15.1,<4)", "xdg-base-dirs (>=6.0.2,<7)", "yaspin (>=3.1.0,<4)", @@ -39,7 +40,7 @@ python = ">=3.12,<4" [tool.poetry.group.dev.dependencies] coverage = "^7.4.4" -mypy = "^1.2.1" +mypy = "^1.18.1" poethepoet = "^0.25.0" pytest = "^8.2.0" pytest-asyncio = "^1.0.0" @@ -88,6 +89,7 @@ show_missing = true [tool.mypy] disable_error_code = "import-untyped" +enable_error_code = "exhaustive-match" [tool.pytest.ini_options] log_level = "DEBUG" diff --git a/src/git_draft/__init__.py b/src/git_draft/__init__.py index 171476d..00dac26 100644 --- a/src/git_draft/__init__.py +++ b/src/git_draft/__init__.py @@ -2,15 +2,15 @@ import logging -from .bots import Action, Bot, Goal -from .toolbox import Toolbox +from .bots import ActionSummary, Bot, Goal, UserFeedback, Worktree __all__ = [ - "Action", + "ActionSummary", "Bot", "Goal", - "Toolbox", + "UserFeedback", + "Worktree", ] logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/src/git_draft/__main__.py b/src/git_draft/__main__.py index b4f3e2b..08dd443 100644 --- a/src/git_draft/__main__.py +++ b/src/git_draft/__main__.py @@ -11,16 +11,11 @@ import sys from .bots import load_bot -from .common import ( - PROGRAM, - Config, - Progress, - UnreachableError, - ensure_state_home, -) +from .common import PROGRAM, Config, UnreachableError, ensure_state_home from .drafter import Drafter, DraftMergeStrategy from .editor import open_editor from .git import Repo +from .progress import Progress from .prompt import ( PromptMetadata, TemplatedPrompt, @@ -41,6 +36,11 @@ def new_parser() -> optparse.OptionParser: parser.disable_interspersed_args() + parser.add_option( + "--batch", + help="disable interactive feedback", + action="store_true", + ) parser.add_option( "--log-path", help="show log path and exit", @@ -167,7 +167,11 @@ async def run() -> None: # noqa: PLR0912 PLR0915 datefmt="%m-%d %H:%M", ) - progress = Progress.dynamic() if sys.stdin.isatty() else Progress.static() + progress = ( + Progress.dynamic() + if sys.stdin.isatty() and not opts.batch + else Progress.static() + ) repo = Repo.enclosing(Path(opts.root) if opts.root else Path.cwd()) drafter = Drafter.create(repo, Store.persistent(), progress) match getattr(opts, "command", "new"): @@ -200,10 +204,10 @@ async def run() -> None: # noqa: PLR0912 PLR0915 accept = Accept(opts.accept or 0) await drafter.generate_draft( - prompt, - bot, - prompt_transform=open_editor if editable else None, + prompt=prompt, + bot=bot, merge_strategy=accept.merge_strategy(), + prompt_transform=open_editor if editable else None, ) if accept == Accept.MERGE_THEN_QUIT: # TODO: Refuse to quit on pending question? @@ -235,7 +239,8 @@ def main() -> None: asyncio.run(run()) except Exception as err: _logger.exception("Program failed.") - print(f"Error: {err}", file=sys.stderr) + message = str(err) or "See logs for more information" + print(f"Error: {message}", file=sys.stderr) sys.exit(1) diff --git a/src/git_draft/bots/__init__.py b/src/git_draft/bots/__init__.py index 9952ffd..9945887 100644 --- a/src/git_draft/bots/__init__.py +++ b/src/git_draft/bots/__init__.py @@ -1,22 +1,19 @@ -"""Bot interfaces and built-in implementations - -* https://aider.chat/docs/leaderboards/ -""" +"""Bot interfaces and built-in implementations""" import importlib import os import sys from ..common import BotConfig, reindent -from ..toolbox import Toolbox -from .common import Action, Bot, Goal +from .common import ActionSummary, Bot, Goal, UserFeedback, Worktree __all__ = [ - "Action", + "ActionSummary", "Bot", "Goal", - "Toolbox", + "UserFeedback", + "Worktree", ] diff --git a/src/git_draft/bots/common.py b/src/git_draft/bots/common.py index 5e3484e..546d066 100644 --- a/src/git_draft/bots/common.py +++ b/src/git_draft/bots/common.py @@ -2,11 +2,13 @@ from __future__ import annotations +from collections.abc import Sequence +import contextlib import dataclasses -from pathlib import Path +from pathlib import Path, PurePosixPath +from typing import Protocol from ..common import ensure_state_home, qualified_class_name -from ..toolbox import Toolbox @dataclasses.dataclass(frozen=True) @@ -17,8 +19,51 @@ class Goal: # TODO: Add timeout. +class Worktree(Protocol): + """File operations + + Implementations may not be thread-safe. Concurrent operations should be + serialized by the caller. + """ + + def list_files(self) -> Sequence[PurePosixPath]: + """List all files""" + + def read_file(self, path: PurePosixPath) -> str | None: + """Get a file's contents""" + + def write_file(self, path: PurePosixPath, contents: str) -> None: + """Update a file's contents""" + + def delete_file(self, path: PurePosixPath) -> None: + """Remove a file""" + + def rename_file( + self, src_path: PurePosixPath, dst_path: PurePosixPath + ) -> None: + """Move a file""" + + def edit_files(self) -> contextlib.AbstractContextManager[Path]: + """Return path to a temporary folder with editable copies of all files + + Any updates are synced back to the work tree when the context exits. + Other operations should not be performed concurrently as they may be + stale or lost. + """ + + +class UserFeedback(Protocol): + """User interactions""" + + def notify(self, update: str) -> None: + """Report progress to the user""" + + def ask(self, question: str) -> str: + """Request additional information from the user""" + + @dataclasses.dataclass -class Action: +class ActionSummary: """End-of-action statistics This dataclass is not frozen to allow bot implementors to populate its @@ -28,7 +73,6 @@ class Action: title: str | None = None request_count: int | None = None token_count: int | None = None - question: str | None = None def increment_request_count(self, n: int = 1, init: bool = False) -> None: self._increment("request_count", n, init) @@ -66,6 +110,8 @@ def state_folder_path(cls, ensure_exists: bool = False) -> Path: path.mkdir(parents=True, exist_ok=True) return path - async def act(self, goal: Goal, toolbox: Toolbox) -> Action: - """Runs the bot, striving to achieve the goal with the given toolbox""" + async def act( + self, goal: Goal, tree: Worktree, feedback: UserFeedback + ) -> ActionSummary: + """Runs the bot, striving to achieve the goal""" raise NotImplementedError() diff --git a/src/git_draft/bots/openai.py b/src/git_draft/bots/openai.py index fb03ee5..3beb12c 100644 --- a/src/git_draft/bots/openai.py +++ b/src/git_draft/bots/openai.py @@ -21,7 +21,7 @@ import openai from ..common import JSONObject, UnreachableError, config_string, reindent -from .common import Action, Bot, Goal, Toolbox +from .common import ActionSummary, Bot, Goal, UserFeedback, Worktree _logger = logging.getLogger(__name__) @@ -173,11 +173,11 @@ def params(self) -> Sequence[openai.types.chat.ChatCompletionToolParam]: class _ToolHandler[V]: - def __init__(self, toolbox: Toolbox) -> None: - self._toolbox = toolbox - self.question: str | None = None + def __init__(self, tree: Worktree, feedback: UserFeedback) -> None: + self._tree = tree + self._feedback = feedback - def _on_ask_user(self) -> V: + def _on_ask_user(self, response: str) -> V: raise NotImplementedError() def _on_read_file(self, path: PurePosixPath, contents: str | None) -> V: @@ -202,28 +202,28 @@ def handle_function(self, function: Any) -> V: _logger.info("Requested function: %s", function) match function.name: case "ask_user": - assert not self.question - self.question = inputs["question"] - return self._on_ask_user() + question = inputs["question"] + response = self._feedback.ask(question) + return self._on_ask_user(response) case "read_file": path = PurePosixPath(inputs["path"]) - return self._on_read_file(path, self._toolbox.read_file(path)) + return self._on_read_file(path, self._tree.read_file(path)) case "write_file": path = PurePosixPath(inputs["path"]) contents = inputs["contents"] - self._toolbox.write_file(path, contents) + self._tree.write_file(path, contents) return self._on_write_file(path) case "delete_file": path = PurePosixPath(inputs["path"]) - self._toolbox.delete_file(path) + self._tree.delete_file(path) return self._on_delete_file(path) case "rename_file": src_path = PurePosixPath(inputs["src_path"]) dst_path = PurePosixPath(inputs["dst_path"]) - self._toolbox.rename_file(src_path, dst_path) + self._tree.rename_file(src_path, dst_path) return self._on_rename_file(src_path, dst_path) case "list_files": - paths = self._toolbox.list_files() + paths = self._tree.list_files() return self._on_list_files(paths) case _ as name: raise UnreachableError(f"Unexpected function: {name}") @@ -234,9 +234,11 @@ def __init__(self, client: openai.OpenAI, model: str) -> None: self._client = client self._model = model - async def act(self, goal: Goal, toolbox: Toolbox) -> Action: + async def act( + self, goal: Goal, tree: Worktree, feedback: UserFeedback + ) -> ActionSummary: tools = _ToolsFactory(strict=False).params() - tool_handler = _CompletionsToolHandler(toolbox) + tool_handler = _CompletionsToolHandler(tree, feedback) messages: list[openai.types.chat.ChatCompletionMessageParam] = [ {"role": "system", "content": reindent(_INSTRUCTIONS)}, @@ -264,15 +266,12 @@ async def act(self, goal: Goal, toolbox: Toolbox) -> Action: if done: break - return Action( - request_count=request_count, - question=tool_handler.question, - ) + return ActionSummary(request_count=request_count) class _CompletionsToolHandler(_ToolHandler[str | None]): - def _on_ask_user(self) -> None: - return None + def _on_ask_user(self, response: str) -> str: + return response def _on_read_file(self, path: PurePosixPath, contents: str | None) -> str: if contents is None: @@ -318,7 +317,9 @@ def _load_assistant_id(self) -> str: f.write(assistant_id) return assistant_id - async def act(self, goal: Goal, toolbox: Toolbox) -> Action: + async def act( + self, goal: Goal, tree: Worktree, feedback: UserFeedback + ) -> ActionSummary: assistant_id = self._load_assistant_id() thread = self._client.beta.threads.create() @@ -330,11 +331,11 @@ async def act(self, goal: Goal, toolbox: Toolbox) -> Action: # We intentionally do not count the two requests above, to focus on # "data requests" only. - action = Action(request_count=0, token_count=0) + action = ActionSummary(request_count=0, token_count=0) with self._client.beta.threads.runs.stream( thread_id=thread.id, assistant_id=assistant_id, - event_handler=_EventHandler(self._client, toolbox, action), + event_handler=_EventHandler(self._client, tree, feedback, action), ) as stream: stream.until_done() return action @@ -342,16 +343,23 @@ async def act(self, goal: Goal, toolbox: Toolbox) -> Action: class _EventHandler(openai.AssistantEventHandler): def __init__( - self, client: openai.Client, toolbox: Toolbox, action: Action + self, + client: openai.Client, + tree: Worktree, + feedback: UserFeedback, + action: ActionSummary, ) -> None: super().__init__() self._client = client - self._toolbox = toolbox + self._tree = tree + self._feedback = feedback self._action = action self._action.increment_request_count() def _clone(self) -> Self: - return self.__class__(self._client, self._toolbox, self._action) + return self.__class__( + self._client, self._tree, self._feedback, self._action + ) @override def on_event(self, event: openai.types.beta.AssistantStreamEvent) -> None: @@ -377,11 +385,8 @@ def on_run_step_done( def _handle_action(self, _run_id: str, data: Any) -> None: tool_outputs = list[Any]() for tool in data.required_action.submit_tool_outputs.tool_calls: - handler = _ThreadToolHandler(self._toolbox, tool.id) + handler = _ThreadToolHandler(self._tree, self._feedback, tool.id) tool_outputs.append(handler.handle_function(tool.function)) - if handler.question: - assert not self._action.question - self._action.question = handler.question run = self.current_run assert run, "No ongoing run" @@ -400,15 +405,17 @@ class _ToolOutput(TypedDict): class _ThreadToolHandler(_ToolHandler[_ToolOutput]): - def __init__(self, toolbox: Toolbox, call_id: str) -> None: - super().__init__(toolbox) + def __init__( + self, tree: Worktree, feedback: UserFeedback, call_id: str + ) -> None: + super().__init__(tree, feedback) self._call_id = call_id def _wrap(self, output: str) -> _ToolOutput: return _ToolOutput(tool_call_id=self._call_id, output=output) - def _on_ask_user(self) -> _ToolOutput: - return self._wrap("OK") + def _on_ask_user(self, response: str) -> _ToolOutput: + return self._wrap(response) def _on_read_file( self, _path: PurePosixPath, contents: str | None diff --git a/src/git_draft/common.py b/src/git_draft/common.py index 828a596..1603281 100644 --- a/src/git_draft/common.py +++ b/src/git_draft/common.py @@ -2,9 +2,9 @@ from __future__ import annotations -from collections.abc import Iterator, Mapping, Sequence -import contextlib +from collections.abc import Mapping, Sequence import dataclasses +import datetime import itertools import logging import os @@ -16,8 +16,6 @@ import prettytable import xdg_base_dirs -import yaspin -import yaspin.core _logger = logging.getLogger(__name__) @@ -110,6 +108,10 @@ def qualified_class_name(cls: type) -> str: return f"{cls.__module__}.{name}" if cls.__module__ else name +def now() -> datetime.datetime: + return datetime.datetime.now().astimezone() + + class Table: """Pretty-printable table""" @@ -137,101 +139,3 @@ def from_cursor(cls, cursor: sqlite3.Cursor) -> Self: table = prettytable.from_db_cursor(cursor, **cls._kwargs) assert table return cls(table) - - -def _tagged(text: str, /, **kwargs) -> str: - tags = [f"{key}={val}" for key, val in kwargs.items() if val is not None] - return f"{text} [{', '.join(tags)}]" if tags else text - - -class Progress: - """Progress feedback interface""" - - def report(self, text: str, **tags) -> None: # pragma: no cover - raise NotImplementedError() - - def spinner( - self, text: str, **tags - ) -> contextlib.AbstractContextManager[ - ProgressSpinner - ]: # pragma: no cover - raise NotImplementedError() - - @staticmethod - def dynamic() -> Progress: - """Progress suitable for interactive terminals""" - return _DynamicProgress() - - @staticmethod - def static() -> Progress: - """Progress suitable for pipes, etc.""" - return _StaticProgress() - - -class ProgressSpinner: - """Operation progress tracker""" - - @contextlib.contextmanager - def hidden(self) -> Iterator[None]: - yield None - - def update(self, text: str, **tags) -> None: # pragma: no cover - raise NotImplementedError() - - -class _DynamicProgress(Progress): - def __init__(self) -> None: - self._spinner: _DynamicProgressSpinner | None = None - - def report(self, text: str, **tags) -> None: - message = f"☞ {_tagged(text, **tags)}" - if self._spinner: - self._spinner.yaspin.write(message) - else: - print(message) # noqa - - @contextlib.contextmanager - def spinner(self, text: str, **tags) -> Iterator[ProgressSpinner]: - assert not self._spinner - with yaspin.yaspin(text=_tagged(text, **tags)) as spinner: - self._spinner = _DynamicProgressSpinner(spinner) - try: - yield self._spinner - except Exception: - self._spinner.yaspin.fail("✗") - raise - else: - self._spinner.yaspin.ok("✓") - finally: - self._spinner = None - - -class _DynamicProgressSpinner(ProgressSpinner): - def __init__(self, yaspin: yaspin.core.Yaspin) -> None: - self.yaspin = yaspin - - @contextlib.contextmanager - def hidden(self) -> Iterator[None]: - with self.yaspin.hidden(): - yield - - def update(self, text: str, **tags) -> None: - self.yaspin.text = _tagged(text, **tags) - - -class _StaticProgress(Progress): - def report(self, text: str, **tags) -> None: - print(_tagged(text, **tags)) # noqa - - @contextlib.contextmanager - def spinner(self, text: str, **tags) -> Iterator[ProgressSpinner]: - self.report(text, **tags) - yield _StaticProgressSpinner(self) - - -class _StaticProgressSpinner(ProgressSpinner): - def __init__(self, progress: _StaticProgress) -> None: - self._progress = progress - - def update(self, text: str, **tags) -> None: - self._progress.report(text, **tags) diff --git a/src/git_draft/drafter.py b/src/git_draft/drafter.py index 04dd0b7..8c76fa2 100644 --- a/src/git_draft/drafter.py +++ b/src/git_draft/drafter.py @@ -2,23 +2,29 @@ from __future__ import annotations -from collections.abc import Callable, Sequence +from collections.abc import Callable import dataclasses -from datetime import datetime, timedelta -import json +from datetime import timedelta import logging -from pathlib import PurePosixPath import re import textwrap import time from typing import Literal -from .bots import Action, Bot, Goal -from .common import JSONObject, Progress, qualified_class_name, reindent +from .bots import ActionSummary, Bot, Goal +from .common import qualified_class_name, reindent +from .events import ( + Event, + EventConsumer, + event_encoder, + feedback_events, + worktree_events, +) from .git import SHA, Repo +from .progress import Progress, ProgressFeedback from .prompt import TemplatedPrompt from .store import Store, sql -from .toolbox import RepoToolbox, ToolVisitor +from .worktrees import GitWorktree _logger = logging.getLogger(__name__) @@ -31,7 +37,7 @@ class Draft: folio: Folio seqno: int is_noop: bool - has_question: bool + has_pending_question: bool walltime: timedelta token_count: int | None @@ -110,12 +116,12 @@ async def generate_draft( with self._progress.spinner("Preparing prompt...") as spinner: # Handle prompt templating and editing. We do this first in case # this fails, to avoid creating unnecessary branches. - toolbox, dirty = RepoToolbox.for_working_dir(self._repo) + tree, dirty = GitWorktree.for_working_dir(self._repo) with spinner.hidden(): prompt_contents = self._prepare_prompt( prompt, prompt_transform, - toolbox, + tree, ) template_name = ( prompt.name if isinstance(prompt, TemplatedPrompt) else None @@ -141,17 +147,15 @@ async def generate_draft( ) # Run the bot to generate the change. - operation_recorder = _OperationRecorder(self._progress) + event_recorder = _EventRecorder(self._progress) with self._progress.spinner("Running bot...") as spinner: + feedback = spinner.feedback() change = await self._generate_change( bot, Goal(prompt_contents), - toolbox.with_visitors( - [operation_recorder], - ), + tree.with_event_consumer(event_recorder), + feedback, ) - if change.action.question: - self._progress.report("Requested progress.") spinner.update( "Completed bot run.", runtime=round(change.walltime.total_seconds(), 1), @@ -163,14 +167,14 @@ async def generate_draft( folio=folio, seqno=seqno, is_noop=change.is_noop, - has_question=change.action.question is not None, + has_pending_question=feedback.pending_question is not None, walltime=change.walltime, token_count=change.action.token_count, ) with self._progress.spinner("Creating draft commit...") as spinner: if dirty: parent_commit_rev = self._commit_tree( - toolbox.tree_sha(), "HEAD", "sync(prompt)" + tree.sha(), "HEAD", "sync(prompt)" ) _logger.info( "Created sync commit. [sha=%s]", parent_commit_rev @@ -186,26 +190,27 @@ async def generate_draft( # when other files are edited. with self._store.cursor() as cursor: cursor.execute( - sql("add-action"), + sql("add-action-summary"), { "prompt_id": prompt_id, "bot_class": qualified_class_name(bot.__class__), "walltime_seconds": change.walltime.total_seconds(), "request_count": change.action.request_count, "token_count": change.action.token_count, - "question": change.action.question, + "pending_question": feedback.pending_question, }, ) + encoder = event_encoder() cursor.executemany( - sql("add-operation"), + sql("add-action-event"), [ { "prompt_id": prompt_id, - "tool": o.tool, - "details": json.dumps(o.details), - "started_at": o.start, + "occurred_at": e.at, + "class": e.__class__.__name__, + "data": encoder.encode(e), } - for o in operation_recorder.operations + for e in event_recorder.events ], ) spinner.update("Created draft commit.", ref=draft.ref) @@ -329,10 +334,10 @@ def _prepare_prompt( self, prompt: str | TemplatedPrompt, prompt_transform: Callable[[str], str] | None, - toolbox: RepoToolbox, + tree: GitWorktree, ) -> str: if isinstance(prompt, TemplatedPrompt): - contents = prompt.render(toolbox) + contents = prompt.render(tree) else: contents = prompt if prompt_transform: @@ -345,19 +350,20 @@ async def _generate_change( self, bot: Bot, goal: Goal, - toolbox: RepoToolbox, + tree: GitWorktree, + feedback: ProgressFeedback, ) -> _Change: - old_tree_sha = toolbox.tree_sha() + old_tree_sha = tree.sha() start_time = time.perf_counter() _logger.debug("Running bot... [bot=%s]", bot) - action = await bot.act(goal, toolbox) + action = await bot.act(goal, tree, feedback) _logger.info("Completed bot action. [action=%s]", action) end_time = time.perf_counter() walltime = end_time - start_time title = action.title or _default_title(goal.prompt) - new_tree_sha = toolbox.tree_sha() + new_tree_sha = tree.sha() return _Change( walltime=timedelta(seconds=walltime), action=action, @@ -421,14 +427,14 @@ def latest_draft_prompt(self) -> str | None: class _Change: """A bot-generated draft, may be a no-op""" - action: Action + action: ActionSummary walltime: timedelta commit_message: str tree_sha: SHA is_noop: bool -class _OperationRecorder(ToolVisitor): +class _EventRecorder(EventConsumer): """Visitor which keeps track of which operations have been performed This is useful to store a summary of each change in our database for later @@ -436,57 +442,34 @@ class _OperationRecorder(ToolVisitor): """ def __init__(self, progress: Progress) -> None: - self.operations = list[_Operation]() + self.events = list[Event]() self._progress = progress - def on_list_files(self, paths: Sequence[PurePosixPath]) -> None: - count = len(paths) - self._progress.report("Listed available files.", count=count) - self._record("list_files", count=count) - - def on_read_file(self, path: PurePosixPath, contents: str | None) -> None: - size = -1 if contents is None else len(contents) - self._progress.report(f"Read {path}.", length=size) - self._record("read_file", path=str(path), size=size) - - def on_write_file(self, path: PurePosixPath, contents: str) -> None: - size = len(contents) - self._progress.report(f"Wrote {path}.", length=size) - self._record("write_file", path=str(path), size=size) - - def on_delete_file(self, path: PurePosixPath) -> None: - self._progress.report(f"Deleted {path}.") - self._record("delete_file", path=str(path)) - - def on_rename_file( - self, - src_path: PurePosixPath, - dst_path: PurePosixPath, - ) -> None: - self._progress.report(f"Renamed {src_path} to {dst_path}.") - self._record( - "rename_file", - src_path=str(src_path), - dst_path=str(dst_path), - ) - - def on_expose_files(self) -> None: - self._progress.report("Exposed files.") - self._record("expose_files") - - def _record(self, tool: str, **kwargs) -> None: - op = _Operation(tool=tool, details=kwargs, start=datetime.now()) - _logger.debug("Recorded operation. [op=%s]", op) - self.operations.append(op) - - -@dataclasses.dataclass(frozen=True) -class _Operation: - """Tool usage record""" - - tool: str - details: JSONObject - start: datetime + def on_event(self, event: Event) -> None: + self.events.append(event) + match event: + case worktree_events.ListFiles(_, paths): + self._progress.report("Listed files.", count=len(paths)) + case worktree_events.ReadFile(_, path, contents): + size = -1 if contents is None else len(contents) + self._progress.report(f"Read {path}.", length=size) + case worktree_events.WriteFile(_, path, contents): + size = len(contents) + self._progress.report(f"Wrote {path}.", length=size) + case worktree_events.DeleteFile(_, path): + self._progress.report(f"Deleted {path}.") + case worktree_events.RenameFile(_, src_path, dst_path): + self._progress.report(f"Renamed {src_path} to {dst_path}.") + case worktree_events.StartEditingFiles(_): + self._progress.report("Started editing files...") + case worktree_events.StopEditingFiles(_): + self._progress.report("Stopped editing files.") + case ( + feedback_events.NotifyUser(_, _) + | feedback_events.RequestUserGuidance(_, _) + | feedback_events.ReceiveUserGuidance(_, _) + ): + pass def _default_title(prompt: str) -> str: diff --git a/src/git_draft/events/__init__.py b/src/git_draft/events/__init__.py new file mode 100644 index 0000000..4b5b2d5 --- /dev/null +++ b/src/git_draft/events/__init__.py @@ -0,0 +1,65 @@ +"""Event package""" + +from pathlib import PurePosixPath +from typing import Any, Protocol + +import msgspec + +from . import feedback_events, worktree_events +from .common import events + + +__all__ = [ + "Event", + "EventConsumer", + "event_decoder", + "event_encoder", + "events", + "feedback_events", + "worktree_events", +] + + +type Event = ( + worktree_events.ListFiles + | worktree_events.ReadFile + | worktree_events.WriteFile + | worktree_events.DeleteFile + | worktree_events.RenameFile + | worktree_events.StartEditingFiles + | worktree_events.StopEditingFiles + | feedback_events.NotifyUser + | feedback_events.RequestUserGuidance + | feedback_events.ReceiveUserGuidance +) + + +class EventConsumer(Protocol): + """Interface for consuming events""" + + def on_event(self, event: Event) -> None: + pass + + +def event_encoder() -> msgspec.json.Encoder: + return msgspec.json.Encoder(enc_hook=_enc_hook) + + +def _enc_hook(obj: Any) -> Any: + assert isinstance(obj, PurePosixPath) + return str(obj) + + +def event_decoder() -> msgspec.json.Decoder: + """Returns a decoder for event instances + + It should be used as follows to get typed values: + + decoder.decode(data, type=events[class_name]) + """ + return msgspec.json.Decoder(dec_hook=_dec_hook) + + +def _dec_hook(tp: type, obj: Any) -> Any: + assert tp is PurePosixPath and isinstance(obj, str) + return PurePosixPath(obj) diff --git a/src/git_draft/events/common.py b/src/git_draft/events/common.py new file mode 100644 index 0000000..e376d8f --- /dev/null +++ b/src/git_draft/events/common.py @@ -0,0 +1,20 @@ +"""Common event utilities""" + +import datetime +import types +from typing import Any + +import msgspec + + +events = types.SimpleNamespace() + + +class EventStruct(msgspec.Struct, frozen=True): + """Base immutable structure for all event types""" + + at: datetime.datetime + + def __init_subclass__(cls, *args: Any, **kwargs) -> None: + super().__init_subclass__(*args, **kwargs) + setattr(events, cls.__name__, cls) diff --git a/src/git_draft/events/feedback_events.py b/src/git_draft/events/feedback_events.py new file mode 100644 index 0000000..2cf1247 --- /dev/null +++ b/src/git_draft/events/feedback_events.py @@ -0,0 +1,21 @@ +"""Event types related to user feedback interactions""" + +from .common import EventStruct + + +class NotifyUser(EventStruct, frozen=True): + """Generic user notification""" + + contents: str + + +class RequestUserGuidance(EventStruct, frozen=True): + """Additional information is requested from the user""" + + question: str + + +class ReceiveUserGuidance(EventStruct, frozen=True): + """Response provided by the user""" + + answer: str diff --git a/src/git_draft/events/worktree_events.py b/src/git_draft/events/worktree_events.py new file mode 100644 index 0000000..61486ef --- /dev/null +++ b/src/git_draft/events/worktree_events.py @@ -0,0 +1,47 @@ +"""Event types related to worktree file operations""" + +from collections.abc import Sequence +from pathlib import PurePosixPath + +from .common import EventStruct + + +class ListFiles(EventStruct, frozen=True): + """All files were listed""" + + paths: Sequence[PurePosixPath] + + +class ReadFile(EventStruct, frozen=True): + """A file was read""" + + path: PurePosixPath + contents: str | None + + +class WriteFile(EventStruct, frozen=True): + """A file was written""" + + path: PurePosixPath + contents: str + + +class DeleteFile(EventStruct, frozen=True): + """A file was deleted""" + + path: PurePosixPath + + +class RenameFile(EventStruct, frozen=True): + """A file was renamed""" + + src_path: PurePosixPath + dst_path: PurePosixPath + + +class StartEditingFiles(EventStruct, frozen=True): + """A temporary editable copy of all files was opened""" + + +class StopEditingFiles(EventStruct, frozen=True): + """The editable copy was closed""" diff --git a/src/git_draft/progress.py b/src/git_draft/progress.py new file mode 100644 index 0000000..da469ef --- /dev/null +++ b/src/git_draft/progress.py @@ -0,0 +1,173 @@ +"""End user progress reporting""" + +from __future__ import annotations + +from collections.abc import Iterator +import contextlib +from typing import override + +import yaspin.core + +from .bots import UserFeedback +from .common import reindent + + +class Progress: + """Progress feedback interface""" + + def report(self, text: str, **tags) -> None: # pragma: no cover + raise NotImplementedError() + + def spinner( + self, text: str, **tags + ) -> contextlib.AbstractContextManager[ + ProgressSpinner + ]: # pragma: no cover + raise NotImplementedError() + + @staticmethod + def dynamic() -> Progress: + """Progress suitable for interactive terminals""" + return _DynamicProgress() + + @staticmethod + def static() -> Progress: + """Progress suitable for pipes, etc.""" + return _StaticProgress() + + +class ProgressSpinner: + """Operation progress tracker""" + + @contextlib.contextmanager + def hidden(self) -> Iterator[None]: + yield None + + def update(self, text: str, **tags) -> None: # pragma: no cover + raise NotImplementedError() + + def feedback(self) -> ProgressFeedback: + raise NotImplementedError() + + +class ProgressFeedback(UserFeedback): + """User feedback interface""" + + def __init__(self) -> None: + self.pending_question: str | None = None + + +_offline_answer = reindent(""" + I'm unable to provide feedback at this time. Perform any final changes and + await further instructions. +""") + + +class _DynamicProgress(Progress): + def __init__(self) -> None: + self._spinner: _DynamicProgressSpinner | None = None + + def report(self, text: str, **tags) -> None: + message = f"☞ {_tagged(text, **tags)}" + if self._spinner: + self._spinner.yaspin.write(message) + else: + print(message) # noqa + + @contextlib.contextmanager + def spinner(self, text: str, **tags) -> Iterator[ProgressSpinner]: + assert not self._spinner + with yaspin.yaspin(text=_tagged(text, **tags)) as spinner: + self._spinner = _DynamicProgressSpinner(spinner) + try: + yield self._spinner + except Exception: + self._spinner.yaspin.fail("✗") + raise + else: + self._spinner.yaspin.ok("✓") + finally: + self._spinner = None + + +class _DynamicProgressSpinner(ProgressSpinner): + def __init__(self, yaspin: yaspin.core.Yaspin) -> None: + self.yaspin = yaspin + + @contextlib.contextmanager + def hidden(self) -> Iterator[None]: + with self.yaspin.hidden(): + yield + + def update(self, text: str, **tags) -> None: + self.yaspin.text = _tagged(text, **tags) + + def feedback(self) -> ProgressFeedback: + return _DynamicProgressFeedback(self) + + +class _DynamicProgressFeedback(ProgressFeedback): + def __init__(self, spinner: _DynamicProgressSpinner) -> None: + super().__init__() + self._spinner = spinner + + @override + def notify(self, update: str) -> None: + self._spinner.update(update) + + @override + def ask(self, question: str) -> str: + assert not self.pending_question + with self._spinner.hidden(): + answer = input(question) + if answer: + return answer + self.pending_question = question + return _offline_answer + + +class _StaticProgress(Progress): + def report(self, text: str, **tags) -> None: + print(_tagged(text, **tags)) # noqa + + @contextlib.contextmanager + def spinner(self, text: str, **tags) -> Iterator[ProgressSpinner]: + self.report(text, **tags) + yield _StaticProgressSpinner(self) + + +class _StaticProgressSpinner(ProgressSpinner): + def __init__(self, progress: _StaticProgress) -> None: + self._progress = progress + + def update(self, text: str, **tags) -> None: + self._progress.report(text, **tags) + + def feedback(self) -> ProgressFeedback: + return _StaticProgressFeedback(self._progress) + + +class _StaticProgressFeedback(ProgressFeedback): + def __init__(self, progress: _StaticProgress) -> None: + super().__init__() + self._progress = progress + + @override + def notify(self, update: str) -> None: + self._progress.report(update) + + @override + def ask(self, question: str) -> str: + assert not self.pending_question + self._progress.report(f"Feedback requested: {question}") + self.pending_question = question + return _offline_answer + + +def _tagged(text: str, /, **kwargs) -> str: + if kwargs: + tags = [ + f"{key}={val}" for key, val in kwargs.items() if val is not None + ] + text = f"{text} [{', '.join(tags)}]" if tags else text + return reindent(text) diff --git a/src/git_draft/prompt.py b/src/git_draft/prompt.py index 96e76a6..177af23 100644 --- a/src/git_draft/prompt.py +++ b/src/git_draft/prompt.py @@ -14,8 +14,9 @@ import docopt import jinja2 +from .bots import Worktree from .common import Config, Table, package_root -from .toolbox import NoopToolbox, Toolbox +from .worktrees import EmptyWorktree _extension = "jinja" @@ -36,8 +37,8 @@ def public(cls, name: PromptName, args: Sequence[str]) -> Self: _check_public_template_name(name) return cls(name, tuple(args)) - def render(self, toolbox: Toolbox) -> str: - prompt = _load_prompt(_jinja_environment(), self.name, toolbox) + def render(self, worktree: Worktree) -> str: + prompt = _load_prompt(_jinja_environment(), self.name, worktree) return prompt.render(self.args) @@ -85,7 +86,7 @@ def _load_layouts() -> Mapping[str, str]: class _Context(TypedDict): prompt: Mapping[str, str] program: PromptName - toolbox: Toolbox + worktree: Worktree @dataclasses.dataclass(frozen=True) @@ -181,13 +182,13 @@ def _template_path(template: jinja2.Template) -> Path: def _load_prompt( - env: jinja2.Environment, name: PromptName, toolbox: Toolbox + env: jinja2.Environment, name: PromptName, worktree: Worktree ) -> _Prompt: rel_path = Path(f"{name}.{_extension}") assert env.loader, "No loader in environment" template = env.loader.load(env, str(rel_path)) context: _Context = dict( - program=name, prompt=_load_layouts(), toolbox=toolbox + program=name, prompt=_load_layouts(), worktree=worktree ) try: module = template.make_module(vars=cast(dict, context)) @@ -203,7 +204,7 @@ def _load_prompt( def find_prompt_metadata(name: PromptName) -> PromptMetadata | None: try: - prompt = _load_prompt(_jinja_environment(), name, NoopToolbox()) + prompt = _load_prompt(_jinja_environment(), name, EmptyWorktree()) except jinja2.TemplateNotFound: return None return prompt.metadata @@ -211,14 +212,14 @@ def find_prompt_metadata(name: PromptName) -> PromptMetadata | None: def templates_table(*, include_local: bool = True) -> Table: env = _jinja_environment(include_local=include_local) - toolbox = NoopToolbox() + worktree = EmptyWorktree() table = Table.empty() table.data.field_names = ["name", "local", "description"] for rel_path in env.list_templates(extensions=[_extension]): if any(p.startswith(".") for p in rel_path.split(os.sep)): continue name, _ext = os.path.splitext(rel_path) - prompt = _load_prompt(env, name, toolbox) + prompt = _load_prompt(env, name, worktree) metadata = prompt.metadata local = "y" if metadata.is_local() else "n" table.data.add_row([name, local, metadata.description or ""]) diff --git a/src/git_draft/prompts/.MACROS.jinja b/src/git_draft/prompts/.MACROS.jinja index b67517c..d797b4e 100644 --- a/src/git_draft/prompts/.MACROS.jinja +++ b/src/git_draft/prompts/.MACROS.jinja @@ -1,5 +1,5 @@ {% macro file_list() %} -{% set paths = toolbox.list_files() %} +{% set paths = worktree.list_files() %} {% if paths %} For reference, here is the list of all currently available files in the repository: diff --git a/src/git_draft/queries/add-action-event.sql b/src/git_draft/queries/add-action-event.sql new file mode 100644 index 0000000..5bfc990 --- /dev/null +++ b/src/git_draft/queries/add-action-event.sql @@ -0,0 +1,2 @@ +insert into action_events (prompt_id, occurred_at, class, data) + values (:prompt_id, :occurred_at, :class, :data) diff --git a/src/git_draft/queries/add-action.sql b/src/git_draft/queries/add-action-summary.sql similarity index 71% rename from src/git_draft/queries/add-action.sql rename to src/git_draft/queries/add-action-summary.sql index e054afd..4968b52 100644 --- a/src/git_draft/queries/add-action.sql +++ b/src/git_draft/queries/add-action-summary.sql @@ -1,14 +1,14 @@ -insert into actions ( +insert into action_summaries ( prompt_id, bot_class, walltime_seconds, request_count, token_count, - question) + pending_question) values ( :prompt_id, :bot_class, :walltime_seconds, :request_count, :token_count, - :question); + :pending_question); diff --git a/src/git_draft/queries/add-operation.sql b/src/git_draft/queries/add-operation.sql deleted file mode 100644 index 76c275b..0000000 --- a/src/git_draft/queries/add-operation.sql +++ /dev/null @@ -1,2 +0,0 @@ -insert into operations (prompt_id, tool, details, started_at) - values (:prompt_id, :tool, :details, :started_at) diff --git a/src/git_draft/queries/create-tables.sql b/src/git_draft/queries/create-tables.sql index 2c5e562..a8e330d 100644 --- a/src/git_draft/queries/create-tables.sql +++ b/src/git_draft/queries/create-tables.sql @@ -19,22 +19,22 @@ create table if not exists prompts ( create unique index if not exists prompts_by_folio_seqno on prompts (folio_id, seqno); -create table if not exists actions ( +create table if not exists action_summaries ( prompt_id integer primary key, created_at timestamp default current_timestamp, bot_class text not null, walltime_seconds real not null, request_count int, token_count int, - question text, + pending_question text, foreign key (prompt_id) references prompts (id) on delete cascade ) without rowid; -create table if not exists operations ( +create table if not exists action_events ( id integer primary key, prompt_id integer not null, - tool text not null, - details text not null, - started_at timestamp not null, - foreign key (prompt_id) references actions (prompt_id) on delete cascade + occurred_at timestamp default current_timestamp, + class text not null, + data text not null, + foreign key (prompt_id) references action_summaries (prompt_id) on delete cascade ); diff --git a/src/git_draft/queries/get-latest-folio-prompt.sql b/src/git_draft/queries/get-latest-folio-prompt.sql index 82149b1..75baf74 100644 --- a/src/git_draft/queries/get-latest-folio-prompt.sql +++ b/src/git_draft/queries/get-latest-folio-prompt.sql @@ -1,7 +1,7 @@ -select p.contents, a.question +select p.contents, s.pending_question from prompts as p join folios as f on p.folio_id = f.id - left join actions as a on p.id = a.prompt_id + left join action_summaries as s on p.id = s.prompt_id where f.id = :folio_id order by p.id desc limit 1; diff --git a/src/git_draft/queries/list-folio-prompts.sql b/src/git_draft/queries/list-folio-prompts.sql index 1a65510..47144b7 100644 --- a/src/git_draft/queries/list-folio-prompts.sql +++ b/src/git_draft/queries/list-folio-prompts.sql @@ -1,13 +1,13 @@ select datetime(min(p.created_at), 'localtime') as created, coalesce(min(template), '-') as template, - coalesce(min(a.bot_name), '-') as bot, - coalesce(round(sum(a.walltime_seconds), 1), 0) as walltime, - count(o.id) as ops + coalesce(min(s.bot_name), '-') as bot, + coalesce(round(sum(s.walltime_seconds), 1), 0) as walltime, + count(e.id) as ops from prompts as p join folios as f on p.folio_id = f.id - left join actions as a on p.id = a.prompt_id - left join operations as o on a.prompt_id = o.prompt_id + left join action_summaries as s on p.id = s.prompt_id + left join action_events as e on s.prompt_id = e.prompt_id where f.id = :folio_id group by p.id order by created desc; diff --git a/src/git_draft/queries/list-folios.sql b/src/git_draft/queries/list-folios.sql index 5c5643b..f625404 100644 --- a/src/git_draft/queries/list-folios.sql +++ b/src/git_draft/queries/list-folios.sql @@ -3,10 +3,10 @@ select f.id as id, min(f.origin_branch) as origin, count(p.id) as prompts, - sum(a.token_count) as tokens + sum(s.token_count) as tokens from folios as f join prompts as p on f.id = p.folio_id - join actions as a on p.id = a.prompt_id + join action_summaries as s on p.id = s.prompt_id where f.repo_uuid = :repo_uuid group by f.id order by created desc; diff --git a/src/git_draft/store.py b/src/git_draft/store.py index 07030b0..792fc1b 100644 --- a/src/git_draft/store.py +++ b/src/git_draft/store.py @@ -19,7 +19,7 @@ class Store: """Lightweight sqlite wrapper""" - _name = "v4.sqlite3" + _name = "v6.sqlite3" def __init__(self, conn: sqlite3.Connection) -> None: self._connection = conn diff --git a/src/git_draft/toolbox.py b/src/git_draft/worktrees.py similarity index 60% rename from src/git_draft/toolbox.py rename to src/git_draft/worktrees.py index bc95d86..5272e99 100644 --- a/src/git_draft/toolbox.py +++ b/src/git_draft/worktrees.py @@ -1,199 +1,92 @@ -"""Functionality available to bots""" - -from __future__ import annotations +"""Worktree implementations""" import collections -from collections.abc import Callable, Iterator, Sequence +from collections.abc import Iterator, Sequence import contextlib import dataclasses import logging from pathlib import Path, PurePosixPath import tempfile -from typing import Protocol, Self, override +from typing import Self, override -from .common import UnreachableError +from .bots import Worktree +from .common import UnreachableError, now +from .events import Event, EventConsumer, worktree_events from .git import SHA, GitError, Repo, null_delimited _logger = logging.getLogger(__name__) -class Toolbox: - """File-system intermediary +class EmptyWorktree(Worktree): + """No-op read-only work tree - Note that toolbox implementations may not be thread-safe. Concurrent - operations should be serialized by the caller. + This tree is used when gathering template metadata. """ - # TODO: Something similar to https://aider.chat/docs/repomap.html, - # including inferring the most important files, and allowing returning - # signature-only versions. - - # TODO: Support a diff-based edit method. - # https://gist.github.com/noporpoise/16e731849eb1231e86d78f9dfeca3abc - - # TODO: Add user feedback tool here. This will make it possible to request - # feedback more than once during a bot action, which leads to a better - # experience when used interactively. - - def __init__(self, visitors: Sequence[ToolVisitor] | None = None) -> None: - self._visitors = visitors or [] - - def _dispatch(self, effect: Callable[[ToolVisitor], None]) -> None: - for visitor in self._visitors: - effect(visitor) - + @override def list_files(self) -> Sequence[PurePosixPath]: - paths = self._list() - self._dispatch(lambda v: v.on_list_files(paths)) - return paths - - def read_file(self, path: PurePosixPath) -> str | None: - try: - contents = self._read(path) - except FileNotFoundError: - contents = None - self._dispatch(lambda v: v.on_read_file(path, contents)) - return contents - - def write_file(self, path: PurePosixPath, contents: str) -> None: - self._dispatch(lambda v: v.on_write_file(path, contents)) - return self._write(path, contents) - - def delete_file(self, path: PurePosixPath) -> None: - self._dispatch(lambda v: v.on_delete_file(path)) - self._delete(path) - - def rename_file( - self, - src_path: PurePosixPath, - dst_path: PurePosixPath, - ) -> None: - """Rename a single file""" - self._dispatch(lambda v: v.on_rename_file(src_path, dst_path)) - self._rename(src_path, dst_path) - - def expose_files( - self, - ) -> contextlib.AbstractContextManager[Path]: # pragma: no cover - """Creates a temporary folder with editable copies of all files - - All updates are synced back afterwards. Other operations should not be - performed concurrently as they may be stale or lost. - """ - self._dispatch(lambda v: v.on_expose_files()) - # TODO: Expose updated files to hook? - return self._expose() - - def _list(self) -> Sequence[PurePosixPath]: # pragma: no cover - raise NotImplementedError() - - def _read(self, path: PurePosixPath) -> str: # pragma: no cover - raise NotImplementedError() - - def _write( - self, path: PurePosixPath, contents: str - ) -> None: # pragma: no cover - raise NotImplementedError() - - def _delete(self, path: PurePosixPath) -> None: # pragma: no cover - raise NotImplementedError() - - def _rename( - self, src_path: PurePosixPath, dst_path: PurePosixPath - ) -> None: - # We can provide a default implementation here. - contents = self._read(src_path) - self._write(dst_path, contents) - self._delete(src_path) - - def _expose( - self, - ) -> contextlib.AbstractContextManager[Path]: # pragma: no cover - raise NotImplementedError() - - -class ToolVisitor(Protocol): - """Tool usage hook""" - - def on_list_files( - self, paths: Sequence[PurePosixPath] - ) -> None: ... # pragma: no cover - - def on_read_file( - self, path: PurePosixPath, contents: str | None - ) -> None: ... # pragma: no cover - - def on_write_file( - self, path: PurePosixPath, contents: str - ) -> None: ... # pragma: no cover - - def on_delete_file( - self, path: PurePosixPath - ) -> None: ... # pragma: no cover - - def on_rename_file( - self, - src_path: PurePosixPath, - dst_path: PurePosixPath, - ) -> None: ... # pragma: no cover - - def on_expose_files(self) -> None: ... # pragma: no cover - - -class NoopToolbox(Toolbox): - """No-op read-only toolbox""" + return [] @override - def _list(self) -> Sequence[PurePosixPath]: - return [] + def read_file(self, path: PurePosixPath) -> str | None: + raise RuntimeError() @override - def _read(self, _path: PurePosixPath) -> str: + def write_file(self, path: PurePosixPath, contents: str) -> None: raise RuntimeError() @override - def _write(self, _path: PurePosixPath, _contents: str) -> None: + def delete_file(self, path: PurePosixPath) -> None: raise RuntimeError() @override - def _delete(self, _path: PurePosixPath) -> None: + def rename_file( + self, src_path: PurePosixPath, dst_path: PurePosixPath + ) -> None: raise RuntimeError() @override - def _expose(self) -> contextlib.AbstractContextManager[Path]: + def edit_files(self) -> contextlib.AbstractContextManager[Path]: raise RuntimeError() -class RepoToolbox(Toolbox): - """Git-repo backed toolbox implementation +class GitWorktree(Worktree): + """Git-backed worktree implementation - All files are directly read from and written to an standalone tree. This + All files are directly read from and written to a standalone tree. This allows concurrent editing without interference with the working directory or index. - This toolbox is not thread-safe. + This implementation is not thread-safe. """ + # TODO: Something similar to https://aider.chat/docs/repomap.html, + # including inferring the most important files, and allowing returning + # signature-only versions. + + # TODO: Support a diff-based edit method. + # https://gist.github.com/noporpoise/16e731849eb1231e86d78f9dfeca3abc + def __init__( self, repo: Repo, start_rev: SHA, - visitors: Sequence[ToolVisitor] | None = None, + event_consumer: EventConsumer | None = None, ) -> None: - super().__init__(visitors) call = repo.git("rev-parse", "--verify", f"{start_rev}^{{tree}}") - self._tree_sha = call.stdout - self._tree_updates = list[_TreeUpdate]() + self._sha = call.stdout + self._updates = list[_Update]() self._repo = repo + self._event_consumer = event_consumer @classmethod def for_working_dir(cls, repo: Repo) -> tuple[Self, bool]: index_tree_sha = repo.git("write-tree").stdout - toolbox = cls(repo, index_tree_sha) - toolbox._sync_updates() # Apply any changes from the working directory + tree = cls(repo, index_tree_sha) + tree._sync_updates() # Apply any changes from the working directory head_tree_sha = repo.git("rev-parse", "HEAD^{tree}").stdout - return toolbox, toolbox.tree_sha() != head_tree_sha + return tree, tree.sha() != head_tree_sha def _sync_updates(self, *, worktree_path: Path | None = None) -> None: repo = self._repo @@ -215,31 +108,83 @@ def ls_files(*args: str) -> Iterator[str]: worktree_path / path_str if worktree_path else Path(path_str), ) - def with_visitors(self, visitors: Sequence[ToolVisitor]) -> Self: - return self.__class__(self._repo, self.tree_sha(), visitors) + def with_event_consumer(self, event_consumer: EventConsumer) -> Self: + return self.__class__(self._repo, self.sha(), event_consumer) - def tree_sha(self) -> SHA: - if updates := self._tree_updates: - self._tree_sha = _update_tree(self._tree_sha, updates, self._repo) + def sha(self) -> SHA: + if updates := self._updates: + self._sha = _update_tree(self._sha, updates, self._repo) updates.clear() - return self._tree_sha + return self._sha + + def _dispatch(self, event: Event) -> None: + if consumer := self._event_consumer: + consumer.on_event(event) + + @override + def list_files(self) -> Sequence[PurePosixPath]: + paths = self._list() + self._dispatch(worktree_events.ListFiles(now(), paths)) + return paths + + @override + def read_file(self, path: PurePosixPath) -> str | None: + try: + contents = self._read(path) + except FileNotFoundError: + contents = None + self._dispatch(worktree_events.ReadFile(now(), path, contents)) + return contents + + @override + def write_file(self, path: PurePosixPath, contents: str) -> None: + self._dispatch(worktree_events.WriteFile(now(), path, contents)) + return self._write(path, contents) + + @override + def delete_file(self, path: PurePosixPath) -> None: + self._dispatch(worktree_events.DeleteFile(now(), path)) + self._delete(path) + + @override + def rename_file( + self, + src_path: PurePosixPath, + dst_path: PurePosixPath, + ) -> None: + """Rename a single file""" + self._dispatch(worktree_events.RenameFile(now(), src_path, dst_path)) + contents = self._read(src_path) + self._write(dst_path, contents) + self._delete(src_path) @override + @contextlib.contextmanager + def edit_files(self) -> Iterator[Path]: + """Creates a temporary folder with editable copies of all files + + All updates are synced back afterwards. Other operations should not be + performed concurrently as they may be stale or lost. + """ + self._dispatch(worktree_events.StartEditingFiles(now())) + with self._edit() as path: + yield path + # TODO: Expose updated files to hook? + self._dispatch(worktree_events.StopEditingFiles(now())) + def _list(self) -> Sequence[PurePosixPath]: - call = self._repo.git("ls-tree", "-rz", "--name-only", self.tree_sha()) + call = self._repo.git("ls-tree", "-rz", "--name-only", self.sha()) return [PurePosixPath(p) for p in null_delimited(call.stdout)] - @override def _read(self, path: PurePosixPath) -> str: try: - return self._repo.git("show", f"{self.tree_sha()}:{path}").stdout + return self._repo.git("show", f"{self.sha()}:{path}").stdout except GitError as exc: msg = str(exc) if "does not exist in" in msg or "exists on disk, but not" in msg: raise FileNotFoundError(f"{path} does not exist") raise - @override def _write(self, path: PurePosixPath, contents: str) -> None: # Update the index without touching the worktree. # https://stackoverflow.com/a/25352119 @@ -258,17 +203,15 @@ def _write_from_disk( str(path), str(contents_path), ).stdout - self._tree_updates.append(_WriteBlob(path, blob_sha)) + self._updates.append(_WriteBlob(path, blob_sha)) - @override def _delete(self, path: PurePosixPath) -> None: - self._tree_updates.append(_DeleteBlob(path)) + self._updates.append(_DeleteBlob(path)) - @override @contextlib.contextmanager - def _expose(self) -> Iterator[Path]: + def _edit(self) -> Iterator[Path]: commit_sha = self._repo.git( - "commit-tree", "-m", "draft! worktree", self.tree_sha() + "commit-tree", "-m", "draft! worktree", self.sha() ).stdout with tempfile.TemporaryDirectory() as path_str: try: @@ -282,22 +225,22 @@ def _expose(self) -> Iterator[Path]: self._repo.git("worktree", "remove", "-f", path_str) -class _TreeUpdate: +class _Update: """Generic tree update""" @dataclasses.dataclass(frozen=True) -class _WriteBlob(_TreeUpdate): +class _WriteBlob(_Update): path: PurePosixPath blob_sha: SHA @dataclasses.dataclass(frozen=True) -class _DeleteBlob(_TreeUpdate): +class _DeleteBlob(_Update): path: PurePosixPath -def _update_tree(sha: SHA, updates: Sequence[_TreeUpdate], repo: Repo) -> SHA: +def _update_tree(sha: SHA, updates: Sequence[_Update], repo: Repo) -> SHA: if not updates: return sha diff --git a/tests/git_draft/bots/common_test.py b/tests/git_draft/bots/common_test.py index 69ac56c..6cf1825 100644 --- a/tests/git_draft/bots/common_test.py +++ b/tests/git_draft/bots/common_test.py @@ -12,21 +12,21 @@ def test_state_folder_path(self) -> None: assert "bots.common_test.FakeBot" in str(FakeBot.state_folder_path()) -class TestAction: +class TestActionSummary: def test_increment_noinit(self) -> None: - action = sut.Action() + action = sut.ActionSummary() with pytest.raises(ValueError): action.increment_request_count() def test_increment_request_count(self) -> None: - action = sut.Action() + action = sut.ActionSummary() action.increment_request_count(init=True) assert action.request_count == 1 action.increment_request_count() assert action.request_count == 2 def test_increment_token_count(self) -> None: - action = sut.Action() + action = sut.ActionSummary() action.increment_token_count(5, init=True) action.increment_token_count(3) assert action.token_count == 8 diff --git a/tests/git_draft/drafter_test.py b/tests/git_draft/drafter_test.py index 0d7c5b4..9b1dea0 100644 --- a/tests/git_draft/drafter_test.py +++ b/tests/git_draft/drafter_test.py @@ -4,10 +4,10 @@ import pytest -from git_draft.bots import Action, Bot, Goal, Toolbox -from git_draft.common import Progress +from git_draft.bots import ActionSummary, Bot, Goal, UserFeedback, Worktree import git_draft.drafter as sut from git_draft.git import SHA, GitError, Repo +from git_draft.progress import Progress from git_draft.store import Store from .conftest import RepoFS @@ -29,15 +29,17 @@ def noop(cls) -> Self: def prompt(cls) -> Self: return cls({"PROMPT": lambda goal: goal.prompt}) - async def act(self, goal: Goal, toolbox: Toolbox) -> Action: + async def act( + self, goal: Goal, tree: Worktree, _feedback: UserFeedback + ) -> ActionSummary: for key, value in self._contents.items(): path = PurePosixPath(key) if value is None: - toolbox.delete_file(path) + tree.delete_file(path) else: contents = value if isinstance(value, str) else value(goal) - toolbox.write_file(path, contents) - return Action() + tree.write_file(path, contents) + return ActionSummary() class TestDrafter: diff --git a/tests/git_draft/prompt_test.py b/tests/git_draft/prompt_test.py index 048a7bd..37614f6 100644 --- a/tests/git_draft/prompt_test.py +++ b/tests/git_draft/prompt_test.py @@ -1,7 +1,7 @@ import pytest import git_draft.prompt as sut -from git_draft.toolbox import RepoToolbox +from git_draft.worktrees import GitWorktree class TestCheckPublicTemplateName: @@ -18,17 +18,17 @@ def test_raises(self, name: str) -> None: class TestTemplatedPrompt: @pytest.fixture(autouse=True) def setup(self, repo) -> None: - self._toolbox = RepoToolbox(repo, "HEAD") + self._tree = GitWorktree(repo, "HEAD") def test_ok(self) -> None: prompt = sut.TemplatedPrompt("add-test", ("--symbol=foo",)) - rendered = prompt.render(self._toolbox) + rendered = prompt.render(self._tree) assert "foo" in rendered def test_missing_variable(self) -> None: prompt = sut.TemplatedPrompt("add-test") with pytest.raises(ValueError): - prompt.render(self._toolbox) + prompt.render(self._tree) class TestFindPromptMetadata: diff --git a/tests/git_draft/toolbox_test.py b/tests/git_draft/work_trees_test.py similarity index 52% rename from tests/git_draft/toolbox_test.py rename to tests/git_draft/work_trees_test.py index 57e1630..be46889 100644 --- a/tests/git_draft/toolbox_test.py +++ b/tests/git_draft/work_trees_test.py @@ -3,7 +3,7 @@ import pytest from git_draft.git import Repo -import git_draft.toolbox as sut +import git_draft.worktrees as sut from .conftest import RepoFS @@ -11,7 +11,7 @@ PPP = PurePosixPath -class TestRepoToolbox: +class TestRepoWorktree: @pytest.fixture(autouse=True) def setup(self, repo: Repo, repo_fs: RepoFS) -> None: self._repo = repo @@ -22,10 +22,10 @@ def test_list_files(self) -> None: self._fs.write("f2", "b") self._fs.flush() - toolbox = sut.RepoToolbox(self._repo, "HEAD") + tree = sut.GitWorktree(self._repo, "HEAD") self._fs.delete("f2") self._fs.write("f3", "c") - assert set(str(p) for p in toolbox.list_files()) == {"f1", "f2"} + assert set(str(p) for p in tree.list_files()) == {"f1", "f2"} def test_read_file(self) -> None: self._fs.write("f1", "a") @@ -34,10 +34,10 @@ def test_read_file(self) -> None: self._fs.flush() self._fs.write("f2", "b") - toolbox = sut.RepoToolbox(self._repo, sha) - assert toolbox.read_file(PPP("f1")) == "a" - assert toolbox.read_file(PPP("f2")) is None - assert toolbox.read_file(PPP("f3")) is None + tree = sut.GitWorktree(self._repo, sha) + assert tree.read_file(PPP("f1")) == "a" + assert tree.read_file(PPP("f2")) is None + assert tree.read_file(PPP("f3")) is None def test_write_file(self) -> None: self._fs.write("f1", "a") @@ -46,11 +46,11 @@ def test_write_file(self) -> None: self._fs.write("f1", "aa") self._fs.flush() - toolbox = sut.RepoToolbox(self._repo, sha) - toolbox.write_file(PPP("f1"), "aaa") - toolbox.write_file(PPP("f3"), "c") - assert toolbox.read_file(PPP("f1")) == "aaa" - assert toolbox.read_file(PPP("f3")) == "c" + tree = sut.GitWorktree(self._repo, sha) + tree.write_file(PPP("f1"), "aaa") + tree.write_file(PPP("f3"), "c") + assert tree.read_file(PPP("f1")) == "aaa" + assert tree.read_file(PPP("f3")) == "c" assert self._fs.read("f1") == "aa" assert self._fs.read("f3") is None @@ -62,21 +62,21 @@ def test_for_working_dir_dirty(self) -> None: self._fs.write("f1", "aa") self._fs.delete("f2") - toolbox, dirty = sut.RepoToolbox.for_working_dir(self._repo) + tree, dirty = sut.GitWorktree.for_working_dir(self._repo) assert dirty - assert toolbox.read_file(PPP("f1")) == "aa" - assert toolbox.read_file(PPP("f2")) is None - assert toolbox.read_file(PPP("f3")) == "c" + assert tree.read_file(PPP("f1")) == "aa" + assert tree.read_file(PPP("f2")) is None + assert tree.read_file(PPP("f3")) == "c" - def test_expose_files(self) -> None: + def test_edit_files(self) -> None: self._fs.write("f1", "a") self._fs.write("f2", "b") self._fs.flush() - toolbox = sut.RepoToolbox(self._repo, "HEAD") - toolbox.delete_file(PPP("f1")) - toolbox.write_file(PPP("f3"), "c") + tree = sut.GitWorktree(self._repo, "HEAD") + tree.delete_file(PPP("f1")) + tree.write_file(PPP("f3"), "c") - with toolbox.expose_files() as path: + with tree.edit_files() as path: assert {".git", "f2", "f3"} == set(c.name for c in path.iterdir()) with open(path / "f2", "w") as w: w.write("bb") @@ -84,11 +84,11 @@ def test_expose_files(self) -> None: w.write("d") (path / "f3").unlink() - # Before sync, toolbox does not have changes. - assert toolbox.read_file(PPP("f2")) == "b" - assert toolbox.read_file(PPP("f3")) == "c" + # Before sync, tree does not have changes. + assert tree.read_file(PPP("f2")) == "b" + assert tree.read_file(PPP("f3")) == "c" - # After sync, toolbox has changes propagated. - assert toolbox.read_file(PPP("f2")) == "bb" - assert toolbox.read_file(PPP("f3")) is None - assert toolbox.read_file(PPP("f4")) == "d" + # After sync, tree has changes propagated. + assert tree.read_file(PPP("f2")) == "bb" + assert tree.read_file(PPP("f3")) is None + assert tree.read_file(PPP("f4")) == "d"