From 642e5f2fc8a0b2752c4c157c6effdef15787b036 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 2 Jun 2026 20:28:54 -0400 Subject: [PATCH 01/31] build: add fastapi/uvicorn/sse-starlette, drop direct watchfiles --- pyproject.toml | 4 +- uv.lock | 446 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 447 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d58c1f7c..1e8fa6eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,9 @@ requires-python = ">=3.11" dependencies = [ "Pillow>=10.0", "hachoir>=3.3", - "watchfiles>=0.22", + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "sse-starlette>=2.1", ] [project.scripts] diff --git a/uv.lock b/uv.lock index 312b2576..8c2c301f 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,24 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.13.0" @@ -15,13 +33,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + [[package]] name = "codecity" source = { editable = "." } dependencies = [ + { name = "fastapi" }, { name = "hachoir" }, { name = "pillow" }, - { name = "watchfiles" }, + { name = "sse-starlette" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.dev-dependencies] @@ -34,9 +66,11 @@ dev = [ [package.metadata] requires-dist = [ + { name = "fastapi", specifier = ">=0.115" }, { name = "hachoir", specifier = ">=3.3" }, { name = "pillow", specifier = ">=10.0" }, - { name = "watchfiles", specifier = ">=0.22" }, + { name = "sse-starlette", specifier = ">=2.1" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, ] [package.metadata.requires-dev] @@ -169,6 +203,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + [[package]] name = "hachoir" version = "3.3.0" @@ -178,6 +237,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/96/9570e6604114e9b2b2e1d90a89d01d10646b9cb6f54bba78f6de5fbbceb1/hachoir-3.3.0-py3-none-any.whl", hash = "sha256:9f721af67867d933383398b9605bc388db25f55dde1034d2bfde91f05e5d9910", size = 650439, upload-time = "2023-12-12T10:59:28.091Z" }, ] +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + [[package]] name = "idna" version = "3.16" @@ -310,6 +412,123 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -375,6 +594,96 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, +] + +[[package]] +name = "starlette" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -438,6 +747,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + [[package]] name = "watchfiles" version = "1.2.0" @@ -541,3 +924,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, ] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] From 4d7afb2d2c6fd4db7638915d71fc687136469709 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 2 Jun 2026 21:00:25 -0400 Subject: [PATCH 02/31] refactor: move scan/cache/clone/media under api/services (no behavior change) Creates api/services/ package and relocates the four data-layer modules; updates all intra-service relative imports, test import paths, and mock.patch string targets to the new api.services.* namespace. Co-Authored-By: Claude Sonnet 4.6 --- api/services/__init__.py | 0 api/{ => services}/cache.py | 0 api/{ => services}/clone.py | 0 api/{ => services}/media.py | 0 api/{ => services}/scan.py | 4 +- api/tests/conftest.py | 4 +- api/tests/test_cache.py | 10 ++--- api/tests/test_clone.py | 12 +++--- api/tests/test_media.py | 2 +- api/tests/test_scan.py | 86 ++++++++++++++++++------------------- 10 files changed, 59 insertions(+), 59 deletions(-) create mode 100644 api/services/__init__.py rename api/{ => services}/cache.py (100%) rename api/{ => services}/clone.py (100%) rename api/{ => services}/media.py (100%) rename api/{ => services}/scan.py (99%) diff --git a/api/services/__init__.py b/api/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/cache.py b/api/services/cache.py similarity index 100% rename from api/cache.py rename to api/services/cache.py diff --git a/api/clone.py b/api/services/clone.py similarity index 100% rename from api/clone.py rename to api/services/clone.py diff --git a/api/media.py b/api/services/media.py similarity index 100% rename from api/media.py rename to api/services/media.py diff --git a/api/scan.py b/api/services/scan.py similarity index 99% rename from api/scan.py rename to api/services/scan.py index 6c4adc16..9a3612db 100755 --- a/api/scan.py +++ b/api/services/scan.py @@ -25,7 +25,7 @@ from pathlib import Path from typing import Any, Callable, Iterator -from .env import env_bool +from api.env import env_bool from .cache import ( cache_load_files, cache_load_git_history, @@ -33,7 +33,7 @@ cache_save_git_history, ) from .media import probe_media_dims -from .types import ( +from api.types import ( BusynessThresholds, CommitEntry, DirNode, diff --git a/api/tests/conftest.py b/api/tests/conftest.py index df3f7b13..1bc26aa5 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -113,8 +113,8 @@ def redirect_cache_root( attribute directly — exactly what the existing _CacheRedirectMixin and CacheTestBase classes do. """ - from api import cache as cache_mod - from api import clone as clone_mod + from api.services import cache as cache_mod + from api.services import clone as clone_mod cache_dir = tmp_path / "cache" cache_dir.mkdir(parents=True, exist_ok=True) diff --git a/api/tests/test_cache.py b/api/tests/test_cache.py index 61a866ce..1e5a54f2 100644 --- a/api/tests/test_cache.py +++ b/api/tests/test_cache.py @@ -8,8 +8,8 @@ import pytest -from api import cache as cache_mod -from api.cache import _git_history_cache_path +from api.services import cache as cache_mod +from api.services.cache import _git_history_cache_path from api.types import CommitEntry @@ -279,8 +279,8 @@ def test_git_history_cache_drops_malformed_commits(self): ]) def test_git_history_rejects_old_version(self): - from api import cache as cache_mod - from api.cache import ( + from api.services import cache as cache_mod + from api.services.cache import ( _git_history_cache_path, cache_load_git_history, ) @@ -364,7 +364,7 @@ def test_manifest_rejects_when_git_history_version_changed(self): """A manifest cache file written under a prior _GIT_HISTORY_CACHE_VERSION must be dropped on load, because the composite version string changes when git-history bumps.""" - from api.cache import ( + from api.services.cache import ( _manifest_cache_path, cache_load_manifest, ) diff --git a/api/tests/test_clone.py b/api/tests/test_clone.py index 4277d047..4532ed84 100644 --- a/api/tests/test_clone.py +++ b/api/tests/test_clone.py @@ -16,8 +16,8 @@ from tempfile import TemporaryDirectory from unittest import mock -from api import clone as clone_mod -from api.clone import ( +from api.services import clone as clone_mod +from api.services.clone import ( BranchNotFoundError, CloneError, HostUnreachableError, @@ -203,7 +203,7 @@ def test_subclass_relationship(self) -> None: class RunGitEnvTests(unittest.TestCase): def test_run_git_disables_terminal_prompt(self) -> None: - from api import clone as clone_mod + from api.services import clone as clone_mod captured = {} @@ -444,7 +444,7 @@ def test_parse_clone_progress_line(): Receiving objects: 45% (123/273), 1.20 MiB | 2.50 MiB/s The parser extracts (stage, percent) when matchable, else None. """ - from api.clone import _parse_clone_progress_line + from api.services.clone import _parse_clone_progress_line cases = [ ("Receiving objects: 45% (123/273), 1.20 MiB | 2.50 MiB/s", ("receiving", 45)), @@ -465,7 +465,7 @@ def test_ensure_clone_emits_throttled_progress_via_callback(tmp_path): The invariant — callback fires with (stage, percent) tuples for each parseable line — is what matters.""" from unittest.mock import MagicMock - from api import clone as clone_mod + from api.services import clone as clone_mod # \r is git's in-place rewrite separator for progress lines; the # drain splits on either \r or \n so we use \n here for readability. @@ -522,7 +522,7 @@ def test_ensure_clone_emits_terminal_percent_of_each_stage(tmp_path): stage change AND at end-of-stream. Verify the user always sees 100% as the final payload for each stage.""" from unittest.mock import MagicMock - from api import clone as clone_mod + from api.services import clone as clone_mod # Many rapid progress lines per stage; the throttle will block # most of them. Without the flush fix, "Receiving 100%" gets diff --git a/api/tests/test_media.py b/api/tests/test_media.py index 2bf6ebf4..ce2173bc 100644 --- a/api/tests/test_media.py +++ b/api/tests/test_media.py @@ -9,7 +9,7 @@ from pathlib import Path from tempfile import TemporaryDirectory -from api.media import _parse_svg_length, probe_media_dims +from api.services.media import _parse_svg_length, probe_media_dims def _write_minimal_png(path: Path, width: int, height: int) -> None: diff --git a/api/tests/test_scan.py b/api/tests/test_scan.py index 50bb81d5..72d7706d 100644 --- a/api/tests/test_scan.py +++ b/api/tests/test_scan.py @@ -14,7 +14,7 @@ import pytest -from api.scan import ( +from api.services.scan import ( _annotate_same_day_totals, _compute_busyness, _extension, @@ -513,7 +513,7 @@ def test_scan_tree_rejects_non_git_root(self): """scan_tree must raise NotAGitRepoError on a non-git directory. Server enforces this at the HTTP boundary; the scanner check is defense-in-depth so direct callers fail fast.""" - from api.scan import NotAGitRepoError + from api.services.scan import NotAGitRepoError with tempfile.TemporaryDirectory() as td: Path(td, "a.txt").write_text("hello") with self.assertRaises(NotAGitRepoError): @@ -594,7 +594,7 @@ class LineCountCapTests(unittest.TestCase): an order-of-magnitude estimate is fine on huge files.""" def test_exact_count_below_threshold(self): - from api.scan import _line_count + from api.services.scan import _line_count with tempfile.NamedTemporaryFile("wb", delete=False) as fh: fh.write(b"line\n" * 1000) small = Path(fh.name) @@ -602,7 +602,7 @@ def test_exact_count_below_threshold(self): self.assertEqual(_line_count(small), 1000) def test_sample_extrapolation_above_threshold(self): - from api.scan import _line_count + from api.services.scan import _line_count # 6 MB file with one newline every 50 bytes -> ~125k lines true. with tempfile.NamedTemporaryFile("wb", delete=False) as fh: line = b"x" * 49 + b"\n" # 50 bytes, one newline @@ -643,18 +643,18 @@ class BuildAuthorsListTests(unittest.TestCase): """ def test_no_trailers_returns_primary_only(self): - from api.scan import _build_authors_list + from api.services.scan import _build_authors_list self.assertEqual(_build_authors_list("Alice", ""), ["Alice"]) def test_two_name_bearing_trailers(self): - from api.scan import _build_authors_list + from api.services.scan import _build_authors_list self.assertEqual( _build_authors_list("Alice", "Bob \x1fCarol "), ["Alice", "Bob", "Carol"], ) def test_primary_dedup_against_trailer(self): - from api.scan import _build_authors_list + from api.services.scan import _build_authors_list # Primary author repeated as a Co-authored-by trailer (cherry- # pick artifact) is dropped — order preserves first-seen. self.assertEqual( @@ -665,25 +665,25 @@ def test_primary_dedup_against_trailer(self): def test_email_only_trailer_uses_local_part(self): # Regression-protection for the privacy fix: an email-only # trailer must not leak the @domain into the authors list. - from api.scan import _build_authors_list + from api.services.scan import _build_authors_list self.assertEqual( _build_authors_list("Alice", ""), ["Alice", "bot"], ) def test_bracketed_value_without_at_sign_kept_verbatim(self): - from api.scan import _build_authors_list + from api.services.scan import _build_authors_list self.assertEqual( _build_authors_list("Alice", ""), ["Alice", "just-localpart"], ) def test_empty_brackets_dropped(self): - from api.scan import _build_authors_list + from api.services.scan import _build_authors_list self.assertEqual(_build_authors_list("Alice", "<>"), ["Alice"]) def test_duplicate_co_author_deduped(self): - from api.scan import _build_authors_list + from api.services.scan import _build_authors_list self.assertEqual( _build_authors_list("Alice", "Bob \x1fBob "), ["Alice", "Bob"], @@ -706,7 +706,7 @@ def test_single_walk_invocation(self): # go through subprocess.run. Wrap both so we catch git log # regardless of which API the implementation chose. from unittest.mock import patch - from api.scan import _collect_git_metadata + from api.services.scan import _collect_git_metadata original_run = subprocess.run original_popen = subprocess.Popen @@ -733,15 +733,15 @@ def counting_popen(args, **kwargs): _record_if_git_log(args) return original_popen(args, **kwargs) - with patch("api.scan.subprocess.run", side_effect=counting_run), \ - patch("api.scan.subprocess.Popen", side_effect=counting_popen): + with patch("api.services.scan.subprocess.run", side_effect=counting_run), \ + patch("api.services.scan.subprocess.Popen", side_effect=counting_popen): _collect_git_metadata(FIXTURE, use_cache=False) self.assertEqual(len(log_calls), 1, f"expected exactly 1 git log call, got: {log_calls}") def test_collect_git_metadata_returns_commits_list(self): - from api.scan import _collect_git_metadata + from api.services.scan import _collect_git_metadata _created, _modified, _tracked, commits = _collect_git_metadata( FIXTURE, use_cache=False, ) @@ -772,7 +772,7 @@ def test_collect_git_metadata_captures_second_author_and_subject_only(self): second-to-last commit (the multi-author "feat: co-authored work" commit is newest). Subject must be the first line only; author must be the second author's name (not the bot).""" - from api.scan import _collect_git_metadata + from api.services.scan import _collect_git_metadata _c, _m, _t, commits = _collect_git_metadata( FIXTURE, use_cache=False, ) @@ -811,7 +811,7 @@ def test_collect_git_metadata_counts_merge_files(self): """A merge commit's combined-diff file count must be > 0, not the empty count git log emits by default for merges.""" import tempfile - from api.scan import _collect_git_metadata + from api.services.scan import _collect_git_metadata with tempfile.TemporaryDirectory() as td: tdp = Path(td) subprocess.run(["git", "init", "-q", "-b", "main", td], check=True) @@ -851,7 +851,7 @@ def test_collect_git_metadata_counts_clean_merge_files(self): files. With `-c`, clean merges report 0; with `--diff-merges=first-parent` they report the side-branch diff.""" import tempfile, subprocess - from api.scan import _collect_git_metadata + from api.services.scan import _collect_git_metadata with tempfile.TemporaryDirectory() as td: tdp = Path(td) subprocess.run(["git", "init", "-q", "-b", "main", td], check=True) @@ -899,7 +899,7 @@ class GitLogRobustnessTests(_CacheRedirectMixin, unittest.TestCase): def test_non_utf8_author_bytes_do_not_crash(self): """Failure mode A: a commit with non-UTF-8 author metadata must parse without raising. Bytes are replaced, not crashed-on.""" - from api.scan import _collect_git_metadata + from api.services.scan import _collect_git_metadata with TemporaryDirectory() as td: td_path = Path(td) _init_repo(td_path) @@ -965,7 +965,7 @@ def test_parse_loop_exception_kills_subprocess_before_waiting(self): ``proc.kill()`` before ``proc.wait()``. Otherwise wait() blocks forever on any git child that still has buffered output.""" from unittest.mock import patch - from api.scan import _collect_git_dates + from api.services.scan import _collect_git_dates class _FakeStdout: """Yields one valid line, then raises — mimics the moment @@ -1009,7 +1009,7 @@ def wait(self, timeout: float | None = None) -> int: return self.returncode or 0 fake = _FakeProc() - with patch("api.scan.subprocess.Popen", return_value=fake): + with patch("api.services.scan.subprocess.Popen", return_value=fake): with self.assertRaises(UnicodeDecodeError): _collect_git_dates(Path("/tmp/does-not-matter")) self.assertTrue(fake.killed, "cleanup must call proc.kill()") @@ -1026,7 +1026,7 @@ def setUpClass(cls): def test_warm_run_skips_git_log(self): from unittest.mock import patch - from api.scan import _collect_git_metadata + from api.services.scan import _collect_git_metadata # Cold run: populates cache. _collect_git_metadata(FIXTURE, use_cache=True) @@ -1040,13 +1040,13 @@ def counting_run(args, **kwargs): return original_run(args, **kwargs) # Warm run: must not invoke `git log` at all. - with patch("api.scan.subprocess.run", side_effect=counting_run): + with patch("api.services.scan.subprocess.run", side_effect=counting_run): _collect_git_metadata(FIXTURE, use_cache=True) self.assertEqual(log_calls, [], "expected zero git log calls on warm run") def test_use_cache_false_bypasses(self): from unittest.mock import patch - from api.scan import _collect_git_metadata + from api.services.scan import _collect_git_metadata _collect_git_metadata(FIXTURE, use_cache=True) # populate @@ -1066,15 +1066,15 @@ def counting_popen(args, **kwargs): _record_if_log(args) return original_popen(args, **kwargs) - with patch("api.scan.subprocess.run", side_effect=counting_run), \ - patch("api.scan.subprocess.Popen", side_effect=counting_popen): + with patch("api.services.scan.subprocess.run", side_effect=counting_run), \ + patch("api.services.scan.subprocess.Popen", side_effect=counting_popen): _collect_git_metadata(FIXTURE, use_cache=False) self.assertEqual(len(log_calls), 1, "use_cache=False must run the combined log walk") def test_cache_invalidated_after_new_commit(self): # Make a commit, confirm next call re-walks history. from unittest.mock import patch - from api.scan import _collect_git_metadata + from api.services.scan import _collect_git_metadata _collect_git_metadata(FIXTURE, use_cache=True) @@ -1105,8 +1105,8 @@ def counting_popen(args, **kwargs): _record_if_log(args) return original_popen(args, **kwargs) - with patch("api.scan.subprocess.run", side_effect=counting_run), \ - patch("api.scan.subprocess.Popen", side_effect=counting_popen): + with patch("api.services.scan.subprocess.run", side_effect=counting_run), \ + patch("api.services.scan.subprocess.Popen", side_effect=counting_popen): _collect_git_metadata(FIXTURE, use_cache=True) self.assertEqual(len(log_calls), 1, "HEAD moved -> must re-walk") @@ -1132,8 +1132,8 @@ def test_warm_run_skips_line_count(self): _final_manifest(str(FIXTURE)) # cold: populates cache - with patch("api.scan._line_count") as line_mock, \ - patch("api.scan._is_binary") as binary_mock: + with patch("api.services.scan._line_count") as line_mock, \ + patch("api.services.scan._is_binary") as binary_mock: _final_manifest(str(FIXTURE)) # warm: should not call either self.assertEqual(line_mock.call_count, 0, "warm scan must not call _line_count") @@ -1175,7 +1175,7 @@ def counting_line_count(p): line_calls.append(p) return original_line_count(p) - with patch("api.scan._line_count", side_effect=counting_line_count): + with patch("api.services.scan._line_count", side_effect=counting_line_count): _final_manifest(str(FIXTURE)) # Only the modified file should be recomputed. @@ -1189,7 +1189,7 @@ def test_use_cache_false_bypasses_file_cache(self): _final_manifest(str(FIXTURE)) # populate cache - with patch("api.scan._line_count", return_value=42) as line_mock: + with patch("api.services.scan._line_count", return_value=42) as line_mock: _final_manifest(str(FIXTURE), use_cache=False) # use_cache=False -> every file gets re-read self.assertGreater(line_mock.call_count, 0) @@ -1198,7 +1198,7 @@ def test_use_cache_false_bypasses_file_cache(self): def _line_count_real(): """Get the unwrapped _line_count for tests that want to call the real implementation while also mocking it.""" - from api.scan import _line_count + from api.services.scan import _line_count return _line_count @@ -1298,7 +1298,7 @@ def test_returns_hex_string_of_expected_length(self): def test_skeleton_and_final_manifests_share_same_tree_signature(self): """The same tree_signature must appear in both the skeleton and final manifest events for the same scan — the whole point of this feature.""" - from api.scan import scan_tree + from api.services.scan import scan_tree with tempfile.TemporaryDirectory() as td: root = Path(td) _init_repo(root) @@ -1315,7 +1315,7 @@ def test_skeleton_and_final_manifests_share_same_tree_signature(self): def test_tree_signature_stable_when_only_metadata_changes(self): """tree_signature must be unchanged when only file content/lines/binary differs — i.e., between skeleton and final phases.""" - from api.scan import scan_tree + from api.services.scan import scan_tree with tempfile.TemporaryDirectory() as td: root = Path(td) _init_repo(root) @@ -1340,7 +1340,7 @@ def _make_tiny_repo(self, tmpdir: str) -> str: return tmpdir def test_yields_skeleton_then_final(self) -> None: - from api.scan import scan_tree + from api.services.scan import scan_tree with TemporaryDirectory() as td: self._make_tiny_repo(td) events = list(scan_tree(td)) @@ -1349,7 +1349,7 @@ def test_yields_skeleton_then_final(self) -> None: self.assertEqual(events[1]["phase"], "final") def test_skeleton_has_placeholder_metadata(self) -> None: - from api.scan import scan_tree + from api.services.scan import scan_tree with TemporaryDirectory() as td: self._make_tiny_repo(td) skeleton, _final = list(scan_tree(td)) @@ -1366,7 +1366,7 @@ def files(node): def test_cancel_event_pre_set_raises_at_first_boundary(self) -> None: import threading - from api.scan import scan_tree, ScanCancelledError + from api.services.scan import scan_tree, ScanCancelledError with TemporaryDirectory() as td: self._make_tiny_repo(td) ev = threading.Event() @@ -1377,7 +1377,7 @@ def test_cancel_event_pre_set_raises_at_first_boundary(self) -> None: def test_cancel_event_set_after_skeleton_raises_in_populate(self) -> None: import threading - from api.scan import scan_tree, ScanCancelledError + from api.services.scan import scan_tree, ScanCancelledError with TemporaryDirectory() as td: root = Path(td) _init_repo(root) @@ -1451,7 +1451,7 @@ def test_heartbeat_calls_progress_callback_throttled(): even if tick() is called rapidly.""" import time from unittest.mock import MagicMock - from api.scan import _Heartbeat + from api.services.scan import _Heartbeat cb = MagicMock() hb = _Heartbeat(on_progress=cb) @@ -1472,7 +1472,7 @@ def test_heartbeat_flush_emits_terminal_count(): """flush() must emit the final seen count when the last tick was throttle-suppressed, so the UI never freezes at a stale value.""" from unittest.mock import MagicMock - from api.scan import _Heartbeat + from api.services.scan import _Heartbeat cb = MagicMock() hb = _Heartbeat(on_progress=cb) @@ -1500,7 +1500,7 @@ def test_heartbeat_flush_noop_when_already_emitted(): """flush() must not re-emit a count that was already emitted.""" import time from unittest.mock import MagicMock - from api.scan import _Heartbeat + from api.services.scan import _Heartbeat cb = MagicMock() hb = _Heartbeat(on_progress=cb) From abe0b8ef31958697240d43f8eb4c140958ae0d6f Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 2 Jun 2026 21:06:43 -0400 Subject: [PATCH 03/31] refactor: env.py -> config.py (live env reads, size constants) Co-Authored-By: Claude Sonnet 4.6 --- api/config.py | 36 ++++++++++++++++ api/env.py | 30 ------------- api/services/clone.py | 4 +- api/services/scan.py | 4 +- api/tests/test_config.py | 41 ++++++++++++++++++ api/tests/test_env.py | 91 ---------------------------------------- 6 files changed, 81 insertions(+), 125 deletions(-) create mode 100644 api/config.py delete mode 100644 api/env.py create mode 100644 api/tests/test_config.py delete mode 100644 api/tests/test_env.py diff --git a/api/config.py b/api/config.py new file mode 100644 index 00000000..8c56ecf3 --- /dev/null +++ b/api/config.py @@ -0,0 +1,36 @@ +"""Process configuration: env-driven flags and size limits. + +Replaces the old api/env.py. Functions that read env are intentionally +LIVE (re-read per call) so tests can monkeypatch os.environ without a +restart — notably CODECITY_ALLOW_LOCAL_REPOS, which gates local scans. +""" +from __future__ import annotations + +import os + +# Cap individual /api/file responses (stray symlink to a giant blob). +MAX_FILE_BYTES = 100 * 1024 * 1024 +# Bodies under this skip gzip — framing overhead exceeds the savings. +GZIP_MIN_BYTES = 256 + +# Permissive truthy set (case-insensitive, trimmed) — matches the prior +# api/env.py semantics so e.g. `-e CODECITY_FOO=yes` keeps working. +_TRUTHY = frozenset({"1", "true", "yes", "on"}) + + +def env_bool(name: str, default: bool = False) -> bool: + """True if env var `name` is a truthy string (1/true/yes/on, any case).""" + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in _TRUTHY + + +def local_repos_allowed() -> bool: + """Live read of CODECITY_ALLOW_LOCAL_REPOS (re-read per call).""" + return env_bool("CODECITY_ALLOW_LOCAL_REPOS") + + +def quiet() -> bool: + """Live read of CODECITY_QUIET — silences disconnect/scan logs.""" + return env_bool("CODECITY_QUIET") diff --git a/api/env.py b/api/env.py deleted file mode 100644 index d938aff2..00000000 --- a/api/env.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Permissive boolean env-var parsing. - -Single helper shared by every codecity env-driven bool. Truthy values -(case-insensitive, whitespace-trimmed): "1", "true", "yes", "on". -Anything else — including unset, "", "0", "false", "no", "off" — is -False (or the supplied default for unset). - -Matches the convention used by Docker / Django / typical CLI tooling -so users setting ``-e CODECITY_FOO=true`` aren't surprised by silent -failure. -""" - -from __future__ import annotations - -import os - -_TRUTHY = frozenset({"1", "true", "yes", "on"}) - - -def env_bool(name: str, default: bool = False) -> bool: - """Read env var ``name`` as a permissive boolean. - - Returns ``default`` if the variable is unset. For any set value, - returns True only when the trimmed lower-case value is in the - truthy set above. - """ - raw = os.environ.get(name) - if raw is None: - return default - return raw.strip().lower() in _TRUTHY diff --git a/api/services/clone.py b/api/services/clone.py index b099bdf5..0d35b7fe 100644 --- a/api/services/clone.py +++ b/api/services/clone.py @@ -27,7 +27,7 @@ from pathlib import Path from typing import Callable -from api.env import env_bool +from api.config import quiet from api.types import ( BranchNotFoundError, CloneError, @@ -46,7 +46,7 @@ def _log(msg: str) -> None: - if not env_bool("CODECITY_QUIET"): + if not quiet(): print(f"[clone] {msg}", file=sys.stderr, flush=True) diff --git a/api/services/scan.py b/api/services/scan.py index 9a3612db..16e9e072 100755 --- a/api/services/scan.py +++ b/api/services/scan.py @@ -25,7 +25,7 @@ from pathlib import Path from typing import Any, Callable, Iterator -from api.env import env_bool +from api.config import quiet from .cache import ( cache_load_files, cache_load_git_history, @@ -73,7 +73,7 @@ def _check_cancel(event: "threading.Event | None") -> None: def _log(msg: str) -> None: - if not env_bool("CODECITY_QUIET"): + if not quiet(): print(f"[scan] {msg}", file=sys.stderr, flush=True) diff --git a/api/tests/test_config.py b/api/tests/test_config.py new file mode 100644 index 00000000..514dd463 --- /dev/null +++ b/api/tests/test_config.py @@ -0,0 +1,41 @@ +"""Tests for api.config — env-driven settings + the live-read helpers.""" +from __future__ import annotations + +import os +import unittest +from unittest import mock + +from api.config import env_bool, local_repos_allowed, quiet, MAX_FILE_BYTES, GZIP_MIN_BYTES + + +class ConfigTests(unittest.TestCase): + def test_env_bool_truthy_values(self) -> None: + for v in ("1", "true", "TRUE", "True", "yes", "on", " on ", "YES"): + with mock.patch.dict(os.environ, {"X": v}): + self.assertTrue(env_bool("X"), v) + + def test_env_bool_falsey_values(self) -> None: + for v in ("0", "false", "no", "off", "", "nope"): + with mock.patch.dict(os.environ, {"X": v}): + self.assertFalse(env_bool("X"), v) + + def test_env_bool_absent_uses_default(self) -> None: + with mock.patch.dict(os.environ, {}, clear=True): + self.assertFalse(env_bool("X")) + self.assertTrue(env_bool("X", default=True)) + + def test_local_repos_allowed_reads_live(self) -> None: + with mock.patch.dict(os.environ, {"CODECITY_ALLOW_LOCAL_REPOS": "1"}): + self.assertTrue(local_repos_allowed()) + with mock.patch.dict(os.environ, {}, clear=True): + self.assertFalse(local_repos_allowed()) + + def test_quiet_reads_live(self) -> None: + with mock.patch.dict(os.environ, {"CODECITY_QUIET": "1"}): + self.assertTrue(quiet()) + with mock.patch.dict(os.environ, {}, clear=True): + self.assertFalse(quiet()) + + def test_size_constants(self) -> None: + self.assertEqual(MAX_FILE_BYTES, 100 * 1024 * 1024) + self.assertEqual(GZIP_MIN_BYTES, 256) diff --git a/api/tests/test_env.py b/api/tests/test_env.py deleted file mode 100644 index 4765d102..00000000 --- a/api/tests/test_env.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Tests for the permissive boolean env-var parser in api.env.""" - -from __future__ import annotations - -import unittest - -import pytest - -from api.env import env_bool - - -class EnvBoolTests(unittest.TestCase): - """Truthy values: '1', 'true', 'yes', 'on' (case-insensitive, - whitespace-trimmed). Anything else (including unset) is False.""" - - NAME = "CODECITY_TEST_ENV_BOOL" - - @pytest.fixture(autouse=True) - def _setup(self, monkeypatch: pytest.MonkeyPatch) -> None: - self.monkeypatch = monkeypatch - # Start from a clean slate every test. - monkeypatch.delenv(self.NAME, raising=False) - - def _set(self, value: str) -> None: - self.monkeypatch.setenv(self.NAME, value) - - # Truthy values - def test_one_enables(self) -> None: - self._set("1") - self.assertTrue(env_bool(self.NAME)) - - def test_true_enables(self) -> None: - self._set("true") - self.assertTrue(env_bool(self.NAME)) - - def test_uppercase_true_enables(self) -> None: - self._set("TRUE") - self.assertTrue(env_bool(self.NAME)) - - def test_mixed_case_true_enables(self) -> None: - self._set("True") - self.assertTrue(env_bool(self.NAME)) - - def test_yes_enables(self) -> None: - self._set("yes") - self.assertTrue(env_bool(self.NAME)) - - def test_on_enables(self) -> None: - self._set("on") - self.assertTrue(env_bool(self.NAME)) - - def test_whitespace_is_trimmed(self) -> None: - self._set(" true ") - self.assertTrue(env_bool(self.NAME)) - - # Falsy values - def test_zero_disables(self) -> None: - self._set("0") - self.assertFalse(env_bool(self.NAME)) - - def test_false_disables(self) -> None: - self._set("false") - self.assertFalse(env_bool(self.NAME)) - - def test_no_disables(self) -> None: - self._set("no") - self.assertFalse(env_bool(self.NAME)) - - def test_off_disables(self) -> None: - self._set("off") - self.assertFalse(env_bool(self.NAME)) - - def test_empty_string_disables(self) -> None: - self._set("") - self.assertFalse(env_bool(self.NAME)) - - def test_arbitrary_string_disables(self) -> None: - self._set("maybe") - self.assertFalse(env_bool(self.NAME)) - - # Unset - def test_unset_returns_default_false(self) -> None: - # No setenv — env var is absent. - self.assertFalse(env_bool(self.NAME)) - - def test_unset_returns_supplied_default(self) -> None: - self.assertTrue(env_bool(self.NAME, default=True)) - - -if __name__ == "__main__": - unittest.main() From c848cc2cd78e9332543389bcd113674e3f12f0ae Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 2 Jun 2026 21:09:43 -0400 Subject: [PATCH 04/31] feat: extract allowed_roots trust model into api/security.py Co-Authored-By: Claude Sonnet 4.6 --- api/security.py | 69 ++++++++++++++++++++++++++++++++++++++ api/tests/test_security.py | 38 +++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 api/security.py create mode 100644 api/tests/test_security.py diff --git a/api/security.py b/api/security.py new file mode 100644 index 00000000..0c118409 --- /dev/null +++ b/api/security.py @@ -0,0 +1,69 @@ +"""The session trust model. + +SINGLE-PROCESS INVARIANT: `allowed_roots` is in-memory module state. +The app MUST run as one process — multi-worker (gunicorn) would split +the trust set across workers and break /api/file and /api/commit. The +Dockerfile and __main__ run a single uvicorn process for this reason. + +Trust rule: every successful manifest scan registers its absolute root. +/api/file and /api/commit then validate that the requested path resolves +under at least one registered root — there is no global filesystem read. +""" +from __future__ import annotations + +import threading +from pathlib import Path + + +class OutsideRootError(Exception): + """Requested path resolves outside every registered scan root.""" + + +class NoRootsRegisteredError(Exception): + """No scan root registered yet — caller must fetch /api/manifest first.""" + + +class TrustStore: + """Thread-safe set of absolute roots that have been scanned this session.""" + + def __init__(self) -> None: + self._roots: set[Path] = set() + self._lock = threading.Lock() + # Serializes clone-or-update so two concurrent manifest requests for + # the same URL don't race the working tree. + self.clone_lock = threading.Lock() + + def reset(self) -> None: + """Fresh trust set (per-process start; tests call between cases).""" + with self._lock: + self._roots = set() + + def register(self, root: Path) -> None: + with self._lock: + self._roots.add(root.resolve()) + + def snapshot(self) -> set[Path]: + with self._lock: + return set(self._roots) + + def assert_inside(self, raw: Path) -> Path: + """Resolve `raw` (strict) and confirm it sits under a registered root. + + Raises NoRootsRegisteredError if none registered, OutsideRootError + if the resolved path escapes every root. Returns the resolved path. + """ + roots = self.snapshot() + if not roots: + raise NoRootsRegisteredError + target = raw.resolve(strict=True) + for root in roots: + try: + target.relative_to(root) + except ValueError: + continue + return target + raise OutsideRootError + + +# Module-level singleton — the one trust set for the process. +TRUST = TrustStore() diff --git a/api/tests/test_security.py b/api/tests/test_security.py new file mode 100644 index 00000000..e79ae206 --- /dev/null +++ b/api/tests/test_security.py @@ -0,0 +1,38 @@ +"""Tests for the allowed_roots trust set + path validation.""" +from __future__ import annotations + +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from api.security import ( + OutsideRootError, + NoRootsRegisteredError, + TrustStore, +) + + +class TrustStoreTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) / "repo" + (self.root / "sub").mkdir(parents=True) + (self.root / "sub" / "f.txt").write_text("hi") + self.store = TrustStore() + + def test_no_roots_raises(self) -> None: + with self.assertRaises(NoRootsRegisteredError): + self.store.assert_inside(self.root / "sub" / "f.txt") + + def test_inside_registered_root_ok(self) -> None: + self.store.register(self.root) + resolved = self.store.assert_inside(self.root / "sub" / "f.txt") + self.assertEqual(resolved, (self.root / "sub" / "f.txt").resolve()) + + def test_outside_root_raises(self) -> None: + self.store.register(self.root) + outside = Path(self.tmp.name) / "elsewhere.txt" + outside.write_text("x") + with self.assertRaises(OutsideRootError): + self.store.assert_inside(outside) From 41ca34d16c4954e7a03cb8544a581ec155669b05 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 2 Jun 2026 21:12:17 -0400 Subject: [PATCH 05/31] feat: Pydantic manifest models (port of types.py) Co-Authored-By: Claude Sonnet 4.6 --- api/models/__init__.py | 0 api/models/manifest.py | 103 +++++++++++++++++++++++++++++++++++++++ api/tests/test_models.py | 45 +++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 api/models/__init__.py create mode 100644 api/models/manifest.py create mode 100644 api/tests/test_models.py diff --git a/api/models/__init__.py b/api/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/models/manifest.py b/api/models/manifest.py new file mode 100644 index 00000000..7a3cecae --- /dev/null +++ b/api/models/manifest.py @@ -0,0 +1,103 @@ +"""Pydantic wire models for the scan manifest. Single source of truth for +the OpenAPI schema and the generated app/src/types/manifest.ts. JSON shape +is byte-compatible with the prior TypedDicts.""" +from __future__ import annotations + +from typing import Annotated, Literal, Optional, Union + +from pydantic import BaseModel, Field, model_validator + + +class GitMeta(BaseModel): + created: Optional[str] = Field(None, description="ISO create date or null") + modified: Optional[str] = Field(None, description="ISO modify date or null") + + +class FileNode(BaseModel): + name: str + type: Literal["file"] + path: str + fullPath: str + extension: str + size: int + lines: int + binary: bool + created: str + modified: str + git: GitMeta + media_width: Optional[int] = None + media_height: Optional[int] = None + + @model_validator(mode="after") + def _media_both_or_neither(self) -> "FileNode": + if (self.media_width is None) != (self.media_height is None): + raise ValueError("media_width and media_height must both be set or both absent") + return self + + +class ExtBreakdownEntry(BaseModel): + ext: str + count: int + size: int + + +class DirNode(BaseModel): + name: str + type: Literal["directory"] + path: str + fullPath: str + children: list["TreeNode"] + children_count: int + children_file_count: int + children_dir_count: int + descendants_count: int + descendants_file_count: int + descendants_dir_count: int + descendants_size: int + descendants_ext_breakdown: list[ExtBreakdownEntry] + + +TreeNode = Annotated[Union[FileNode, DirNode], Field(discriminator="type")] + + +class RepoInfo(BaseModel): + branch: Optional[str] = None + remote_url: Optional[str] = None + head_sha: Optional[str] = None + head_subject: Optional[str] = None + dirty: bool + + +class CommitEntry(BaseModel): + date: str = Field(description="YYYY-MM-DD") + files: int + sha: str + authors: list[str] + subject: str + same_day_total: int + + +class BusynessThresholds(BaseModel): + avg: int + busy: int + + +class Manifest(BaseModel): + root: str + scanned_at: str + signature: str + tree_signature: str + tree: DirNode + repo: RepoInfo + commits: list[CommitEntry] + busyness: BusynessThresholds + display_root: Optional[str] = None + + +class SignatureResponse(BaseModel): + root: str + scanned_at: str + signature: str + + +DirNode.model_rebuild() diff --git a/api/tests/test_models.py b/api/tests/test_models.py new file mode 100644 index 00000000..f5e4017c --- /dev/null +++ b/api/tests/test_models.py @@ -0,0 +1,45 @@ +"""Wire-model validation: byte-compatible JSON + media both-or-neither rule.""" +from __future__ import annotations + +import unittest + +from api.models.manifest import FileNode, Manifest +from pydantic import ValidationError + + +class ModelTests(unittest.TestCase): + def test_file_node_media_both_or_neither_ok(self) -> None: + FileNode( + name="a.png", type="file", path="a.png", fullPath="/r/a.png", + extension=".png", size=1, lines=0, binary=True, + created="2020-01-01", modified="2020-01-01", + git={"created": None, "modified": None}, + media_width=10, media_height=20, + ) + + def test_file_node_media_one_only_rejected(self) -> None: + with self.assertRaises(ValidationError): + FileNode( + name="a.png", type="file", path="a.png", fullPath="/r/a.png", + extension=".png", size=1, lines=0, binary=True, + created="2020-01-01", modified="2020-01-01", + git={"created": None, "modified": None}, + media_width=10, + ) + + def test_manifest_excludes_none_optional_keys(self) -> None: + m = Manifest( + root="/r", scanned_at="2020", signature="s", tree_signature="t", + tree={ + "name": "r", "type": "directory", "path": "", "fullPath": "/r", + "children": [], "children_count": 0, "children_file_count": 0, + "children_dir_count": 0, "descendants_count": 0, + "descendants_file_count": 0, "descendants_dir_count": 0, + "descendants_size": 0, "descendants_ext_breakdown": [], + }, + repo={"branch": None, "remote_url": None, "head_sha": None, + "head_subject": None, "dirty": False}, + commits=[], busyness={"avg": 0, "busy": 0}, + ) + dumped = m.model_dump(exclude_none=True) + self.assertNotIn("display_root", dumped) From 667928f1082dc77326cdf3eea16c450a7e6ad0db Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 2 Jun 2026 21:14:02 -0400 Subject: [PATCH 06/31] feat: Pydantic response + SSE event models Co-Authored-By: Claude Sonnet 4.6 --- api/models/events.py | 33 +++++++++++++++++++++++++++++++++ api/models/responses.py | 34 ++++++++++++++++++++++++++++++++++ api/tests/test_models.py | 22 ++++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 api/models/events.py create mode 100644 api/models/responses.py diff --git a/api/models/events.py b/api/models/events.py new file mode 100644 index 00000000..a88d3ed3 --- /dev/null +++ b/api/models/events.py @@ -0,0 +1,33 @@ +"""SSE event payloads for /api/manifest. Each is the `data:` body of a +named SSE event (event names: cloning/scanning/skeleton/final/error). +These models also document the stream in OpenAPI as schema components.""" +from __future__ import annotations + +from typing import Literal, Optional + +from pydantic import BaseModel + +from api.models.manifest import Manifest + + +class CloningEvent(BaseModel): + display_root: Optional[str] = None + stage: Optional[Literal["receiving", "resolving", "counting"]] = None + percent: Optional[int] = None + + +class ScanningEvent(BaseModel): + display_root: Optional[str] = None + files_scanned: Optional[int] = None + + +class SkeletonEvent(BaseModel): + manifest: Manifest + + +class FinalEvent(BaseModel): + manifest: Manifest + + +class ErrorEvent(BaseModel): + error: str diff --git a/api/models/responses.py b/api/models/responses.py new file mode 100644 index 00000000..a8fc8dfe --- /dev/null +++ b/api/models/responses.py @@ -0,0 +1,34 @@ +"""Non-streaming JSON response bodies.""" +from __future__ import annotations + +from pydantic import BaseModel + + +class ErrorResponse(BaseModel): + error: str + + +class FileTooLargeResponse(BaseModel): + error: str + size: int + limit: int + + +class HealthResponse(BaseModel): + ok: bool + + +class ConfigResponse(BaseModel): + allowLocalRepos: bool + + +class CacheClearResponse(BaseModel): + deleted: int + + +class CommitDetailResponse(BaseModel): + sha: str + authors: list[str] + date: str # YYYY-MM-DD + subject: str + body: str diff --git a/api/tests/test_models.py b/api/tests/test_models.py index f5e4017c..12f8bb56 100644 --- a/api/tests/test_models.py +++ b/api/tests/test_models.py @@ -43,3 +43,25 @@ def test_manifest_excludes_none_optional_keys(self) -> None: ) dumped = m.model_dump(exclude_none=True) self.assertNotIn("display_root", dumped) + + +class ResponseModelTests(unittest.TestCase): + def test_error_response(self) -> None: + from api.models.responses import ErrorResponse + self.assertEqual(ErrorResponse(error="x").model_dump(), {"error": "x"}) + + def test_health_and_config(self) -> None: + from api.models.responses import HealthResponse, ConfigResponse + self.assertEqual(HealthResponse(ok=True).model_dump(), {"ok": True}) + self.assertEqual( + ConfigResponse(allowLocalRepos=False).model_dump(), + {"allowLocalRepos": False}, + ) + + def test_sse_event_serialization(self) -> None: + from api.models.events import ScanningEvent, ErrorEvent + self.assertEqual( + ScanningEvent(display_root="r", files_scanned=3).model_dump(exclude_none=True), + {"display_root": "r", "files_scanned": 3}, + ) + self.assertEqual(ErrorEvent(error="boom").model_dump(), {"error": "boom"}) From d455a3316a906f7f5c601bb7136792fd7ad7fe70 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 2 Jun 2026 21:18:50 -0400 Subject: [PATCH 07/31] feat: FastAPI app factory + health/config router + SPA static + Scalar docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds api/app.py (create_app factory), api/routers/health.py (/api/health + /api/config), api/static.py (SPA fallback with extension-based 404 guard and fresh-router-per-app isolation), Scalar docs at /api/docs, OpenAPI JSON relocated to /api/openapi.json. Adds httpx2 dev dep — Starlette 1.2.x's TestClient prefers it (import httpx2 as httpx) and deprecates plain httpx. All 8 TDD tests pass warning-free; 0 pyright strict errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/app.py | 53 ++++++++++ api/routers/__init__.py | 0 api/routers/health.py | 19 ++++ api/static.py | 50 ++++++++++ api/tests/test_server_health.py | 168 ++++++++++++++------------------ pyproject.toml | 4 + uv.lock | 39 ++++++++ 7 files changed, 236 insertions(+), 97 deletions(-) create mode 100644 api/app.py create mode 100644 api/routers/__init__.py create mode 100644 api/routers/health.py create mode 100644 api/static.py diff --git a/api/app.py b/api/app.py new file mode 100644 index 00000000..6cfa4ae3 --- /dev/null +++ b/api/app.py @@ -0,0 +1,53 @@ +"""FastAPI app factory. + +Order matters: API routers register first, the SPA static catch-all last +(it owns every non-/api path). Swagger + default ReDoc are disabled; +Scalar is mounted at /api/docs and OpenAPI JSON relocated to +/api/openapi.json (the source for the generated TS types).""" +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.gzip import GZipMiddleware +from fastapi.responses import HTMLResponse, JSONResponse + +from api.config import GZIP_MIN_BYTES +from api.routers import health +from api.security import TRUST +from api.static import make_static_router + +DEFAULT_STATIC_DIR = Path(__file__).resolve().parent / "static" + +_SCALAR_HTML = """CodeCity API + + + +""" + + +def create_app(static_dir: Path | None = None) -> FastAPI: + TRUST.reset() # fresh trust set per process / per test app + app = FastAPI( + title="CodeCity API", + docs_url=None, # disable Swagger UI + redoc_url=None, # disable default ReDoc + openapi_url="/api/openapi.json", + ) + app.add_middleware(GZipMiddleware, minimum_size=GZIP_MIN_BYTES) + + @app.get("/api/docs", include_in_schema=False) + def scalar_docs() -> HTMLResponse: # pyright: ignore[reportUnusedFunction] + return HTMLResponse(_SCALAR_HTML) + + # JSON 404 for unknown /api/* (HTTPException detail -> {"error": ...}). + @app.exception_handler(HTTPException) + async def _http_exc( # pyright: ignore[reportUnusedFunction] + _req: Request, exc: HTTPException + ) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, content={"error": exc.detail}) + + app.include_router(health.router) + # NOTE: file/commit/manifest routers added in later tasks, BEFORE static. + app.include_router(make_static_router(static_dir or DEFAULT_STATIC_DIR)) + return app diff --git a/api/routers/__init__.py b/api/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/routers/health.py b/api/routers/health.py new file mode 100644 index 00000000..80fdd7cb --- /dev/null +++ b/api/routers/health.py @@ -0,0 +1,19 @@ +"""GET /api/health and GET /api/config.""" +from __future__ import annotations + +from fastapi import APIRouter + +from api.config import local_repos_allowed +from api.models.responses import ConfigResponse, HealthResponse + +router = APIRouter(prefix="/api", tags=["meta"]) + + +@router.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(ok=True) + + +@router.get("/config", response_model=ConfigResponse) +def config() -> ConfigResponse: + return ConfigResponse(allowLocalRepos=local_repos_allowed()) diff --git a/api/static.py b/api/static.py new file mode 100644 index 00000000..bad57807 --- /dev/null +++ b/api/static.py @@ -0,0 +1,50 @@ +"""SPA static serving + index fallback. + +API routes are registered before this is mounted, so anything reaching +here is either a real static asset or a client-side route -> index.html. +Path traversal is rejected. /api/* that falls through is a 404 JSON. + +A path with a file extension that does not exist on disk is a genuine +404 (e.g. /openapi.json must not be masked by the SPA index); only +extension-less route-like paths fall back to index.html.""" +from __future__ import annotations + +import mimetypes +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request, Response +from fastapi.responses import FileResponse + + +def make_static_router(static_dir: Path) -> APIRouter: + static_dir = static_dir.resolve() + router = APIRouter() # fresh per app — never a module-level singleton + + @router.get("/{full_path:path}") + def serve( # pyright: ignore[reportUnusedFunction] + full_path: str, request: Request + ) -> Response: + # Never serve API paths from here. + if full_path.startswith("api/"): + raise HTTPException(status_code=404, detail="unknown api route") + if ".." in Path(full_path).parts: + raise HTTPException(status_code=403, detail="forbidden") + rel = full_path or "index.html" + target = (static_dir / rel).resolve() + try: + target.relative_to(static_dir) + except ValueError: + raise HTTPException(status_code=403, detail="forbidden") + if target.is_file(): + ctype, _ = mimetypes.guess_type(str(target)) + return FileResponse(target, media_type=ctype or "application/octet-stream") + # A missing path WITH a file extension is a real 404, not an SPA route. + if Path(full_path).suffix: + raise HTTPException(status_code=404, detail="not found") + # SPA fallback: unknown extension-less route -> index.html. + index = static_dir / "index.html" + if index.is_file(): + return FileResponse(index, media_type="text/html") + raise HTTPException(status_code=404, detail="not found") + + return router diff --git a/api/tests/test_server_health.py b/api/tests/test_server_health.py index adc2b9e3..a82da05c 100644 --- a/api/tests/test_server_health.py +++ b/api/tests/test_server_health.py @@ -1,103 +1,77 @@ -"""Tests for /api/health and general server routing (split from test_server.py).""" - +"""TestClient coverage for health/config + static SPA serving + docs.""" from __future__ import annotations -import http.client -import json -import unittest -from http import HTTPStatus from pathlib import Path -from tempfile import TemporaryDirectory import pytest +from fastapi.testclient import TestClient + +from api.app import create_app + + +@pytest.fixture() +def client(tmp_path: Path) -> TestClient: + static = tmp_path / "static" + static.mkdir() + (static / "index.html").write_text("hi") + (static / "assets").mkdir() + (static / "assets" / "main.js").write_text("console.log('ok')") + app = create_app(static_dir=static) + return TestClient(app) + + +def test_health(client: TestClient) -> None: + r = client.get("/api/health") + assert r.status_code == 200 + assert r.json() == {"ok": True} + + +def test_config_default_disabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # The conftest sets CODECITY_ALLOW_LOCAL_REPOS=1 session-wide for + # tests that exercise local scan paths. Override it here to verify the + # default-disabled state that the real endpoint exposes. + monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False) + static = tmp_path / "static" + static.mkdir() + (static / "index.html").write_text("") + app = create_app(static_dir=static) + r = TestClient(app).get("/api/config") + assert r.status_code == 200 + assert r.json() == {"allowLocalRepos": False} + + +def test_root_serves_index(client: TestClient) -> None: + r = client.get("/") + assert r.status_code == 200 + assert "hi" in r.text + + +def test_static_asset(client: TestClient) -> None: + r = client.get("/assets/main.js") + assert r.status_code == 200 + assert r.text == "console.log('ok')" + + +def test_unknown_api_route_404_json(client: TestClient) -> None: + r = client.get("/api/nope") + assert r.status_code == 404 + assert "error" in r.json() + + +def test_spa_fallback_serves_index_for_unknown_non_api(client: TestClient) -> None: + r = client.get("/some/spa/route") + assert r.status_code == 200 + assert "hi" in r.text + + +def test_openapi_relocated(client: TestClient) -> None: + assert client.get("/api/openapi.json").status_code == 200 + assert client.get("/openapi.json").status_code == 404 # default disabled + -from api.server import start_server - - -class ServerHealthTests(unittest.TestCase): - """Tests covering /api/health and the surrounding static/routing layer - (root index, static MIME, unknown api routes, path traversal).""" - - @pytest.fixture(autouse=True) - def _setup_fixtures( - self, redirect_cache_root, init_git_repo, http_helpers, - ) -> None: - self.cache_root = redirect_cache_root - self._init_git_repo = init_git_repo - self._http = http_helpers - - def setUp(self) -> None: - super().setUp() - self.tmp = TemporaryDirectory() - self.addCleanup(self.tmp.cleanup) - static = Path(self.tmp.name) / "static" - static.mkdir() - (static / "index.html").write_text("hi") - (static / "assets").mkdir() - (static / "assets" / "main.js").write_text("console.log('ok')") - - # A small directory we can scan in the manifest test. Initialized - # as a git repo because the API now requires every local scan - # target to be inside a git working tree. - self.project = Path(self.tmp.name) / "project" - self.project.mkdir() - self._init_git_repo(self.project) - (self.project / "README.md").write_text("# demo\n") - - self.server, self.port, self.shutdown = start_server(port=0, static_dir=static) - self.addCleanup(self.shutdown) - self.base = f"http://127.0.0.1:{self.port}" - - def test_health_route(self) -> None: - status, body, ctype = self._http.get(self.base + "/api/health") - self.assertEqual(status, HTTPStatus.OK) - self.assertIn("application/json", ctype) - self.assertEqual(json.loads(body), {"ok": True}) - - def test_root_serves_index_html(self) -> None: - status, body, ctype = self._http.get(self.base + "/") - self.assertEqual(status, HTTPStatus.OK) - self.assertIn("text/html", ctype) - self.assertIn(b"hi", body) - - def test_static_asset_with_correct_mime(self) -> None: - status, body, ctype = self._http.get(self.base + "/assets/main.js") - self.assertEqual(status, HTTPStatus.OK) - self.assertTrue( - ctype.startswith("text/javascript") - or ctype.startswith("application/javascript") - ) - self.assertEqual(body, b"console.log('ok')") - - def test_unknown_api_route_returns_404_json(self) -> None: - status, body, ctype = self._http.get(self.base + "/api/nope") - self.assertEqual(status, HTTPStatus.NOT_FOUND) - self.assertIn("application/json", ctype) - self.assertEqual(json.loads(body), {"error": "unknown api route"}) - - def test_missing_static_returns_404(self) -> None: - status, _, _ = self._http.get(self.base + "/does-not-exist.html") - self.assertEqual(status, HTTPStatus.NOT_FOUND) - - def test_path_traversal_rejected(self) -> None: - # urllib normalizes ../ on the client side, so we go raw to make sure - # the server itself rejects a crafted escape attempt. - conn = http.client.HTTPConnection("127.0.0.1", self.port) - conn.request("GET", "/../api/scan.py") - resp = conn.getresponse() - self.assertIn(resp.status, (HTTPStatus.FORBIDDEN, HTTPStatus.NOT_FOUND)) - - def test_health_response_below_threshold_uncompressed(self) -> None: - # /api/health body is ~15 bytes — below the 256-byte threshold. - # Even with gzip in Accept-Encoding, response is not compressed. - status, body, _, enc = self._http.get_with_headers( - self.base + "/api/health", - {"Accept-Encoding": "gzip"}, - ) - self.assertEqual(status, HTTPStatus.OK) - self.assertEqual(enc, "") - self.assertEqual(json.loads(body), {"ok": True}) - - -if __name__ == "__main__": - unittest.main() +def test_scalar_docs_served(client: TestClient) -> None: + r = client.get("/api/docs") + assert r.status_code == 200 + assert "text/html" in r.headers["content-type"] diff --git a/pyproject.toml b/pyproject.toml index 1e8fa6eb..6ad7c1c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,10 @@ dev = [ "pytest-xdist>=3", "pytest-cov>=4", "pyright>=1.1", + # Starlette 1.2.x's TestClient prefers httpx2 (`import httpx2 as httpx`); + # plain httpx is deprecated for testclient and warns. httpx2 is the + # maintainer-recommended successor — keep it to stay warning-free. + "httpx2>=2.3", ] [tool.pytest.ini_options] diff --git a/uv.lock b/uv.lock index 8c2c301f..86fe243c 100644 --- a/uv.lock +++ b/uv.lock @@ -58,6 +58,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "httpx2" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -75,6 +76,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "httpx2", specifier = ">=2.3" }, { name = "pyright", specifier = ">=1.1" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-cov", specifier = ">=4" }, @@ -237,6 +239,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/96/9570e6604114e9b2b2e1d90a89d01d10646b9cb6f54bba78f6de5fbbceb1/hachoir-3.3.0-py3-none-any.whl", hash = "sha256:9f721af67867d933383398b9605bc388db25f55dde1034d2bfde91f05e5d9910", size = 650439, upload-time = "2023-12-12T10:59:28.091Z" }, ] +[[package]] +name = "httpcore2" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/34/18f1c596e677962f040284246f393b10a1f8ce440b3a7e69c637d0f1c7ad/httpcore2-2.3.0.tar.gz", hash = "sha256:07327e251560960eea8e969d92d4c6a325feb13cca39e25340731336c3baf924", size = 64300, upload-time = "2026-06-01T13:15:02.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl", hash = "sha256:477e9e334f74e5240dcac002e890580f36a57d40ff0fb14cc9655731d23b8415", size = 80024, upload-time = "2026-06-01T13:15:00.001Z" }, +] + [[package]] name = "httptools" version = "0.8.0" @@ -280,6 +295,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, ] +[[package]] +name = "httpx2" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/9a/cca0b9145f13d8ae34b885ae28d403a1469a433abc78e0f94f4ce94e650b/httpx2-2.3.0.tar.gz", hash = "sha256:227e7c41d95a76d4077a52640564132777215fc3394e07b66a3116c33d668fa9", size = 81115, upload-time = "2026-06-01T13:15:04.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl", hash = "sha256:6f393663bdf6dbe7fe90118e3eb5b2bd024a675cae0390ac08cec9198812d8b7", size = 74538, upload-time = "2026-06-01T13:15:01.566Z" }, +] + [[package]] name = "idna" version = "3.16" @@ -738,6 +768,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From edcb623e06d0adcb4d6ee14783dc450d8136f97c Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 2 Jun 2026 21:26:57 -0400 Subject: [PATCH 08/31] feat: /api/file router (trust-gated, 413 oversize, text coercion) Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 5 +- api/routers/file.py | 43 ++++++++ api/tests/test_server_file.py | 191 +++++++--------------------------- 3 files changed, 81 insertions(+), 158 deletions(-) create mode 100644 api/routers/file.py diff --git a/api/app.py b/api/app.py index 6cfa4ae3..f9dfb5a7 100644 --- a/api/app.py +++ b/api/app.py @@ -13,7 +13,7 @@ from fastapi.responses import HTMLResponse, JSONResponse from api.config import GZIP_MIN_BYTES -from api.routers import health +from api.routers import file, health from api.security import TRUST from api.static import make_static_router @@ -48,6 +48,7 @@ async def _http_exc( # pyright: ignore[reportUnusedFunction] return JSONResponse(status_code=exc.status_code, content={"error": exc.detail}) app.include_router(health.router) - # NOTE: file/commit/manifest routers added in later tasks, BEFORE static. + app.include_router(file.router) + # NOTE: commit/manifest routers added in later tasks, BEFORE static. app.include_router(make_static_router(static_dir or DEFAULT_STATIC_DIR)) return app diff --git a/api/routers/file.py b/api/routers/file.py new file mode 100644 index 00000000..e15beaa2 --- /dev/null +++ b/api/routers/file.py @@ -0,0 +1,43 @@ +"""GET /api/file — serve a file from disk, restricted to scanned roots.""" +from __future__ import annotations + +import mimetypes +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Query, Response +from fastapi.responses import JSONResponse + +from api.config import MAX_FILE_BYTES +from api.models.responses import FileTooLargeResponse +from api.security import NoRootsRegisteredError, OutsideRootError, TRUST +from api.services.media import is_media + +router = APIRouter(prefix="/api", tags=["file"]) + + +@router.get("/file") +def get_file(path: str = Query(..., description="Absolute path inside a scanned root")) -> Response: # pyright: ignore[reportUnusedFunction] + try: + target = TRUST.assert_inside(Path(path)) + except NoRootsRegisteredError: + raise HTTPException(403, "no scan root registered yet — fetch /api/manifest first") + except OutsideRootError: + raise HTTPException(403, "outside scan root") + except (OSError, RuntimeError): + raise HTTPException(404, "not found") + + if not target.is_file(): + raise HTTPException(404, "not a file") + + size = target.stat().st_size + if size > MAX_FILE_BYTES: + return JSONResponse( + status_code=413, + content=FileTooLargeResponse( + error="file too large", size=size, limit=MAX_FILE_BYTES + ).model_dump(), + ) + + guessed, _ = mimetypes.guess_type(str(target)) + ctype = guessed if is_media(guessed) and guessed else "text/plain; charset=utf-8" + return Response(content=target.read_bytes(), media_type=ctype) diff --git a/api/tests/test_server_file.py b/api/tests/test_server_file.py index b213188a..d7dc8405 100644 --- a/api/tests/test_server_file.py +++ b/api/tests/test_server_file.py @@ -1,174 +1,53 @@ -"""Tests for /api/file?path= (split from test_server.py).""" - +"""TestClient coverage for /api/file (trust gate, 403, 413, traversal, MIME).""" from __future__ import annotations -import gzip -import json -import unittest -from http import HTTPStatus from pathlib import Path -from tempfile import TemporaryDirectory import pytest +from fastapi.testclient import TestClient -from api import server as server_mod -from api.server import start_server - - -class FileApiTests(unittest.TestCase): - """Coverage for /api/file — the root-bounded file reader.""" - - @pytest.fixture(autouse=True) - def _setup_fixtures(self, redirect_cache_root, http_helpers, monkeypatch) -> None: - self.cache_root = redirect_cache_root - self._http = http_helpers - self.monkeypatch = monkeypatch - - def setUp(self) -> None: - super().setUp() - self.tmp = TemporaryDirectory() - self.addCleanup(self.tmp.cleanup) - self.scan_root = Path(self.tmp.name) / "project" - self.scan_root.mkdir() - - # Inside-root files - (self.scan_root / "hello.txt").write_text("hello world") - (self.scan_root / "image.png").write_bytes(b"\x89PNG\r\n\x1a\nfake") - sub = self.scan_root / "sub" - sub.mkdir() - (sub / "nested.md").write_text("# heading") - - # An outside-root file the server must refuse to expose - self.outside = Path(self.tmp.name) / "secret.txt" - self.outside.write_text("you can't see me") - - # Static dir is irrelevant to /api/file but required by start_server - static = Path(self.tmp.name) / "static" - static.mkdir() - (static / "index.html").write_text("ok") - - self.server, self.port, self.shutdown = start_server(port=0, static_dir=static) - self.addCleanup(self.shutdown) - self.base = f"http://127.0.0.1:{self.port}" - # Prime the trust set directly — we're unit-testing /api/file, not - # the manifest path that normally registers the root. - server_mod._State.allowed_roots.add(self.scan_root.resolve()) - - def test_returns_text_with_correct_mime(self) -> None: - status, body, ctype = self._http.get( - self.base + f"/api/file?path={self.scan_root / 'hello.txt'}" - ) - self.assertEqual(status, HTTPStatus.OK) - self.assertTrue(ctype.startswith("text/plain")) - self.assertEqual(body, b"hello world") - - def test_returns_image_with_correct_mime(self) -> None: - status, body, ctype = self._http.get( - self.base + f"/api/file?path={self.scan_root / 'image.png'}" - ) - self.assertEqual(status, HTTPStatus.OK) - self.assertEqual(ctype, "image/png") - self.assertTrue(body.startswith(b"\x89PNG")) - - def test_nested_path_inside_root(self) -> None: - status, _, _ = self._http.get( - self.base + f"/api/file?path={self.scan_root / 'sub' / 'nested.md'}" - ) - self.assertEqual(status, HTTPStatus.OK) - - def test_path_outside_root_forbidden(self) -> None: - status, body, _ = self._http.get(self.base + f"/api/file?path={self.outside}") - self.assertEqual(status, HTTPStatus.FORBIDDEN) - self.assertEqual(json.loads(body), {"error": "outside scan root"}) +from api.app import create_app +from api.security import TRUST - def test_missing_path_param(self) -> None: - status, _, _ = self._http.get(self.base + "/api/file") - self.assertEqual(status, HTTPStatus.BAD_REQUEST) - def test_nonexistent_file(self) -> None: - status, _, _ = self._http.get( - self.base + f"/api/file?path={self.scan_root / 'nope.txt'}" - ) - self.assertEqual(status, HTTPStatus.NOT_FOUND) +@pytest.fixture() +def project(tmp_path: Path) -> Path: + p = tmp_path / "repo" + (p / "src").mkdir(parents=True) + (p / "src" / "a.txt").write_text("hello") + return p - def test_directory_is_not_a_file(self) -> None: - status, _, _ = self._http.get(self.base + f"/api/file?path={self.scan_root / 'sub'}") - self.assertEqual(status, HTTPStatus.NOT_FOUND) - def test_extensionless_textfile_returns_text(self) -> None: - # LICENSE, Makefile, Dockerfile, .gitignore — mimetypes can't help. - license_path = self.scan_root / "LICENSE" - license_path.write_text("MIT License\n\nCopyright (c) ...") - status, body, ctype = self._http.get(self.base + f"/api/file?path={license_path}") - self.assertEqual(status, HTTPStatus.OK) - self.assertTrue(ctype.startswith("text/plain")) - self.assertIn(b"MIT License", body) +@pytest.fixture() +def client(tmp_path: Path) -> TestClient: + static = tmp_path / "static" + static.mkdir() + (static / "index.html").write_text("x") + return TestClient(create_app(static_dir=static)) - def test_shell_script_returns_text_not_octet_stream(self) -> None: - # mimetypes guesses .sh as 'application/x-sh' — neither media nor - # text. We want shell scripts (and friends) shown as code. - sh_path = self.scan_root / "build.sh" - sh_path.write_text("#!/bin/bash\necho hi\n") - status, body, ctype = self._http.get(self.base + f"/api/file?path={sh_path}") - self.assertEqual(status, HTTPStatus.OK) - self.assertTrue(ctype.startswith("text/plain")) - self.assertIn(b"echo hi", body) - def test_aggressive_text_rendering_for_unknown_binaries(self) -> None: - # Even files that look binary get served as text/plain so the - # frontend renders the bytes IDE-style. (Truly-binary content - # decodes with replacement chars in the browser; that's fine.) - bin_path = self.scan_root / "blob.dat" - bin_path.write_bytes(b"\x00\x01\x02\x03" * 200) - status, _, ctype = self._http.get(self.base + f"/api/file?path={bin_path}") - self.assertEqual(status, HTTPStatus.OK) - self.assertTrue(ctype.startswith("text/plain")) +def test_file_requires_registered_root(client: TestClient, project: Path) -> None: + r = client.get("/api/file", params={"path": str(project / "src" / "a.txt")}) + assert r.status_code == 403 + assert "error" in r.json() - def test_file_api_text_gzipped(self) -> None: - # A text file (>256 bytes) requested with Accept-Encoding: gzip - # comes back compressed. - big_text = self.scan_root / "big.md" - big_text.write_text("# heading\n\n" + ("hello world\n" * 100)) - status, body, ctype, enc = self._http.get_with_headers( - self.base + f"/api/file?path={big_text}", - {"Accept-Encoding": "gzip"}, - ) - self.assertEqual(status, HTTPStatus.OK) - self.assertEqual(enc, "gzip") - self.assertTrue(ctype.startswith("text/")) - decoded = gzip.decompress(body) - self.assertIn(b"hello world", decoded) - def test_file_api_image_not_gzipped(self) -> None: - # Already-compressed media bypasses gzip even when client offers it. - # Use a >256-byte fake PNG so the size threshold isn't doing the work. - png_path = self.scan_root / "big-image.png" - png_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"x" * 500) - status, body, ctype, enc = self._http.get_with_headers( - self.base + f"/api/file?path={png_path}", - {"Accept-Encoding": "gzip"}, - ) - self.assertEqual(status, HTTPStatus.OK) - self.assertEqual(enc, "") - self.assertEqual(ctype, "image/png") - # Body is the raw "PNG" bytes — not gzipped. - self.assertTrue(body.startswith(b"\x89PNG")) +def test_file_inside_root_ok(client: TestClient, project: Path) -> None: + TRUST.register(project) + r = client.get("/api/file", params={"path": str(project / "src" / "a.txt")}) + assert r.status_code == 200 + assert r.text == "hello" + assert r.headers["content-type"].startswith("text/plain") - def test_local_src_blocked_via_manifest_keeps_file_endpoint_clean(self) -> None: - """/api/file trusts `_State.allowed_roots`, which is populated by - successful manifest scans. The local-repo gate sits upstream in - _resolve_scan_target / _serve_manifest, so blocked-local scans - never register a root. Validate the chain by attempting a local - manifest with the gate off — expect 403 and no trust-set growth.""" - import urllib.parse - self.monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False) - q = urllib.parse.urlencode({"src": "/tmp/some-local-path"}) - status, body, _ = self._http.get(self.base + f"/api/manifest?{q}") - self.assertEqual(status, HTTPStatus.FORBIDDEN) - err = json.loads(body)["error"] - self.assertIn("local repositories are disabled", err) +def test_file_outside_root_403(client: TestClient, project: Path, tmp_path: Path) -> None: + TRUST.register(project) + outside = tmp_path / "secret.txt" + outside.write_text("nope") + r = client.get("/api/file", params={"path": str(outside)}) + assert r.status_code == 403 -if __name__ == "__main__": - unittest.main() +def test_file_missing_param_400(client: TestClient) -> None: + r = client.get("/api/file") + assert r.status_code in (400, 422) From 73c970b71a8e9e3001fd5e3f9c32fc5532f4295f Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 2 Jun 2026 21:29:17 -0400 Subject: [PATCH 09/31] feat: /api/commit router (sha validation + multi-root lookup) Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 5 +- api/routers/commit.py | 49 +++++++++++++ api/tests/test_server_commit.py | 125 ++++++++++---------------------- 3 files changed, 91 insertions(+), 88 deletions(-) create mode 100644 api/routers/commit.py diff --git a/api/app.py b/api/app.py index f9dfb5a7..01859424 100644 --- a/api/app.py +++ b/api/app.py @@ -13,7 +13,7 @@ from fastapi.responses import HTMLResponse, JSONResponse from api.config import GZIP_MIN_BYTES -from api.routers import file, health +from api.routers import commit, file, health from api.security import TRUST from api.static import make_static_router @@ -49,6 +49,7 @@ async def _http_exc( # pyright: ignore[reportUnusedFunction] app.include_router(health.router) app.include_router(file.router) - # NOTE: commit/manifest routers added in later tasks, BEFORE static. + app.include_router(commit.router) + # NOTE: manifest router added in later tasks, BEFORE static. app.include_router(make_static_router(static_dir or DEFAULT_STATIC_DIR)) return app diff --git a/api/routers/commit.py b/api/routers/commit.py new file mode 100644 index 00000000..ebc403a5 --- /dev/null +++ b/api/routers/commit.py @@ -0,0 +1,49 @@ +"""GET /api/commit?sha= — commit detail from any registered scan root.""" +from __future__ import annotations + +import re +import subprocess + +from fastapi import APIRouter, HTTPException, Query + +from api.models.responses import CommitDetailResponse +from api.security import TRUST +from api.services.scan import _build_authors_list # pyright: ignore[reportPrivateUsage] + +router = APIRouter(prefix="/api", tags=["commit"]) + +_SHA_RE = re.compile(r"^[0-9a-fA-F]{7,40}$") +_FMT = ( + "%H%x00%an%x00%aI%x00%s%x00" + "%(trailers:key=Co-authored-by,valueonly,separator=%x1f)%x00%b" +) + + +@router.get("/commit", response_model=CommitDetailResponse) +def get_commit(sha: str = Query(...)) -> CommitDetailResponse: # pyright: ignore[reportUnusedFunction] + if not _SHA_RE.match(sha.strip()): + raise HTTPException(400, "invalid or missing sha") + roots = TRUST.snapshot() + if not roots: + raise HTTPException(404, "no scan root registered yet — fetch /api/manifest first") + for root in roots: + try: + out = subprocess.check_output( + ["git", "-c", "safe.directory=*", "-C", str(root), + "show", "-s", f"--format={_FMT}", sha.strip()], + stderr=subprocess.DEVNULL, text=True, + ) + except subprocess.CalledProcessError: + continue + parts = out.rstrip("\n").split("\x00", 5) + if len(parts) < 6: + continue + full_sha, author, iso_date, subject, trailers_raw, body = parts + return CommitDetailResponse( + sha=full_sha, + authors=_build_authors_list(author, trailers_raw), + date=iso_date[:10], + subject=subject, + body=body, + ) + raise HTTPException(404, "sha not found in any registered scan root") diff --git a/api/tests/test_server_commit.py b/api/tests/test_server_commit.py index 42d31683..3f261d29 100644 --- a/api/tests/test_server_commit.py +++ b/api/tests/test_server_commit.py @@ -1,105 +1,58 @@ -"""Tests for GET /api/commit?sha=.""" +"""TestClient coverage for /api/commit (sha validation + lookup).""" from __future__ import annotations -import json import subprocess -import unittest from pathlib import Path import pytest +from fastapi.testclient import TestClient -from api import server as server_mod -from api.server import start_server +from api.app import create_app +from api.security import TRUST -FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" -FIXTURE = FIXTURES_DIR / "sample-repo" +def _git(*args: str, cwd: Path) -> str: + return subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, text=True + ).stdout.strip() -class TestServeCommitDetail(unittest.TestCase): - """Coverage for /api/commit — full commit message detail endpoint.""" - @pytest.fixture(autouse=True) - def _setup_fixtures(self, redirect_cache_root, http_helpers, monkeypatch) -> None: - self.cache_root = redirect_cache_root - self._http = http_helpers - self.monkeypatch = monkeypatch +@pytest.fixture() +def repo(tmp_path: Path) -> Path: + p = tmp_path / "repo" + p.mkdir() + _git("init", "-q", cwd=p) + _git("config", "user.email", "a@b.c", cwd=p) + _git("config", "user.name", "Tester", cwd=p) + (p / "f.txt").write_text("x") + _git("add", ".", cwd=p) + _git("commit", "-qm", "first commit\n\nbody line", cwd=p) + return p - def setUp(self) -> None: - super().setUp() - self.server, self.port, self.shutdown = start_server(port=0) - self.addCleanup(self.shutdown) - self.base = f"http://127.0.0.1:{self.port}" - # Register the fixture as an allowed scan root so /api/commit can - # resolve shas inside it. Mirrors what _serve_manifest does on a - # successful scan; we call it directly to avoid a real HTTP round-trip. - server_mod._State.allowed_roots.add(FIXTURE.resolve()) - def _get_commit(self, sha: str): - status, body = self._http.request(self.port, f"/api/commit?sha={sha}") - return status, body +@pytest.fixture() +def client(tmp_path: Path) -> TestClient: + static = tmp_path / "static" + static.mkdir() + (static / "index.html").write_text("x") + return TestClient(create_app(static_dir=static)) - def test_returns_404_on_unknown_sha(self) -> None: - status, body = self._get_commit("a" * 40) - self.assertEqual(status, 404) - self.assertIn("error", body) - def test_returns_400_on_invalid_sha_shape(self) -> None: - for bad in ("", "xyz", "12", "Z" * 40, "g" * 7): - with self.subTest(sha=bad): - status, body = self._get_commit(bad) - self.assertEqual(status, 400) - self.assertIn("error", body) +def test_commit_invalid_sha_400(client: TestClient) -> None: + assert client.get("/api/commit", params={"sha": "zzz"}).status_code == 400 - def test_returns_authors_subject_body_for_valid_sha(self) -> None: - # The fixture's last commit is the multi-author "feat: co-authored work" one. - sha = subprocess.check_output( - ["git", "-C", str(FIXTURE), "rev-parse", "HEAD"], text=True, - ).strip() - status, body = self._get_commit(sha) - self.assertEqual(status, 200) - self.assertEqual(body["sha"], sha) - self.assertEqual( - body["authors"], - [ - "Test Fixture Bot", - "Pair Programmer", - "Reviewer Person", - "emailonly-bot", - ], - ) - self.assertEqual(body["date"], "2024-05-15") - self.assertEqual(body["subject"], "feat: co-authored work") - # Multi-line body - self.assertIn("team effort", body["body"]) - # No email in the structured author list (privacy). The raw body - # may still contain the original trailer lines as written by git; - # only the parsed identities are sanitized. - self.assertNotIn("@", json.dumps(body["authors"])) - def test_short_sha_resolves(self) -> None: - full_sha = subprocess.check_output( - ["git", "-C", str(FIXTURE), "rev-parse", "HEAD"], text=True, - ).strip() - short = full_sha[:7] - status, body = self._get_commit(short) - self.assertEqual(status, 200) - self.assertEqual(body["sha"], full_sha) +def test_commit_no_roots_404(client: TestClient) -> None: + r = client.get("/api/commit", params={"sha": "abc1234"}) + assert r.status_code == 404 - def test_commit_unaffected_by_local_gate_when_no_roots_registered(self) -> None: - """/api/commit looks up shas across already-registered scan roots. - The local-repo gate doesn't change /api/commit's behavior on its - own — this test guards against accidentally adding a stray - local-gate check on the commit path.""" - from http import HTTPStatus - self.monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False) - # No roots registered yet → expect 404 (no scan root) not 403. - # The local-repo gate is orthogonal to commit-detail lookup. - status, body, _ = self._http.get( - self.base + "/api/commit?sha=abcdef0123456789", - ) - self.assertEqual(status, HTTPStatus.NOT_FOUND) - - -if __name__ == "__main__": - unittest.main() +def test_commit_lookup_ok(client: TestClient, repo: Path) -> None: + TRUST.register(repo) + sha = _git("rev-parse", "HEAD", cwd=repo) + r = client.get("/api/commit", params={"sha": sha}) + assert r.status_code == 200 + body = r.json() + assert body["sha"] == sha + assert body["subject"] == "first commit" + assert "Tester" in body["authors"] From ccd7c0bbc26c1789296f5804447a88c666c1abe9 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 2 Jun 2026 21:50:26 -0400 Subject: [PATCH 10/31] feat: source resolution + /api/manifest/signature + cache-delete routes Adds classify/resolve_source helpers and the GET /api/manifest/signature and DELETE /api/manifest/cache FastAPI routes (SSE stream is Task 10). All status codes and error messages match the legacy server.py behavior. Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 4 +- api/routers/manifest.py | 177 +++++++++++++++++++++++++++++ api/tests/test_server_cache.py | 135 ++++++++++------------ api/tests/test_server_signature.py | 92 ++++++--------- 4 files changed, 272 insertions(+), 136 deletions(-) create mode 100644 api/routers/manifest.py diff --git a/api/app.py b/api/app.py index 01859424..18e776f4 100644 --- a/api/app.py +++ b/api/app.py @@ -13,7 +13,7 @@ from fastapi.responses import HTMLResponse, JSONResponse from api.config import GZIP_MIN_BYTES -from api.routers import commit, file, health +from api.routers import commit, file, health, manifest from api.security import TRUST from api.static import make_static_router @@ -50,6 +50,6 @@ async def _http_exc( # pyright: ignore[reportUnusedFunction] app.include_router(health.router) app.include_router(file.router) app.include_router(commit.router) - # NOTE: manifest router added in later tasks, BEFORE static. + app.include_router(manifest.router) app.include_router(make_static_router(static_dir or DEFAULT_STATIC_DIR)) return app diff --git a/api/routers/manifest.py b/api/routers/manifest.py new file mode 100644 index 00000000..76474879 --- /dev/null +++ b/api/routers/manifest.py @@ -0,0 +1,177 @@ +"""/api/manifest/signature, DELETE /api/manifest/cache. + +Source classification + resolution shared by the signature route, cache +route, and the SSE manifest stream (added in a later task). Local sources +require CODECITY_ALLOW_LOCAL_REPOS and a git working tree; git URLs are +cloned via the service layer. + +ResolveError carries a status + message; the signature/cache routes turn +it into an HTTPException. The SSE route (added later) turns it into an +`error` event instead (EventSource can't read 4xx bodies).""" +from __future__ import annotations + +import os +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from fastapi import APIRouter, HTTPException, Query + +from api.config import local_repos_allowed +from api.models.manifest import SignatureResponse +from api.models.responses import CacheClearResponse +from api.security import TRUST +from api.services.cache import cache_clear_manifests +from api.services.clone import ( + BranchNotFoundError, + CloneError, + HostUnreachableError, + RepoNotFoundError, + clone_dir_for, + ensure_clone, +) +from api.services.scan import signature_tree + +router = APIRouter(prefix="/api", tags=["manifest"]) + +_LOCAL_PATH_PREFIX = re.compile(r"^(/|~|\./|\.\./|[A-Za-z]:[\\/])") +_GIT_SSH_FORM = re.compile(r"^[^@]+@[^:]+:") + +_NOT_GIT_ERROR = ( + "path is not inside a git working tree. CodeCity requires a git " + "project — try `git init` inside the directory, or paste a git " + "URL instead." +) + +_LOCAL_DISABLED_ERROR = ( + "local repositories are disabled — restart codecity with " + "CODECITY_ALLOW_LOCAL_REPOS=1. " + "See https://github.com/thalida/codecity#local-directories" +) + + +@dataclass +class ResolveError(Exception): + status: int + message: str + + +@dataclass +class Resolved: + path: Path + src: str + branch: str | None + kind: Literal["local", "git"] + display_root: str + + +def classify(raw: str) -> Literal["local", "git", "invalid"]: + """Classify a raw ?src= value as a local path, a git URL, or invalid. + + Path-like prefixes (absolute, home, relative, Windows drive) → 'local'. + URLs (scheme:// or git@host:path SSH form) → 'git'. + Anything else → 'invalid'. + """ + if not raw: + return "invalid" + if _LOCAL_PATH_PREFIX.match(raw): + return "local" + if "://" in raw or _GIT_SSH_FORM.match(raw): + return "git" + return "invalid" + + +def _is_git_working_tree(path: Path) -> bool: + """Return True if path is inside a git working tree. + + Runs git rev-parse --is-inside-work-tree with cwd=path: + - working tree (top-level OR subdir OR linked worktree) → "true" + - bare repo → "false" + - non-git directory → command fails with non-zero exit + + Failures (missing git binary, timeout, OS error) all fall through to + False — better to reject with a clear message than to scan a path we + can't verify.""" + try: + r = subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=str(path), + capture_output=True, + text=True, + check=False, + timeout=5, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return r.returncode == 0 and r.stdout.strip() == "true" + + +def resolve_source(src: str, branch: str | None) -> Resolved: + """Resolve a raw ?src into a scan target. Raises ResolveError on any + validation failure. For git URLs this performs the clone (network).""" + if not src: + raise ResolveError(400, "missing 'src' query param") + kind = classify(src) + if kind == "invalid": + raise ResolveError(400, "unrecognized source — pass a local path or a git URL") + if kind == "git": + display = f"{src}@{branch}" if branch else src + try: + with TRUST.clone_lock: + local = ensure_clone(src, branch) + except (BranchNotFoundError, RepoNotFoundError, HostUnreachableError) as e: + raise ResolveError(400, str(e)) + except CloneError as e: + raise ResolveError(502, str(e)) + return Resolved(local, src, branch, "git", display) + # kind == "local" — ignore any branch, scan the working tree in place + if not local_repos_allowed(): + raise ResolveError(403, _LOCAL_DISABLED_ERROR) + try: + target = Path(src).resolve(strict=True) + except (OSError, RuntimeError): + raise ResolveError(404, "path not found") + if not target.is_dir(): + raise ResolveError(400, "path is not a directory") + if not _is_git_working_tree(target): + raise ResolveError(400, _NOT_GIT_ERROR) + return Resolved(target, src, None, "local", src) + + +@router.get("/manifest/signature", response_model=SignatureResponse) +def signature( + src: str = Query(...), + branch: str | None = Query(None), + no_cache: bool = Query(False), +) -> SignatureResponse: + try: + resolved = resolve_source(src, branch) + except ResolveError as e: + raise HTTPException(e.status, e.message) + try: + sig = signature_tree(str(resolved.path), use_cache=not no_cache) + except Exception as e: # noqa: BLE001 + raise HTTPException(500, f"signature failed: {e}") + return SignatureResponse.model_validate(dict(sig)) + + +@router.delete("/manifest/cache", response_model=CacheClearResponse) +def clear_cache( + src: str = Query(...), + branch: str | None = Query(None), +) -> CacheClearResponse: + if not src: + raise HTTPException(400, "missing 'src' query param") + kind = classify(src) + if kind == "invalid": + raise HTTPException(400, "unrecognized source — pass a local path or a git URL") + if kind == "git": + abs_root = clone_dir_for(src, branch) + else: + # Non-strict resolve so a recents entry for a since-deleted path + # still drops its cache. + abs_root = Path(src).resolve(strict=False) + return CacheClearResponse(deleted=cache_clear_manifests(abs_root)) diff --git a/api/tests/test_server_cache.py b/api/tests/test_server_cache.py index 053ed7b8..690c4ddf 100644 --- a/api/tests/test_server_cache.py +++ b/api/tests/test_server_cache.py @@ -1,84 +1,63 @@ -"""Tests for DELETE /api/manifest/cache (split from test_server.py).""" - +"""TestClient coverage for DELETE /api/manifest/cache.""" from __future__ import annotations -import unittest +import subprocess from pathlib import Path -from tempfile import TemporaryDirectory import pytest +from fastapi.testclient import TestClient + +from api.app import create_app +from api.services.cache import cache_save_manifest +from api.services.scan import signature_tree + + +def _git(*a: str, cwd: Path) -> None: + subprocess.run(["git", *a], cwd=cwd, check=True, capture_output=True) + + +@pytest.fixture() +def repo(tmp_path: Path) -> Path: + p = tmp_path / "repo" + p.mkdir() + _git("init", "-q", cwd=p) + _git("config", "user.email", "a@b.c", cwd=p) + _git("config", "user.name", "T", cwd=p) + (p / "f.txt").write_text("x") + _git("add", ".", cwd=p) + _git("commit", "-qm", "c", cwd=p) + return p + + +@pytest.fixture() +def client(tmp_path: Path, redirect_cache_root) -> TestClient: + static = tmp_path / "static" + static.mkdir() + (static / "index.html").write_text("x") + return TestClient(create_app(static_dir=static)) + + +def test_cache_missing_src(client: TestClient) -> None: + assert client.delete("/api/manifest/cache").status_code in (400, 422) + + +def test_cache_invalid_src_400(client: TestClient) -> None: + r = client.delete("/api/manifest/cache", params={"src": "neither-path-nor-url"}) + assert r.status_code == 400 + + +def test_cache_clears_warmed_local_source(client: TestClient, repo: Path) -> None: + # Warm the cache directly via the service layer (no SSE stream yet). + sig = signature_tree(str(repo), use_cache=False)["signature"] + cache_save_manifest(repo.resolve(), sig, {"root": str(repo)}) # type: ignore[arg-type] + r = client.delete("/api/manifest/cache", params={"src": str(repo)}) + assert r.status_code == 200 + assert r.json()["deleted"] >= 1 + -from api.server import start_server - - -class ManifestCacheDeleteTests(unittest.TestCase): - """DELETE /api/manifest/cache wipes every cached manifest for a - given source. Used by the frontend's recents-remove flow.""" - - @pytest.fixture(autouse=True) - def _setup_fixtures( - self, redirect_cache_root, init_git_repo, http_helpers, - ) -> None: - self.cache_root = redirect_cache_root - self._init_git_repo = init_git_repo - self._http = http_helpers - - def setUp(self) -> None: - super().setUp() - self.server, self.server_port, self.shutdown = start_server(port=0) - self.addCleanup(self.shutdown) - - def test_clears_cache_for_local_source(self) -> None: - with TemporaryDirectory() as td: - self._init_git_repo(Path(td)) - (Path(td) / "a.py").write_text("x = 1\n") - # Warm the cache by hitting /api/manifest once. - self._http.request_stream(self.server_port, f"/api/manifest?src={td}") - manifests_dir = self.cache_root / "manifests" - self.assertEqual(len(list(manifests_dir.iterdir())), 1) - - # DELETE the cache for this source. - url = ( - f"http://127.0.0.1:{self.server_port}/api/manifest/cache" - f"?src={td}" - ) - status, body = self._http.delete(url) - self.assertEqual(status, 200) - self.assertEqual(body, {"deleted": 1}) - self.assertEqual(list(manifests_dir.iterdir()), []) - - def test_missing_src_returns_400(self) -> None: - url = f"http://127.0.0.1:{self.server_port}/api/manifest/cache" - status, body = self._http.delete(url) - self.assertEqual(status, 400) - self.assertIn("missing", body["error"]) - - def test_invalid_src_returns_400(self) -> None: - url = ( - f"http://127.0.0.1:{self.server_port}/api/manifest/cache" - f"?src=neither-a-path-nor-a-url" - ) - status, body = self._http.delete(url) - self.assertEqual(status, 400) - self.assertIn("unrecognized", body["error"]) - - def test_no_cache_entries_returns_zero(self) -> None: - # Path was never scanned — DELETE is a no-op success. - with TemporaryDirectory() as td: - url = ( - f"http://127.0.0.1:{self.server_port}/api/manifest/cache" - f"?src={td}" - ) - status, body = self._http.delete(url) - self.assertEqual(status, 200) - self.assertEqual(body, {"deleted": 0}) - - def test_unknown_delete_route_returns_404(self) -> None: - url = f"http://127.0.0.1:{self.server_port}/api/nope" - status, body = self._http.delete(url) - self.assertEqual(status, 404) - self.assertIn("unknown api route", body["error"]) - - -if __name__ == "__main__": - unittest.main() +def test_cache_delete_not_gated_by_local_repos(client: TestClient, repo: Path, monkeypatch) -> None: + # Cache-delete must work even when local repos are disabled. + monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False) + r = client.delete("/api/manifest/cache", params={"src": str(repo)}) + assert r.status_code == 200 + assert "deleted" in r.json() diff --git a/api/tests/test_server_signature.py b/api/tests/test_server_signature.py index 864b5982..668edd60 100644 --- a/api/tests/test_server_signature.py +++ b/api/tests/test_server_signature.py @@ -1,72 +1,52 @@ -"""Tests for /api/manifest/signature (split from test_server.py).""" - +"""TestClient coverage for /api/manifest/signature + local-repo gating.""" from __future__ import annotations -import json -import unittest -import urllib.parse -from http import HTTPStatus +import subprocess from pathlib import Path -from tempfile import TemporaryDirectory import pytest +from fastapi.testclient import TestClient + +from api.app import create_app + -from api.server import start_server +def _git(*a: str, cwd: Path) -> None: + subprocess.run(["git", *a], cwd=cwd, check=True, capture_output=True) -class SignatureRouteTests(unittest.TestCase): - """Coverage for /api/manifest/signature — the cheap-poll endpoint - whose digest must match the full manifest's signature.""" +@pytest.fixture() +def repo(tmp_path: Path) -> Path: + p = tmp_path / "repo" + p.mkdir() + _git("init", "-q", cwd=p) + _git("config", "user.email", "a@b.c", cwd=p) + _git("config", "user.name", "T", cwd=p) + (p / "f.txt").write_text("x") + _git("add", ".", cwd=p) + _git("commit", "-qm", "c", cwd=p) + return p - @pytest.fixture(autouse=True) - def _setup_fixtures( - self, redirect_cache_root, init_git_repo, http_helpers, - ) -> None: - self.cache_root = redirect_cache_root - self._init_git_repo = init_git_repo - self._http = http_helpers - def setUp(self) -> None: - super().setUp() - self.tmp = TemporaryDirectory() - self.addCleanup(self.tmp.cleanup) - static = Path(self.tmp.name) / "static" - static.mkdir() - (static / "index.html").write_text("hi") +@pytest.fixture() +def client(tmp_path: Path, redirect_cache_root) -> TestClient: + static = tmp_path / "static" + static.mkdir() + (static / "index.html").write_text("x") + return TestClient(create_app(static_dir=static)) - self.project = Path(self.tmp.name) / "project" - self.project.mkdir() - self._init_git_repo(self.project) - (self.project / "README.md").write_text("# demo\n") - self.server, self.port, self.shutdown = start_server(port=0, static_dir=static) - self.addCleanup(self.shutdown) - self.base = f"http://127.0.0.1:{self.port}" +def test_signature_missing_src_400(client: TestClient) -> None: + assert client.get("/api/manifest/signature").status_code in (400, 422) - def test_signature_route_matches_manifest_signature(self) -> None: - # The contract powering the cheap-poll: the signature endpoint - # returns the same digest the full manifest would have produced. - q = urllib.parse.urlencode({"src": str(self.project)}) - m_status, m_events = self._http.request_stream(self.port, f"/api/manifest?{q}") - s_status, s_body, s_ctype = self._http.get(self.base + f"/api/manifest/signature?{q}") - self.assertEqual(m_status, HTTPStatus.OK) - self.assertEqual(s_status, HTTPStatus.OK) - self.assertIn("application/json", s_ctype) - manifest = next(e for e in m_events if e["phase"] == "final")["manifest"] - sig = json.loads(s_body) - self.assertEqual(sig["signature"], manifest["signature"]) - # Lean shape — no tree / repo fields on the signature endpoint. - self.assertNotIn("tree", sig) - self.assertNotIn("repo", sig) - def test_signature_route_missing_query_returns_400(self) -> None: - status, _, _ = self._http.get(self.base + "/api/manifest/signature") - self.assertEqual(status, HTTPStatus.BAD_REQUEST) +def test_signature_local_disabled_403(client: TestClient, repo: Path, monkeypatch) -> None: + monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False) + r = client.get("/api/manifest/signature", params={"src": str(repo)}) + assert r.status_code == 403 - def test_signature_route_nonexistent_path_returns_404(self) -> None: - q = urllib.parse.urlencode({"src": str(self.project / "nope")}) - status, _, _ = self._http.get(self.base + f"/api/manifest/signature?{q}") - self.assertEqual(status, HTTPStatus.NOT_FOUND) -if __name__ == "__main__": - unittest.main() +def test_signature_ok(client: TestClient, repo: Path, allow_local_repos) -> None: + r = client.get("/api/manifest/signature", params={"src": str(repo)}) + assert r.status_code == 200 + body = r.json() + assert set(body) == {"root", "scanned_at", "signature"} From e8ff324d308e9eadc76c37ac82765d470721defd Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 2 Jun 2026 21:56:02 -0400 Subject: [PATCH 11/31] feat: SSE /api/manifest stream (errors as events, cancel via is_disconnected, cache-on-final) --- api/routers/manifest.py | 131 ++++- api/tests/test_server_manifest.py | 934 +++--------------------------- 2 files changed, 222 insertions(+), 843 deletions(-) diff --git a/api/routers/manifest.py b/api/routers/manifest.py index 76474879..54aa6337 100644 --- a/api/routers/manifest.py +++ b/api/routers/manifest.py @@ -10,20 +10,28 @@ `error` event instead (EventSource can't read 4xx bodies).""" from __future__ import annotations +import asyncio +import json import os import re import subprocess +import threading from dataclasses import dataclass from pathlib import Path -from typing import Literal +from typing import Any, AsyncIterator, Literal -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, HTTPException, Query, Request +from sse_starlette.sse import EventSourceResponse from api.config import local_repos_allowed from api.models.manifest import SignatureResponse from api.models.responses import CacheClearResponse from api.security import TRUST -from api.services.cache import cache_clear_manifests +from api.services.cache import ( + cache_clear_manifests, + cache_load_manifest, + cache_save_manifest, +) from api.services.clone import ( BranchNotFoundError, CloneError, @@ -32,7 +40,7 @@ clone_dir_for, ensure_clone, ) -from api.services.scan import signature_tree +from api.services.scan import ScanCancelledError, scan_tree, signature_tree router = APIRouter(prefix="/api", tags=["manifest"]) @@ -175,3 +183,118 @@ def clear_cache( # still drops its cache. abs_root = Path(src).resolve(strict=False) return CacheClearResponse(deleted=cache_clear_manifests(abs_root)) + + +def _sse(event: str, payload: dict[str, Any]) -> dict[str, Any]: + """sse-starlette event dict: {'event': name, 'data': json-string}.""" + return {"event": event, "data": json.dumps(payload)} + + +@router.get("/manifest") +async def manifest( # pyright: ignore[reportUnusedFunction] + request: Request, + src: str = Query(""), + branch: str | None = Query(None), + no_cache: bool = Query(False), +) -> EventSourceResponse: + use_cache = not no_cache + + async def gen() -> AsyncIterator[dict[str, Any]]: + # Resolve (incl. git clone) — failures become error EVENTS, not 4xx + # (EventSource can't read 4xx bodies). + try: + resolved = await asyncio.to_thread(resolve_source, src, branch) + except ResolveError as e: + yield _sse("error", {"error": e.message}) + return + + display = resolved.display_root + if resolved.kind == "git": + yield _sse("cloning", {"display_root": display}) + yield _sse("scanning", {"display_root": display}) + + TRUST.register(resolved.path) + + # Signature probe (cache key). + try: + sig = (await asyncio.to_thread( + signature_tree, str(resolved.path), use_cache=use_cache + ))["signature"] + except Exception as e: # noqa: BLE001 + yield _sse("error", {"error": f"scan failed: {e}"}) + return + + # Warm cache hit -> single final event. + if use_cache: + cached = await asyncio.to_thread( + cache_load_manifest, resolved.path.resolve(), sig + ) + if cached is not None: + if resolved.kind == "git": + cached["display_root"] = display + yield _sse("final", {"manifest": cached}) + return + + # Cold scan: run scan_tree on a worker thread, bridge its events + # (skeleton/final) + heartbeat progress through an asyncio.Queue. + cancel = threading.Event() + loop = asyncio.get_running_loop() + q: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue() + final_holder: dict[str, Any] = {"manifest": None, "err": None} + + def _put(item: dict[str, Any] | None) -> None: + loop.call_soon_threadsafe(q.put_nowait, item) + + def _on_progress(files_scanned: int) -> None: + _put(_sse("scanning", {"display_root": display, "files_scanned": files_scanned})) + + def _run() -> None: + try: + for ev in scan_tree( + str(resolved.path), use_cache=use_cache, + cancel_event=cancel, on_scan_progress=_on_progress, + ): + phase = ev["phase"] # "skeleton" | "final" + m = ev["manifest"] + if resolved.kind == "git": + m["display_root"] = display + if phase == "final": + final_holder["manifest"] = m + _put(_sse(phase, {"manifest": m})) + except ScanCancelledError: + pass + except Exception as e: # noqa: BLE001 + final_holder["err"] = e + _put(_sse("error", {"error": f"scan failed: {e}"})) + finally: + _put(None) # sentinel + + worker = threading.Thread(target=_run, daemon=True) + worker.start() + + disconnected = False + try: + while True: + if await request.is_disconnected(): + disconnected = True + break + try: + item = await asyncio.wait_for(q.get(), timeout=0.5) + except asyncio.TimeoutError: + continue + if item is None: + break + yield item + finally: + cancel.set() + await asyncio.to_thread(worker.join, 2.0) + + # ALWAYS write cache on a clean final (read gated by no_cache; write + # is not). Skipped only on disconnect or scan error. + final = final_holder["manifest"] + if final is not None and final_holder["err"] is None and not disconnected: + await asyncio.to_thread( + cache_save_manifest, resolved.path.resolve(), sig, final + ) + + return EventSourceResponse(gen()) diff --git a/api/tests/test_server_manifest.py b/api/tests/test_server_manifest.py index 8e3fd283..9ad0d83a 100644 --- a/api/tests/test_server_manifest.py +++ b/api/tests/test_server_manifest.py @@ -1,845 +1,101 @@ -"""Tests for /api/manifest streaming and its supporting machinery -(split from test_server.py). - -Includes the route-level coverage plus the helpers and infra that back -the streaming endpoint (source classification, scan-target resolution, -disconnect handling, NDJSON event streaming, git working-tree gating).""" - +"""TestClient coverage for the /api/manifest SSE stream (happy path + errors + cache).""" from __future__ import annotations -import gzip -import io import json -import socket -import threading -import unittest -import urllib.parse -import urllib.request -import zlib -from http import HTTPStatus +import subprocess from pathlib import Path -from tempfile import TemporaryDirectory -from unittest import mock import pytest - -from api import clone as clone_mod -from api import server as server_mod -from api.server import ( - _classify_source, - _start_disconnect_watchdog, - _stream_events, - start_server, -) - - -class ManifestRouteTests(unittest.TestCase): - """Coverage for /api/manifest at the HTTP layer — query handling, - no_cache parsing, gzip negotiation, error codes.""" - - @pytest.fixture(autouse=True) - def _setup_fixtures( - self, redirect_cache_root, init_git_repo, http_helpers, monkeypatch, - ) -> None: - self.cache_root = redirect_cache_root - self._init_git_repo = init_git_repo - self._http = http_helpers - self.monkeypatch = monkeypatch - - def setUp(self) -> None: - super().setUp() - self.tmp = TemporaryDirectory() - self.addCleanup(self.tmp.cleanup) - static = Path(self.tmp.name) / "static" - static.mkdir() - (static / "index.html").write_text("hi") - - # A small directory we can scan in the manifest test. Initialized - # as a git repo because the API now requires every local scan - # target to be inside a git working tree. - self.project = Path(self.tmp.name) / "project" - self.project.mkdir() - self._init_git_repo(self.project) - (self.project / "README.md").write_text("# demo\n") - - self.server, self.port, self.shutdown = start_server(port=0, static_dir=static) - self.addCleanup(self.shutdown) - self.base = f"http://127.0.0.1:{self.port}" - - def test_manifest_route_scans_query_path(self) -> None: - q = urllib.parse.urlencode({"src": str(self.project)}) - status, events = self._http.request_stream(self.port, f"/api/manifest?{q}") - self.assertEqual(status, HTTPStatus.OK) - final = next(e for e in events if e["phase"] == "final") - payload = final["manifest"] - self.assertEqual(payload["tree"]["name"], "project") - self.assertIn("signature", payload) - # Successful scan must register the root. - self.assertIn(self.project.resolve(), server_mod._State.allowed_roots) - - def test_manifest_missing_query_returns_400(self) -> None: - status, body, _ = self._http.get(self.base + "/api/manifest") - self.assertEqual(status, HTTPStatus.BAD_REQUEST) - self.assertIn("missing", json.loads(body)["error"]) - - def test_manifest_nonexistent_path_returns_404(self) -> None: - q = urllib.parse.urlencode({"src": str(self.project / "nope")}) - status, _, _ = self._http.get(self.base + f"/api/manifest?{q}") - self.assertEqual(status, HTTPStatus.NOT_FOUND) - - def test_no_cache_query_param_truthy_parsing(self) -> None: - from api.server import _parse_no_cache - self.assertTrue(_parse_no_cache("no_cache=true")) - self.assertTrue(_parse_no_cache("no_cache=TRUE")) - self.assertTrue(_parse_no_cache("no_cache=1")) - self.assertFalse(_parse_no_cache("no_cache=false")) - self.assertFalse(_parse_no_cache("no_cache=0")) - self.assertFalse(_parse_no_cache("")) - self.assertFalse(_parse_no_cache("path=/tmp")) - - def test_manifest_response_gzipped_when_requested(self) -> None: - # Client advertises gzip; server compresses the NDJSON stream; - # decompressed body parses line-by-line as JSON events. - q = urllib.parse.urlencode({"src": str(self.project)}) - status, body, ctype, enc = self._http.get_with_headers( - self.base + f"/api/manifest?{q}", - {"Accept-Encoding": "gzip"}, - ) - self.assertEqual(status, HTTPStatus.OK) - self.assertEqual(enc, "gzip") - self.assertEqual(ctype, "application/x-ndjson") - decoded = gzip.decompress(body) - events = [json.loads(line) for line in decoded.splitlines() if line] - final = next(e for e in events if e["phase"] == "final") - self.assertEqual(final["manifest"]["tree"]["name"], "project") - - def test_manifest_response_uncompressed_without_accept_encoding(self) -> None: - # No Accept-Encoding header at all -> no Content-Encoding, - # body parses directly as NDJSON. - q = urllib.parse.urlencode({"src": str(self.project)}) - status, body, ctype, enc = self._http.get_with_headers( - self.base + f"/api/manifest?{q}", {}, - ) - self.assertEqual(status, HTTPStatus.OK) - self.assertEqual(enc, "") - self.assertEqual(ctype, "application/x-ndjson") - events = [json.loads(line) for line in body.splitlines() if line] - final = next(e for e in events if e["phase"] == "final") - self.assertEqual(final["manifest"]["tree"]["name"], "project") - - def test_manifest_response_uncompressed_when_gzip_not_in_accept(self) -> None: - # Client supports brotli but not gzip -> no compression. - q = urllib.parse.urlencode({"src": str(self.project)}) - status, body, _, enc = self._http.get_with_headers( - self.base + f"/api/manifest?{q}", - {"Accept-Encoding": "br"}, - ) - self.assertEqual(status, HTTPStatus.OK) - self.assertEqual(enc, "") - # Sanity: each line is valid JSON. - events = [json.loads(line) for line in body.splitlines() if line] - self.assertGreaterEqual(len(events), 1) - - def test_manifest_local_src_403_when_disabled(self) -> None: - self.monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False) - q = urllib.parse.urlencode({"src": str(self.project)}) - status, body, _ = self._http.get(self.base + f"/api/manifest?{q}") - self.assertEqual(status, HTTPStatus.FORBIDDEN) - err = json.loads(body)["error"] - self.assertIn("local repositories are disabled", err) - self.assertIn("CODECITY_ALLOW_LOCAL_REPOS=1", err) - - def test_manifest_local_src_succeeds_when_enabled(self) -> None: - self.monkeypatch.setenv("CODECITY_ALLOW_LOCAL_REPOS", "1") - q = urllib.parse.urlencode({"src": str(self.project)}) - status, events = self._http.request_stream( - self.port, f"/api/manifest?{q}", - ) - self.assertEqual(status, HTTPStatus.OK) - # 'final' phase event is the manifest body. - final = next(e for e in events if e["phase"] == "final") - self.assertEqual(final["manifest"]["tree"]["name"], "project") - - -class ClassifySourceTests(unittest.TestCase): - def test_absolute_path(self) -> None: - self.assertEqual(_classify_source("/Users/foo/bar"), "local") - - def test_home_path(self) -> None: - self.assertEqual(_classify_source("~/code/foo"), "local") - - def test_relative_path_dot(self) -> None: - self.assertEqual(_classify_source("./foo"), "local") - self.assertEqual(_classify_source("../foo"), "local") - - def test_windows_drive_path(self) -> None: - self.assertEqual(_classify_source("C:\\Users\\foo"), "local") - self.assertEqual(_classify_source("D:/foo/bar"), "local") - - def test_https_url(self) -> None: - self.assertEqual(_classify_source("https://github.com/owner/repo"), "git") - self.assertEqual(_classify_source("http://example.com/x.git"), "git") - - def test_git_ssh_url(self) -> None: - self.assertEqual(_classify_source("git@github.com:owner/repo.git"), "git") - - def test_garbage(self) -> None: - self.assertEqual(_classify_source("garbage"), "invalid") - self.assertEqual(_classify_source(""), "invalid") - self.assertEqual(_classify_source("just-a-word"), "invalid") - - -class ResolveScanTargetTests(unittest.TestCase): - """Behavior tests via the HTTP layer, since _resolve_scan_target is internal.""" - - @pytest.fixture(autouse=True) - def _setup_fixtures(self, init_git_repo, http_helpers) -> None: - self._init_git_repo = init_git_repo - self._http = http_helpers - - def setUp(self) -> None: - self.server, self.server_port, self.shutdown = start_server(port=0) - self.addCleanup(self.shutdown) - - def test_local_path_ok(self) -> None: - with TemporaryDirectory() as td: - self._init_git_repo(Path(td)) - (Path(td) / "x.py").write_text("print('hi')\n") - status, events = self._http.request_stream( - self.server_port, f"/api/manifest?src={td}", - ) - self.assertEqual(status, 200) - final = next(e for e in events if e["phase"] == "final")["manifest"] - # resolve() follows macOS /var -> /private/var symlinks; the - # manifest's root field reflects the real resolved path. - self.assertEqual(final.get("root"), str(Path(td).resolve())) - - def test_local_path_with_branch_silently_ignored(self) -> None: - with TemporaryDirectory() as td: - self._init_git_repo(Path(td)) - (Path(td) / "x.py").write_text("print('hi')\n") - status, events = self._http.request_stream( - self.server_port, f"/api/manifest?src={td}&branch=main", - ) - self.assertEqual(status, 200) - final = next(e for e in events if e["phase"] == "final")["manifest"] - # display_root not set for in-place local scan - self.assertNotIn("display_root", final) - - def test_invalid_source(self) -> None: - status, body = self._http.request(self.server_port, "/api/manifest?src=garbage") - self.assertEqual(status, 400) - self.assertIn("unrecognized source", body.get("error", "").lower()) - - def test_missing_src(self) -> None: - status, body = self._http.request(self.server_port, "/api/manifest") - self.assertEqual(status, 400) - self.assertIn("'src'", body.get("error", "")) - - def test_old_path_param_rejected(self) -> None: - # ?path= is no longer recognized — server should 400 missing 'src'. - with TemporaryDirectory() as td: - status, body = self._http.request(self.server_port, f"/api/manifest?path={td}") - self.assertEqual(status, 400) - self.assertIn("'src'", body.get("error", "")) - - def test_nonexistent_path(self) -> None: - status, body = self._http.request(self.server_port, "/api/manifest?src=/this/does/not/exist/xyzzy") - self.assertEqual(status, 404) - self.assertIn("path not found", body.get("error", "")) - - def test_path_is_file_not_directory(self) -> None: - with TemporaryDirectory() as td: - f = Path(td) / "afile.txt" - f.write_text("hi") - status, body = self._http.request(self.server_port, f"/api/manifest?src={f}") - self.assertEqual(status, 400) - self.assertIn("not a directory", body.get("error", "")) - - -class DisplayRootTests(unittest.TestCase): - @pytest.fixture(autouse=True) - def _setup_fixtures( - self, init_git_repo, make_fake_remote, http_helpers, - ) -> None: - self._init_git_repo = init_git_repo - self._make_fake_remote = make_fake_remote - self._http = http_helpers - - def setUp(self) -> None: - self.server, self.server_port, self.shutdown = start_server(port=0) - self.addCleanup(self.shutdown) - - def test_local_src_no_display_root(self) -> None: - with TemporaryDirectory() as td: - self._init_git_repo(Path(td)) - (Path(td) / "x.py").write_text("\n") - status, events = self._http.request_stream( - self.server_port, f"/api/manifest?src={td}", - ) - self.assertEqual(status, 200) - final = next(e for e in events if e["phase"] == "final")["manifest"] - self.assertNotIn("display_root", final) - - def test_git_url_sets_display_root(self) -> None: - # Use a local bare repo so we don't hit the network. - with TemporaryDirectory() as td: - remote, _ = self._make_fake_remote(Path(td)) - # Use a file:// URL so _classify_source returns 'git'. - url = f"file://{remote}" - # Monkey-patch CACHE_ROOT so we don't pollute ~/.cache. - with mock.patch.object(clone_mod, "CACHE_ROOT", Path(td) / "cache"): - status, events = self._http.request_stream( - self.server_port, f"/api/manifest?src={url}", - ) - self.assertEqual(status, 200) - final = next(e for e in events if e["phase"] == "final")["manifest"] - self.assertEqual(final.get("display_root"), url) - - def test_git_url_with_branch_appends_at_branch(self) -> None: - with TemporaryDirectory() as td: - remote, _ = self._make_fake_remote(Path(td)) - url = f"file://{remote}" - with mock.patch.object(clone_mod, "CACHE_ROOT", Path(td) / "cache"): - status, events = self._http.request_stream( - self.server_port, f"/api/manifest?src={url}&branch=feature", - ) - self.assertEqual(status, 200) - final = next(e for e in events if e["phase"] == "final")["manifest"] - self.assertEqual(final.get("display_root"), f"{url}@feature") - - def test_cloning_event_includes_display_root(self) -> None: - # The first cloning event must carry display_root so the client - # can show "{label} (pending)" before clone/scan even starts. - with TemporaryDirectory() as td: - remote, _ = self._make_fake_remote(Path(td)) - url = f"file://{remote}" - with mock.patch.object(clone_mod, "CACHE_ROOT", Path(td) / "cache"): - status, events = self._http.request_stream( - self.server_port, f"/api/manifest?src={url}", - ) - self.assertEqual(status, 200) - self.assertEqual(events[0]["phase"], "cloning") - self.assertEqual(events[0].get("display_root"), url) - - def test_scanning_event_includes_display_root(self) -> None: - # Local sources skip cloning; their first event is `scanning`, - # which must also carry display_root (the raw local path the - # caller passed in — the same value the final manifest omits). - with TemporaryDirectory() as td: - self._init_git_repo(Path(td)) - (Path(td) / "x.py").write_text("\n") - status, events = self._http.request_stream( - self.server_port, f"/api/manifest?src={td}", - ) - self.assertEqual(status, 200) - self.assertEqual(events[0]["phase"], "scanning") - self.assertEqual(events[0].get("display_root"), td) - - -class ClientDisconnectTests(unittest.TestCase): - """Browsers routinely close the TCP socket mid-response — tab reload, - navigating away, or giving up on a multi-minute large-repo scan. The - resulting BrokenPipeError / ConnectionResetError surfaces in - BaseServer.handle_error, which would otherwise dump a full traceback - to stderr for every disconnect. The server overrides handle_error to - swallow those specific exceptions while still logging real bugs.""" - - def setUp(self) -> None: - self.server, _port, shutdown = start_server(port=0) - self.addCleanup(shutdown) - - def _drive(self, exc: BaseException) -> str: - with mock.patch("sys.stderr", new_callable=io.StringIO) as stderr: - try: - raise exc - except type(exc): - self.server.handle_error(None, ("127.0.0.1", 12345)) - return stderr.getvalue() - - def test_broken_pipe_is_silent(self) -> None: - self.assertEqual(self._drive(BrokenPipeError(32, "broken pipe")), "") - - def test_connection_reset_is_silent(self) -> None: - self.assertEqual(self._drive(ConnectionResetError(54, "reset")), "") - - def test_connection_aborted_is_silent(self) -> None: - self.assertEqual( - self._drive(ConnectionAbortedError(53, "aborted")), "" - ) - - def test_real_errors_still_log(self) -> None: - out = self._drive(RuntimeError("kaboom")) - self.assertIn("RuntimeError", out) - self.assertIn("kaboom", out) - - -class StreamEventsHelperTests(unittest.TestCase): - """Unit tests for the server's NDJSON streaming primitive. - - Tested with a stub handler whose `wfile` is a BytesIO so we can - inspect the exact wire bytes without spinning up a real server.""" - - def _make_handler(self, accept_encoding: str = "gzip") -> object: - class _Stub: - def __init__(self): - self.wfile = io.BytesIO() - self.headers = {"Accept-Encoding": accept_encoding} - self._sent_status: int | None = None - self._sent_headers: list[tuple[str, str]] = [] - self._ended = False - - def send_response(self, code: int) -> None: - self._sent_status = code - - def send_header(self, k: str, v: str) -> None: - self._sent_headers.append((k, v)) - - def end_headers(self) -> None: - self._ended = True - - return _Stub() - - def test_writes_ndjson_lines(self) -> None: - h = self._make_handler(accept_encoding="identity") - _stream_events(h, [ - {"phase": "skeleton", "manifest": {"x": 1}}, - {"phase": "final", "manifest": {"x": 2}}, - ], threading.Event()) - # identity encoding — wire bytes are plain JSON-lines. - lines = h.wfile.getvalue().decode("utf-8").splitlines() - self.assertEqual(len(lines), 2) - self.assertEqual(json.loads(lines[0])["phase"], "skeleton") - self.assertEqual(json.loads(lines[1])["phase"], "final") - - def test_sends_correct_headers(self) -> None: - h = self._make_handler(accept_encoding="gzip, deflate") - _stream_events(h, [{"phase": "final", "manifest": {}}], threading.Event()) - self.assertEqual(h._sent_status, 200) - header_dict = dict(h._sent_headers) - self.assertEqual(header_dict.get("Content-Type"), "application/x-ndjson") - self.assertEqual(header_dict.get("Content-Encoding"), "gzip") - # No Content-Length — chunked. - self.assertNotIn("Content-Length", header_dict) - - def test_gzip_round_trip(self) -> None: - h = self._make_handler(accept_encoding="gzip") - _stream_events(h, [{"phase": "final", "manifest": {"k": "v"}}], threading.Event()) - decompressed = gzip.decompress(h.wfile.getvalue()) - line = decompressed.decode("utf-8").strip() - self.assertEqual(json.loads(line)["manifest"], {"k": "v"}) - - def test_broken_pipe_sets_cancel_event(self) -> None: - h = self._make_handler(accept_encoding="identity") - # Replace wfile with one that raises on write. - class _Broken: - def write(self, _b): raise BrokenPipeError(32, "broken") - def flush(self): pass - h.wfile = _Broken() - ev = threading.Event() - with self.assertRaises(BrokenPipeError): - _stream_events(h, [{"phase": "final", "manifest": {}}], ev) - self.assertTrue(ev.is_set()) - - def test_gzip_flushes_between_events(self) -> None: - """Regression: GzipFile.write() buffers internally. Without an - explicit flush between events, the skeleton bytes wouldn't reach - the wire until the final event closes the stream. This test - decompresses what's in the buffer AFTER the first event but - BEFORE the second event finishes — that bytestream must contain - the first event's JSON.""" - h = self._make_handler(accept_encoding="gzip") - snapshots: list[bytes] = [] - - def _events(): - yield {"phase": "skeleton", "manifest": {"x": 1}} - # Snapshot the wire bytes BEFORE the final event is written. - snapshots.append(h.wfile.getvalue()) - yield {"phase": "final", "manifest": {"x": 2}} - - _stream_events(h, _events(), threading.Event()) - # Decompress the snapshot. With Z_SYNC_FLUSH, the partial gzip - # stream is decodable up to the sync marker. - # Strip the gzip header (10 bytes) and feed raw DEFLATE to a - # decompressor. Z_SYNC_FLUSH means each flushed block is - # self-contained DEFLATE, so this works. - decomp = zlib.decompressobj(wbits=-zlib.MAX_WBITS) - partial = decomp.decompress(snapshots[0][10:]) - self.assertIn(b'"skeleton"', partial, - "skeleton event must reach wire before final event") - - def test_close_time_broken_pipe_sets_cancel_event(self) -> None: - """If the broken pipe surfaces only at gzip-close time (common in - practice — gzip buffers most writes), cancel_event must still be - set so the surrounding scan thread stops. - - Note: ``GzipFile.close()`` doesn't propagate to - ``fileobj.close()``; the real close-time failure mode is the - trailer ``write()`` call. We simulate that here by making writes - succeed during the event loop (and during the per-event - ``gz.flush()`` sync blocks) but fail once the loop has exited — - i.e. when GzipFile writes its 8-byte trailer.""" - h = self._make_handler(accept_encoding="gzip") - - class _TrailerFails: - def __init__(self) -> None: - self._written: bytes = b"" - self._loop_done: bool = False - - def write(self, b: bytes) -> int: - if self._loop_done: - raise BrokenPipeError(32, "broken at close") - self._written += b - return len(b) - - def flush(self) -> None: - pass - - stub = _TrailerFails() - h.wfile = stub - - def _events(): - try: - yield {"phase": "final", "manifest": {}} - finally: - # Generator finally fires when the for-loop in - # _stream_events exhausts it (next() → StopIteration), - # which happens before gz.close() writes the trailer. - stub._loop_done = True - - ev = threading.Event() - # Event writes + per-boundary flush succeed; the gzip trailer - # write in finally raises BrokenPipeError, which the finally - # block must swallow AND propagate to cancel_event. - _stream_events(h, _events(), ev) - self.assertTrue(ev.is_set(), - "close-time BrokenPipe must set cancel_event") - - -class DisconnectWatchdogTests(unittest.TestCase): - """The watchdog is a daemon thread that polls a connection for - EOF and trips a cancel event. Tested against a real socketpair - so we can close one end and observe the watchdog's reaction.""" - - def test_sets_event_on_client_close(self) -> None: - srv, cli = socket.socketpair() - self.addCleanup(srv.close) - self.addCleanup(cli.close) - - class _Handler: - def __init__(self, s): - self.connection = s - - ev = threading.Event() - t = _start_disconnect_watchdog(_Handler(srv), ev) - # Client closes its end → server-side select wakes, - # MSG_PEEK returns 0 bytes, watchdog sets the event. - cli.close() - self.assertTrue(ev.wait(timeout=2.0), "watchdog should set event within 2s") - t.join(timeout=1.0) - self.assertFalse(t.is_alive()) - - def test_exits_when_event_set_externally(self) -> None: - srv, cli = socket.socketpair() - self.addCleanup(srv.close) - self.addCleanup(cli.close) - - class _Handler: - def __init__(self, s): - self.connection = s - - ev = threading.Event() - t = _start_disconnect_watchdog(_Handler(srv), ev) - # Normal scan finish path: caller signals event; watchdog - # observes it next poll cycle and exits cleanly. - ev.set() - t.join(timeout=2.0) - self.assertFalse(t.is_alive()) - - -class ManifestStreamTests(unittest.TestCase): - """End-to-end /api/manifest tests for the NDJSON streaming - behavior and the disk-cache lifecycle.""" - - @pytest.fixture(autouse=True) - def _setup_fixtures( - self, redirect_cache_root, init_git_repo, http_helpers, - ) -> None: - self.cache_root = redirect_cache_root - self._init_git_repo = init_git_repo - self._http = http_helpers - - def setUp(self) -> None: - super().setUp() - self.server, self.server_port, self.shutdown = start_server(port=0) - self.addCleanup(self.shutdown) - - def _make_tiny_repo(self, td: str) -> None: - # Init as a git working tree — the API now requires every local - # scan target to be inside one. Files are left untracked here; - # tests that need them to appear under the default - # (tracked-only) scan should commit them explicitly. Tests that - # only assert on event ordering / headers don't care. - self._init_git_repo(Path(td)) - (Path(td) / "a.py").write_text("x = 1\n") - (Path(td) / "b.py").write_text("y = 2\ny = 3\n") - - def test_cold_cache_emits_skeleton_then_final(self) -> None: - with TemporaryDirectory() as td: - self._make_tiny_repo(td) - status, events = self._http.request_stream( - self.server_port, f"/api/manifest?src={td}", - ) - self.assertEqual(status, 200) - # Local sources stream: scanning → skeleton → final. The - # scanning marker is a phase-only event with no manifest. - manifest_events = [e for e in events if "manifest" in e] - self.assertEqual(len(manifest_events), 2) - self.assertEqual(manifest_events[0]["phase"], "skeleton") - self.assertEqual(manifest_events[1]["phase"], "final") - - def test_warm_cache_emits_one_final(self) -> None: - with TemporaryDirectory() as td: - self._make_tiny_repo(td) - # Warm the cache. - self._http.request_stream(self.server_port, f"/api/manifest?src={td}") - # Second hit should be a single-final response. - status, events = self._http.request_stream( - self.server_port, f"/api/manifest?src={td}", - ) - self.assertEqual(status, 200) - manifest_events = [e for e in events if "manifest" in e] - self.assertEqual(len(manifest_events), 1) - self.assertEqual(manifest_events[0]["phase"], "final") - - def test_no_cache_skips_lookup_but_still_writes(self) -> None: - with TemporaryDirectory() as td: - self._make_tiny_repo(td) - # Warm the cache first. - self._http.request_stream(self.server_port, f"/api/manifest?src={td}") - # no_cache=true should NOT serve from cache (forces a fresh scan). - _, events = self._http.request_stream( - self.server_port, f"/api/manifest?src={td}&no_cache=true", - ) - manifest_events = [e for e in events if "manifest" in e] - self.assertEqual( - len(manifest_events), 2, "no_cache should force a fresh scan", - ) - # Clear the cache, then run another no_cache scan. `use_cache` - # gates only the READ — a fresh scan must STILL write its result, - # so the next normal load is served up-to-date data instead of a - # stale (or absent) manifest. - import shutil - shutil.rmtree(self.cache_root, ignore_errors=True) - self.cache_root.mkdir(parents=True, exist_ok=True) - self._http.request_stream( - self.server_port, f"/api/manifest?src={td}&no_cache=true", - ) - manifests_dir = self.cache_root / "manifests" - self.assertTrue( - manifests_dir.exists() and list(manifests_dir.iterdir()), - "no_cache=true must still WRITE the manifest cache (read-only flag)", - ) - - def test_skeleton_has_placeholder_lines(self) -> None: - import subprocess - with TemporaryDirectory() as td: - self._make_tiny_repo(td) - # Default scan filters untracked files — commit a.py/b.py so - # they appear in the manifest tree the test inspects below. - for cmd in ( - ["git", "-C", td, "config", "user.email", "t@t"], - ["git", "-C", td, "config", "user.name", "t"], - ["git", "-C", td, "add", "a.py", "b.py"], - ["git", "-C", td, "commit", "-q", "-m", "init"], - ): - subprocess.run(cmd, check=True) - status, events = self._http.request_stream( - self.server_port, f"/api/manifest?src={td}", - ) - manifest_events = [e for e in events if "manifest" in e] - skeleton_tree = manifest_events[0]["manifest"]["tree"] - final_tree = manifest_events[1]["manifest"]["tree"] - - def files(node): - for child in node["children"]: - if child["type"] == "file": - yield child - else: - yield from files(child) - - skeleton_files = {f["name"]: f for f in files(skeleton_tree)} - final_files = {f["name"]: f for f in files(final_tree)} - - # b.py has 2 real lines; skeleton should still report 1. - self.assertEqual(skeleton_files["b.py"]["lines"], 1, - "skeleton must use placeholder lines=1, not real count") - self.assertEqual(final_files["b.py"]["lines"], 2, - "final must report real line count") - # Every skeleton file should be lines=1 (sanity check). - for f in skeleton_files.values(): - self.assertEqual(f["lines"], 1) - - def test_mid_stream_error_emits_error_event(self) -> None: - """If scan_tree_streaming raises unexpectedly after the skeleton - has emitted, the server emits a {phase:'error'} event so the - client gets a clean message, not a truncated stream.""" - from unittest.mock import patch - with TemporaryDirectory() as td: - self._make_tiny_repo(td) - # Patch _populate_file_metadata to blow up. The skeleton has - # already been yielded by the time this runs, so we exercise - # the mid-stream-error path specifically. - with patch("api.scan._populate_file_metadata") as mock_pop: - mock_pop.side_effect = RuntimeError("disk on fire") - status, events = self._http.request_stream( - self.server_port, f"/api/manifest?src={td}", - ) - self.assertEqual(status, 200) - # Expect: skeleton + error (or just error if the skeleton - # boundary check fires first — either is acceptable). - error_events = [e for e in events if e.get("phase") == "error"] - self.assertEqual(len(error_events), 1) - self.assertIn("disk on fire", error_events[0]["error"]) - - def test_response_headers(self) -> None: - with TemporaryDirectory() as td: - self._make_tiny_repo(td) - url = f"http://127.0.0.1:{self.server_port}/api/manifest?src={td}" - req = urllib.request.Request(url, headers={"Accept-Encoding": "gzip"}) - resp = urllib.request.urlopen(req) - self.assertEqual(resp.headers.get("Content-Type"), "application/x-ndjson") - self.assertEqual(resp.headers.get("Content-Encoding"), "gzip") - self.assertIsNone(resp.headers.get("Content-Length")) - resp.read() # drain - - -class GitOnlyLocalPathTests(unittest.TestCase): - """Per task 11c: codecity is git-aware, so local scan targets must be - inside a git working tree. Non-git dirs and bare repos are rejected - with a 400 + helpful message; git URLs are unaffected (the clone IS - a working tree); cache-delete bypasses the check (purely hygienic).""" - - @pytest.fixture(autouse=True) - def _setup_fixtures( - self, redirect_cache_root, init_git_repo, http_helpers, - ) -> None: - self.cache_root = redirect_cache_root - self._init_git_repo = init_git_repo - self._http = http_helpers - - def setUp(self) -> None: - super().setUp() - self.server, self.server_port, self.shutdown = start_server(port=0) - self.addCleanup(self.shutdown) - self.base = f"http://127.0.0.1:{self.server_port}" - - def test_local_path_in_git_repo_accepted(self) -> None: - """A path that IS a git working tree streams a manifest (200).""" - with TemporaryDirectory() as td: - self._init_git_repo(Path(td)) - (Path(td) / "x.py").write_text("print('hi')\n") - status, events = self._http.request_stream( - self.server_port, f"/api/manifest?src={td}", - ) - self.assertEqual(status, HTTPStatus.OK) - # Must reach a final event — the stream actually ran. - self.assertTrue(any(e.get("phase") == "final" for e in events)) - - def test_local_path_not_in_git_repo_rejected(self) -> None: - """A plain directory (no git) → 400 with the helpful message.""" - with TemporaryDirectory() as td: - (Path(td) / "x.py").write_text("print('hi')\n") - status, body = self._http.request( - self.server_port, f"/api/manifest?src={td}", - ) - self.assertEqual(status, HTTPStatus.BAD_REQUEST) - self.assertIn("git working tree", body.get("error", "")) - - def test_local_path_bare_repo_rejected(self) -> None: - """A bare git repo has no working tree, so it must be rejected.""" - with TemporaryDirectory() as parent: - bare = Path(parent) / "bare.git" - self._init_git_repo(bare, bare=True) - status, body = self._http.request( - self.server_port, f"/api/manifest?src={bare}", - ) - self.assertEqual(status, HTTPStatus.BAD_REQUEST) - self.assertIn("git working tree", body.get("error", "")) - - def test_local_subdir_of_git_repo_accepted(self) -> None: - """Subdirs inherit the working-tree property — must be accepted.""" - with TemporaryDirectory() as td: - self._init_git_repo(Path(td)) - sub = Path(td) / "src" - sub.mkdir() - (sub / "x.py").write_text("print('hi')\n") - status, events = self._http.request_stream( - self.server_port, f"/api/manifest?src={sub}", - ) - self.assertEqual(status, HTTPStatus.OK) - self.assertTrue(any(e.get("phase") == "final" for e in events)) - - def test_signature_endpoint_rejects_non_git_path(self) -> None: - """_resolve_scan_target also gates the signature endpoint.""" - with TemporaryDirectory() as td: - (Path(td) / "x.py").write_text("print('hi')\n") - status, body = self._http.request( - self.server_port, f"/api/manifest/signature?src={td}", - ) - self.assertEqual(status, HTTPStatus.BAD_REQUEST) - self.assertIn("git working tree", body.get("error", "")) - - def test_delete_cache_bypasses_git_check(self) -> None: - """Cache deletion is hygienic — it must NOT require a git repo, - so a user can clean up a recents entry whose path was removed - or was never a git project.""" - with TemporaryDirectory() as td: - # No git init — pure non-git directory. - url = ( - f"http://127.0.0.1:{self.server_port}/api/manifest/cache" - f"?src={td}" - ) - status, body = self._http.delete(url) - self.assertEqual(status, HTTPStatus.OK) - # Zero entries (nothing was cached), but the operation itself - # succeeded — that's the contract. - self.assertEqual(body, {"deleted": 0}) - - -class IsGitWorkingTreeHelperTests(unittest.TestCase): - """Direct unit tests for the helper that the server endpoints call.""" - - @pytest.fixture(autouse=True) - def _setup_fixtures(self, init_git_repo) -> None: - self._init_git_repo = init_git_repo - - def test_returns_true_for_working_tree(self) -> None: - from api.server import _is_git_working_tree - with TemporaryDirectory() as td: - self._init_git_repo(Path(td)) - self.assertTrue(_is_git_working_tree(Path(td))) - - def test_returns_true_for_subdir_of_working_tree(self) -> None: - from api.server import _is_git_working_tree - with TemporaryDirectory() as td: - self._init_git_repo(Path(td)) - sub = Path(td) / "nested" - sub.mkdir() - self.assertTrue(_is_git_working_tree(sub)) - - def test_returns_false_for_non_git_directory(self) -> None: - from api.server import _is_git_working_tree - with TemporaryDirectory() as td: - self.assertFalse(_is_git_working_tree(Path(td))) - - def test_returns_false_for_bare_repo(self) -> None: - from api.server import _is_git_working_tree - with TemporaryDirectory() as parent: - bare = Path(parent) / "bare.git" - self._init_git_repo(bare, bare=True) - self.assertFalse(_is_git_working_tree(bare)) - - -if __name__ == "__main__": - unittest.main() +from fastapi.testclient import TestClient + +from api.app import create_app + + +def _git(*a: str, cwd: Path) -> None: + subprocess.run(["git", *a], cwd=cwd, check=True, capture_output=True) + + +@pytest.fixture() +def repo(tmp_path: Path) -> Path: + p = tmp_path / "repo" + p.mkdir() + _git("init", "-q", cwd=p) + _git("config", "user.email", "a@b.c", cwd=p) + _git("config", "user.name", "T", cwd=p) + (p / "f.txt").write_text("hello\nworld\n") + _git("add", ".", cwd=p) + _git("commit", "-qm", "c", cwd=p) + return p + + +@pytest.fixture() +def client(tmp_path: Path, redirect_cache_root) -> TestClient: + static = tmp_path / "static" + static.mkdir() + (static / "index.html").write_text("x") + return TestClient(create_app(static_dir=static)) + + +def _parse_sse(text: str) -> list[tuple[str, dict]]: + events: list[tuple[str, dict]] = [] + name = "message" + data_lines: list[str] = [] + for line in text.splitlines(): + if line.startswith("event:"): + name = line[len("event:"):].strip() + elif line.startswith("data:"): + data_lines.append(line[len("data:"):].strip()) + elif line == "": + if data_lines: + events.append((name, json.loads("".join(data_lines)))) + name, data_lines = "message", [] + return events + + +def test_manifest_stream_local(client: TestClient, repo: Path, allow_local_repos) -> None: + with client.stream("GET", "/api/manifest", params={"src": str(repo), "no_cache": "true"}) as r: + assert r.status_code == 200 + assert "text/event-stream" in r.headers["content-type"] + body = "".join(r.iter_text()) + events = _parse_sse(body) + names = [n for n, _ in events] + assert "scanning" in names + assert names[-1] == "final" + final = events[-1][1] + assert final["manifest"]["root"] + assert final["manifest"]["tree"]["type"] == "directory" + + +def test_manifest_stream_missing_src_emits_error_event(client: TestClient) -> None: + with client.stream("GET", "/api/manifest") as r: + assert r.status_code == 200 + body = "".join(r.iter_text()) + events = _parse_sse(body) + assert events[-1][0] == "error" + assert "src" in events[-1][1]["error"] + + +def test_manifest_stream_local_disabled_error_event(client: TestClient, repo: Path, monkeypatch) -> None: + monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False) + with client.stream("GET", "/api/manifest", params={"src": str(repo)}) as r: + body = "".join(r.iter_text()) + events = _parse_sse(body) + assert events[-1][0] == "error" + assert "disabled" in events[-1][1]["error"] + + +def test_manifest_cold_scan_then_warm_cache_hit(client: TestClient, repo: Path, allow_local_repos) -> None: + # First request WITHOUT no_cache: cold scan must emit skeleton+final AND + # write the manifest cache (the bug-fix under test). + with client.stream("GET", "/api/manifest", params={"src": str(repo)}) as r: + cold = _parse_sse("".join(r.iter_text())) + cold_names = [n for n, _ in cold] + assert "skeleton" in cold_names + assert cold_names[-1] == "final" + + # Second identical request: warm-cache hit -> single final, NO skeleton. + with client.stream("GET", "/api/manifest", params={"src": str(repo)}) as r: + warm = _parse_sse("".join(r.iter_text())) + warm_names = [n for n, _ in warm] + assert "skeleton" not in warm_names, f"expected warm hit, got {warm_names}" + assert warm_names[-1] == "final" From c25282a4ddc963598483d4eecf3918ccc903bb51 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 2 Jun 2026 21:59:10 -0400 Subject: [PATCH 12/31] test: real-socket SSE disconnect does not hang server --- api/tests/test_sse_socket.py | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 api/tests/test_sse_socket.py diff --git a/api/tests/test_sse_socket.py b/api/tests/test_sse_socket.py new file mode 100644 index 00000000..470c3ba0 --- /dev/null +++ b/api/tests/test_sse_socket.py @@ -0,0 +1,67 @@ +"""Real-socket SSE: client disconnects mid-stream -> scan cancels, no cache write.""" +from __future__ import annotations + +import socket +import subprocess +import threading +import time +from pathlib import Path + +import pytest +import uvicorn + +from api.app import create_app + + +def _git(*a: str, cwd: Path) -> None: + subprocess.run(["git", *a], cwd=cwd, check=True, capture_output=True) + + +@pytest.fixture() +def repo(tmp_path: Path) -> Path: + p = tmp_path / "repo" + p.mkdir() + _git("init", "-q", cwd=p) + _git("config", "user.email", "a@b.c", cwd=p) + _git("config", "user.name", "T", cwd=p) + for i in range(50): + (p / f"f{i}.txt").write_text("x\n" * 100) + _git("add", ".", cwd=p) + _git("commit", "-qm", "c", cwd=p) + return p + + +@pytest.fixture() +def server(tmp_path: Path, redirect_cache_root): + static = tmp_path / "static" + static.mkdir() + (static / "index.html").write_text("x") + app = create_app(static_dir=static) + config = uvicorn.Config(app, host="127.0.0.1", port=0, log_level="error") + srv = uvicorn.Server(config) + thread = threading.Thread(target=srv.run, daemon=True) + thread.start() + while not srv.started: + time.sleep(0.01) + port = srv.servers[0].sockets[0].getsockname()[1] + yield port + srv.should_exit = True + thread.join(timeout=5) + + +def test_disconnect_midstream_does_not_hang(server, repo, monkeypatch) -> None: + monkeypatch.setenv("CODECITY_ALLOW_LOCAL_REPOS", "1") + port = server + s = socket.create_connection(("127.0.0.1", port), timeout=5) + req = ( + f"GET /api/manifest?src={repo}&no_cache=true HTTP/1.1\r\n" + f"Host: 127.0.0.1\r\nConnection: close\r\n\r\n" + ) + s.sendall(req.encode()) + # Read a little (status + first event), then bail. + s.recv(256) + s.close() + # Server must remain responsive: a fresh health request succeeds quickly. + import urllib.request + with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=5) as r: + assert r.status == 200 From 5433488fd2bedaeb8cd0d8262a49c740d43c6163 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 2 Jun 2026 22:12:38 -0400 Subject: [PATCH 13/31] refactor: retire hand-rolled server/types/reload; uvicorn entrypoint Relocate scanner TypedDicts -> api/services/manifest_types.py, FileEntry -> cache.py, exceptions -> owning service modules. Delete server.py/types.py/ _reload.py. python -m api now launches a single uvicorn process; app.py exposes a module-level app for the uvicorn import string. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/__main__.py | 89 +- api/_reload.py | 93 -- api/app.py | 4 + api/server.py | 1076 ------------------ api/services/cache.py | 25 +- api/services/clone.py | 26 +- api/{types.py => services/manifest_types.py} | 169 +-- api/services/scan.py | 21 +- api/tests/test_cache.py | 2 +- api/tests/test_cli.py | 10 + api/tests/test_reload.py | 35 - api/tests/test_scan.py | 2 +- api/tests/test_server_config.py | 69 +- 13 files changed, 121 insertions(+), 1500 deletions(-) delete mode 100644 api/_reload.py delete mode 100644 api/server.py rename api/{types.py => services/manifest_types.py} (51%) delete mode 100644 api/tests/test_reload.py diff --git a/api/__main__.py b/api/__main__.py index 3b486353..3289ea4f 100644 --- a/api/__main__.py +++ b/api/__main__.py @@ -1,28 +1,21 @@ """api CLI entrypoint. -Surface: - python -m api Serve on :8080. - python -m api --port 8000 Override port. - python -m api --reload Auto-reload on .py changes (dev only). - python -m api --version Print version. + python -m api Serve on :8080 (single uvicorn process). + python -m api --port 8000 Override port. + python -m api --reload Auto-reload on source changes (dev only). + python -m api --version Print version. -The container ENTRYPOINT runs `python -m api`, so this is the only entrypoint -in production. Dev mode uses --reload via docker-compose.dev.yml. - -Port + browser-opening logic that lived in the old cli.py is now external: -Docker handles port mapping (-p HOST:CONTAINER), and end users open the URL -themselves. +SINGLE PROCESS by design — see api/security.py (the allowed_roots trust +set is in-memory; multi-worker would split it). No --workers flag. """ - from __future__ import annotations import argparse -import os -import signal import sys -import threading from typing import Optional +import uvicorn + from api import __version__ @@ -32,65 +25,23 @@ def _build_parser() -> argparse.ArgumentParser: description="Visualize a codebase as an isometric 3D city.", ) p.add_argument("--version", action="version", version=f"codecity {__version__}") - p.add_argument( - "--port", - type=int, - default=8080, - help="HTTP port to listen on (default: 8080).", - ) - p.add_argument( - "--reload", - action="store_true", - help="Watch api/**/*.py and re-exec on change (dev only).", - ) + p.add_argument("--port", type=int, default=8080, help="HTTP port (default 8080).") + p.add_argument("--host", default="0.0.0.0", help="Bind host (default 0.0.0.0).") + p.add_argument("--reload", action="store_true", help="Auto-reload (dev only).") return p def main(argv: Optional[list[str]] = None) -> int: - if argv is None: - argv = sys.argv[1:] - args = _build_parser().parse_args(argv) - - if args.reload: - # Defer the import — keeps watchfiles off the cold-start import graph - # for `codecity --version` / `--help` / non-reload runs. - try: - from api._reload import run_with_reload - except ImportError: - print( - "error: --reload is not yet wired up. " - "Use docker compose -f docker-compose.dev.yml up for dev mode.", - file=sys.stderr, - ) - return 2 - return run_with_reload(port=args.port) - - return _serve(port=args.port) - - -def _serve(port: int) -> int: - from api.server import start_server - - _, bound, shutdown = start_server(port=port, host="0.0.0.0") - print( - f"[codecity] listening on http://0.0.0.0:{bound}/", - file=sys.stderr, - flush=True, + args = _build_parser().parse_args(sys.argv[1:] if argv is None else argv) + uvicorn.run( + "api.app:app", + host=args.host, + port=args.port, + reload=args.reload, + reload_dirs=["api"] if args.reload else None, + workers=1, + log_level="info", ) - print("[codecity] Ctrl-C to stop", file=sys.stderr, flush=True) - - stop_event = threading.Event() - - def _handle_signal(signum: int, _frame: object) -> None: - stop_event.set() - - signal.signal(signal.SIGINT, _handle_signal) - signal.signal(signal.SIGTERM, _handle_signal) - - try: - stop_event.wait() - finally: - shutdown() return 0 diff --git a/api/_reload.py b/api/_reload.py deleted file mode 100644 index 791bba6b..00000000 --- a/api/_reload.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Dev-only: watch api/**/*.py and re-exec the process on change. - -Used by `python -m api --reload` (i.e. docker-compose.dev.yml's api command). -Not imported in prod — keeps watchfiles out of the runtime hot path. - -Implementation: re-exec via os.execv. Simpler than child-process supervision -and Compose's `init: true` ensures we get a clean shutdown on SIGTERM. The -api/scan.py and api/server.py state is fully recreated on re-exec. -""" - -from __future__ import annotations - -import os -import signal -import sys -from pathlib import Path -from threading import Event, Thread - -WATCH_ROOT = Path(__file__).resolve().parent - - -def _is_python_source(path: Path) -> bool: - """True if `path` is a .py file under WATCH_ROOT, excluding __pycache__.""" - if path.suffix != ".py": - return False - if "__pycache__" in path.parts: - return False - try: - path.resolve().relative_to(WATCH_ROOT) - except ValueError: - return False - return True - - -def run_with_reload(port: int) -> int: - """Run the server with auto-reload on api/**/*.py changes. - - Returns the server's exit code. Re-execs on first detected change instead - of returning — execv replaces the process image, so the function never - returns in that path. - """ - from watchfiles import watch - - from api.server import start_server - - _, bound, shutdown = start_server(port=port, host="0.0.0.0") - print( - f"[codecity] listening on http://0.0.0.0:{bound}/ (reload enabled)", - file=sys.stderr, - flush=True, - ) - - # One Event drives both the watcher thread (stops the watch() loop) and - # the main thread (wakes from wait() on SIGTERM/SIGINT). The watcher - # never sets it — execv replaces the process before that matters — but - # the main signal handler does. - shutdown_event = Event() - - def _watcher() -> None: - for changes in watch(str(WATCH_ROOT), stop_event=shutdown_event): - relevant = [path for _change, path in changes if _is_python_source(Path(path))] - if not relevant: - continue - for p in relevant: - print(f"[codecity] reload triggered by {p}", file=sys.stderr, flush=True) - # Shut down the server cleanly, then re-exec ourselves with the - # same argv. execv replaces the process — no return. - try: - shutdown() - except Exception as e: # pylint: disable=broad-except - print( - f"[codecity] shutdown error before reload: {e}", - file=sys.stderr, - flush=True, - ) - # Re-exec with `python -m api `. sys.argv[1:] preserves - # --port and --reload because argparse doesn't consume args destructively. - # Revisit if we add subcommands or env-driven config that argparse mutates. - os.execv(sys.executable, [sys.executable, "-m", "api", *sys.argv[1:]]) - - Thread(target=_watcher, daemon=True, name="cc-reload-watcher").start() - - def _handle(signum: int, _frame: object) -> None: - shutdown_event.set() - - signal.signal(signal.SIGINT, _handle) - signal.signal(signal.SIGTERM, _handle) - - try: - shutdown_event.wait() - finally: - shutdown() - return 0 diff --git a/api/app.py b/api/app.py index 18e776f4..312bf648 100644 --- a/api/app.py +++ b/api/app.py @@ -53,3 +53,7 @@ async def _http_exc( # pyright: ignore[reportUnusedFunction] app.include_router(manifest.router) app.include_router(make_static_router(static_dir or DEFAULT_STATIC_DIR)) return app + + +# Module-level instance for `uvicorn api.app:app` (prod + --reload). +app = create_app() diff --git a/api/server.py b/api/server.py deleted file mode 100644 index 5c06343d..00000000 --- a/api/server.py +++ /dev/null @@ -1,1076 +0,0 @@ -"""Local HTTP server backing the browser-served frontend. - -Serves the Vite-built frontend from a static dir supplied by the Docker -entrypoint (or `static_dir=` kwarg to `start_server`) and computes a -scan manifest on demand at `/api/manifest?src=…[&branch=…]`. `src` is -either a local absolute path or a git URL; for git URLs, the repo is -cloned into `~/.cache/codecity/clones/` and scanned from there. - -Bind address is configurable via `start_server(host=...)`; production -(`python -m api`) binds 0.0.0.0 so Docker port-forwarding works. -Container isolation gates external access — the published `-p 8080:8080` -is what the host actually exposes. - -Threading: ``ThreadingHTTPServer`` so concurrent /api/file fetches and a -manifest scan don't serialize on each other. The server runs on a daemon -thread so the main thread can stay responsive (and let Ctrl-C land). - -Trust model: every successful manifest scan registers its absolute root -in ``_State.allowed_roots``. ``/api/file`` then validates that the -requested file resolves under at least one of those roots. This means a -client can only fetch files from directories it has previously asked the -server to scan — there's no global filesystem read. -""" - -from __future__ import annotations - -import gzip -import json -import mimetypes -import os -import queue -import re -import select -import socket as _socket -import subprocess -import sys -import threading -from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from io import BufferedIOBase -from pathlib import Path -from typing import Any, Callable, Iterable, Literal -from urllib.parse import parse_qs, urlparse - -from api.env import env_bool -from api.clone import ( - CloneError, - BranchNotFoundError, - RepoNotFoundError, - HostUnreachableError, - ensure_clone, -) -from api.cache import ( - cache_clear_manifests, - cache_load_manifest, - cache_save_manifest, -) -from api.media import is_media -from api.scan import ( - ScanCancelledError, - _build_authors_list, - scan_tree, - signature_tree, -) -from api.types import ( - CacheClearResponse, - CommitDetailResponse, - ConfigResponse, - ErrorResponse, - FileTooLargeResponse, - HealthResponse, - Manifest, - ScanStreamEvent, - SignatureResponse, -) - -# Cap individual /api/file responses so a stray symlink to a giant blob -# doesn't try to load 10 GB into the browser. -MAX_FILE_BYTES = 100 * 1024 * 1024 - - -_LOCAL_PATH_PREFIX = re.compile(r"^(/|~|\./|\.\./|[A-Za-z]:[\\/])") -_GIT_SSH_FORM = re.compile(r"^[^@]+@[^:]+:") - - -def _classify_source(raw: str) -> Literal["local", "git", "invalid"]: - """Classify a raw `?src=` value as a local path, a git URL, or invalid. - - Path-like prefixes (absolute, home, relative, Windows drive) → 'local'. - URLs (scheme:// or git@host:path SSH form) → 'git'. - Anything else → 'invalid'. - """ - if not raw: - return "invalid" - if _LOCAL_PATH_PREFIX.match(raw): - return "local" - if "://" in raw or _GIT_SSH_FORM.match(raw): - return "git" - return "invalid" - - -def _is_git_working_tree(path: Path) -> bool: - """Return True if ``path`` is inside a git working tree. - - Runs ``git rev-parse --is-inside-work-tree`` with cwd=path: - - working tree (top-level OR subdir OR linked worktree) → "true" - - bare repo → "false" - - non-git directory → command fails with non-zero exit - - CodeCity is fundamentally git-aware: every local scan needs a real - working tree to walk. Bare repos have no working tree (just the .git - object database) so they're rejected here too. - - Failures (missing git binary, timeout, OS error) all fall through to - False — better to reject with a clear message than to scan a path we - can't verify.""" - try: - result = subprocess.run( - ["git", "rev-parse", "--is-inside-work-tree"], - cwd=str(path), - capture_output=True, - text=True, - check=False, - timeout=5, - # Defensive: a path nested inside a credentialed repo could - # otherwise prompt for a passphrase and hang the subprocess. - # We're only reading metadata, so no auth is ever needed. - env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, - ) - except (OSError, subprocess.TimeoutExpired): - return False - return result.returncode == 0 and result.stdout.strip() == "true" - - -_NOT_GIT_ERROR = ( - "path is not inside a git working tree. CodeCity requires a git " - "project — try `git init` inside the directory, or paste a git " - "URL instead." -) - -_LOCAL_DISABLED_ERROR = ( - "local repositories are disabled — restart codecity with " - "CODECITY_ALLOW_LOCAL_REPOS=1. " - "See https://github.com/thalida/codecity#local-directories" -) - - -# Bodies under this threshold skip compression — gzip's framing -# overhead (~20 bytes header + trailer) exceeds the savings on small -# responses. The typical hits are /api/health and small error JSON. -_GZIP_MIN_BYTES = 256 - - -def _maybe_gzip( - handler: BaseHTTPRequestHandler, body: bytes, -) -> tuple[bytes, str | None]: - """If the client advertised Accept-Encoding: gzip, gzip-encode body. - - Returns ``(encoded, "gzip")`` when compression applies, ``(body, - None)`` otherwise. Caller is responsible for setting the - Content-Encoding header from the second element when non-None. - - The Accept-Encoding parser is intentionally loose: a substring - check for "gzip" matches the typical "gzip, deflate" or - "gzip;q=1.0". It does not parse RFC 7231 q-values; ``q=0`` would - be misinterpreted as accept, but that's a vanishingly rare config - and the worst case is a successfully-decoded gzip response. - """ - accept = handler.headers.get("Accept-Encoding", "") - if "gzip" not in accept.lower() or len(body) < _GZIP_MIN_BYTES: - return body, None - return gzip.compress(body, compresslevel=6), "gzip" - - -# Where the Vite build output lives. Resolved at import time so tests can -# spin up a server without an installed wheel layout. -STATIC_DIR = Path(__file__).resolve().parent / "static" - - -class _State: - """Module-level state shared by every handler instance.""" - static_dir: Path = STATIC_DIR - # Every absolute path that has been successfully scanned this session. - # /api/file uses this as its trust set. - allowed_roots: set[Path] = set() - # Guards allowed_roots — ThreadingHTTPServer can run a manifest scan - # (writer) concurrently with a file fetch (reader), and CPython will - # raise RuntimeError: Set changed size during iteration if a write - # lands mid-read. - allowed_roots_lock: threading.Lock = threading.Lock() - # Serializes clone-or-update so two concurrent manifest requests for - # the same URL don't race the working tree. ensure_clone is the cache - # itself (filesystem-backed); the lock just keeps it consistent. - clone_lock: threading.Lock = threading.Lock() - - -JsonBody = ( - Manifest - | SignatureResponse - | ErrorResponse - | FileTooLargeResponse - | HealthResponse - | CacheClearResponse - | CommitDetailResponse - | ConfigResponse -) - - -def _send_json(handler: BaseHTTPRequestHandler, status: int, body: JsonBody) -> None: - payload = json.dumps(body).encode("utf-8") - payload, encoding = _maybe_gzip(handler, payload) - handler.send_response(status) - handler.send_header("Content-Type", "application/json; charset=utf-8") - if encoding: - handler.send_header("Content-Encoding", encoding) - handler.send_header("Content-Length", str(len(payload))) - handler.end_headers() - handler.wfile.write(payload) - - -def _stream_events( - handler: BaseHTTPRequestHandler, - events: Iterable[ScanStreamEvent | dict[str, Any]], - cancel_event: threading.Event, -) -> None: - """Stream NDJSON events over a chunked HTTP response. - - Each event becomes one line: `\\n`. Encoded via iterencode - so peak memory is bounded by one ~64 KB chunk, not the serialized - size of the manifest. Wraps wfile in gzip when the client - advertises it. - - Flushes after every event boundary: ``gz.flush()`` emits a - ``Z_SYNC_FLUSH`` DEFLATE block (so decompressors actually see the - bytes — ``GzipFile.write()`` buffers internally and would - otherwise emit nothing until close), then ``handler.wfile.flush()`` - pushes the BufferedWriter into the socket. Without this the - skeleton event would be stuck behind the final event in - production. - - Sets cancel_event on BrokenPipe/ConnectionReset (write-time AND - close-time) so a concurrently-running scan thread can stop ASAP. - Also checks cancel_event between events so a watchdog can - interrupt iteration without waiting for a write to fail.""" - accept = handler.headers.get("Accept-Encoding", "") - use_gzip = "gzip" in accept.lower() - - handler.send_response(HTTPStatus.OK) - handler.send_header("Content-Type", "application/x-ndjson") - if use_gzip: - handler.send_header("Content-Encoding", "gzip") - # No Content-Length → chunked transfer. - handler.end_headers() - - # Both BufferedWriter (handler.wfile) and GzipFile inherit from - # io.BufferedIOBase, so we can reassign without a `# type: ignore`. - sink: BufferedIOBase = handler.wfile - gz: gzip.GzipFile | None = None - if use_gzip: - # mtime=0 → deterministic bytes (helps tests). compresslevel=6 - # matches the existing _maybe_gzip path's choice. - gz = gzip.GzipFile(fileobj=sink, mode="wb", compresslevel=6, mtime=0) - sink = gz - - try: - encoder = json.JSONEncoder() - for event in events: - for chunk in encoder.iterencode(event): - sink.write(chunk.encode("utf-8")) - sink.write(b"\n") - # Boundary flush: emit a Z_SYNC_FLUSH DEFLATE block so the - # decompressor sees this event's bytes, then push from the - # BufferedWriter into the socket. - if gz is not None: - gz.flush() - handler.wfile.flush() - # Let a watchdog interrupt between events without needing a - # write to fail first. - if cancel_event.is_set(): - break - except (BrokenPipeError, ConnectionResetError): - cancel_event.set() - raise - finally: - if gz is not None: - try: - gz.close() - except (BrokenPipeError, ConnectionResetError): - # Gzip buffers most output, so a peer that already - # disconnected often only surfaces at close time. - # Mirror the write-path behavior: surface cancel to the - # surrounding scan, but don't re-raise (we're in - # finally; any real exception already propagated). - cancel_event.set() - - -_WATCHDOG_POLL_SEC = 0.5 - - -def _start_disconnect_watchdog( - handler: BaseHTTPRequestHandler, - cancel_event: threading.Event, -) -> threading.Thread: - """Spawn a daemon thread that watches `handler.connection` for - client-side EOF and sets `cancel_event` when seen. - - Polls every ~500ms via select(); when the socket becomes - readable, peeks one byte — an empty peek means the peer closed. - Loop also exits if cancel_event is set by anyone else (normal - scan completion, or the writer noticing a broken pipe first). - """ - sock = handler.connection - - def _loop() -> None: - while not cancel_event.is_set(): - try: - readable, _, _ = select.select( - [sock], [], [], _WATCHDOG_POLL_SEC, - ) - except (OSError, ValueError): - cancel_event.set() - return - if not readable: - continue - try: - peek = sock.recv(1, _socket.MSG_PEEK) - except OSError: - cancel_event.set() - return - if not peek: - # EOF — client closed its end. - cancel_event.set() - return - # Unexpected data from the client mid-scan (the browser - # isn't supposed to send anything until it reads the - # response). MSG_PEEK didn't consume the byte, so select() - # will keep waking us up on it forever — sleep one poll - # cycle to avoid spinning at 100% CPU. cancel_event.wait() - # both serves as the sleep AND lets the loop exit promptly - # if anyone sets the event during the wait. - cancel_event.wait(_WATCHDOG_POLL_SEC) - - t = threading.Thread(target=_loop, daemon=True, name="cc-disconnect-watchdog") - t.start() - return t - - -def _parse_no_cache(query: str) -> bool: - """Parse ?no_cache=… as a boolean. Strict: only 'true' (any case) - and '1' count as on; absent or anything else is off. Maps to - scan_tree(use_cache=not ).""" - raw = parse_qs(query).get("no_cache", [""])[0].strip().lower() - return raw in ("true", "1") - - -def _resolve_scan_target( - handler: BaseHTTPRequestHandler, query: str -) -> tuple[Path, str, str | None, Literal["local", "git"]] | None: - """Parse ?src=… [&branch=…] and resolve to a scan root. - - Returns (resolved_path, original_src, branch_or_None, kind) on success, or - None after sending the appropriate 4xx/5xx error response. - - Branch semantics: - - Local src: branch is silently ignored. Scan the live working tree. - - Git URL src: branch is passed through to ensure_clone. - """ - params = parse_qs(query) - raw_src = params.get("src", [""])[0] - raw_branch = params.get("branch", [""])[0] or None - - if not raw_src: - _send_json(handler, HTTPStatus.BAD_REQUEST, {"error": "missing 'src' query param"}) - return None - - kind = _classify_source(raw_src) - if kind == "invalid": - _send_json( - handler, - HTTPStatus.BAD_REQUEST, - {"error": "unrecognized source — pass a local path or a git URL"}, - ) - return None - - if kind == "git": - try: - with _State.clone_lock: - local = ensure_clone(raw_src, raw_branch) - return local, raw_src, raw_branch, "git" - except (BranchNotFoundError, RepoNotFoundError, HostUnreachableError) as e: - _send_json(handler, HTTPStatus.BAD_REQUEST, {"error": str(e)}) - return None - except CloneError as e: - _send_json(handler, HTTPStatus.BAD_GATEWAY, {"error": str(e)}) - return None - - # kind == "local" — ignore any &branch=, scan the working tree in place - if not _local_repos_allowed(): - _send_json( - handler, HTTPStatus.FORBIDDEN, {"error": _LOCAL_DISABLED_ERROR} - ) - return None - try: - scan_target = Path(raw_src).resolve(strict=True) - except (OSError, RuntimeError): - _send_json(handler, HTTPStatus.NOT_FOUND, {"error": "path not found"}) - return None - if not scan_target.is_dir(): - _send_json( - handler, HTTPStatus.BAD_REQUEST, {"error": "path is not a directory"} - ) - return None - if not _is_git_working_tree(scan_target): - _send_json( - handler, HTTPStatus.BAD_REQUEST, {"error": _NOT_GIT_ERROR} - ) - return None - return scan_target, raw_src, None, "local" - - -def _local_repos_allowed() -> bool: - """Return True if CODECITY_ALLOW_LOCAL_REPOS is set to a truthy - value. Read fresh on each call so tests can monkeypatch the env - var without restarting the server.""" - return env_bool("CODECITY_ALLOW_LOCAL_REPOS") - - -def _serve_config(handler: BaseHTTPRequestHandler) -> None: - """GET /api/config — server-side feature flags for the frontend.""" - body: ConfigResponse = {"allowLocalRepos": _local_repos_allowed()} - _send_json(handler, HTTPStatus.OK, body) - - -_COMMIT_SHA_RE = re.compile(r"^[0-9a-fA-F]{7,40}$") - - -def _serve_commit_detail(handler: BaseHTTPRequestHandler, query: str) -> None: - """GET /api/commit?sha=. Returns {sha, authors, date, subject, body} - for a commit inside any registered scan root. Validates the sha shape - locally before shelling out to ``git show``. - - Multi-root resolution: tries each allowed scan root in turn and - returns the first hit. Most deployments have one root; for the rare - multi-root case this picks the first repo that contains the sha. - - No email in the response. ``git show`` is the only git call here — no - diff is fetched. - """ - params = parse_qs(query) - sha = (params.get("sha") or [""])[0].strip() - if not _COMMIT_SHA_RE.match(sha): - _send_json(handler, HTTPStatus.BAD_REQUEST, - {"error": "invalid or missing sha"}) - return - - with _State.allowed_roots_lock: - roots_snapshot = set(_State.allowed_roots) - - if not roots_snapshot: - _send_json(handler, HTTPStatus.NOT_FOUND, - {"error": "no scan root registered yet — fetch /api/manifest first"}) - return - - fmt = ("%H%x00%an%x00%aI%x00%s%x00" - "%(trailers:key=Co-authored-by,valueonly,separator=%x1f)%x00%b") - for root in roots_snapshot: - try: - out = subprocess.check_output( - ["git", "-c", "safe.directory=*", "-C", str(root), - "show", "-s", f"--format={fmt}", sha], - stderr=subprocess.DEVNULL, - text=True, - ) - except subprocess.CalledProcessError: - continue - parts = out.rstrip("\n").split("\x00", 5) - if len(parts) < 6: - continue - full_sha, author, iso_date, subject, trailers_raw, body = parts - response: CommitDetailResponse = { - "sha": full_sha, - "authors": _build_authors_list(author, trailers_raw), - "date": iso_date[:10], - "subject": subject, - "body": body, - } - _send_json(handler, HTTPStatus.OK, response) - return - - _send_json(handler, HTTPStatus.NOT_FOUND, - {"error": "sha not found in any registered scan root"}) - - -def _serve_manifest(handler: BaseHTTPRequestHandler, query: str) -> None: - """Stream the scan manifest for the requested source as NDJSON. - - Event sequence for git sources: - cloning → scanning → skeleton → final (cold cache, cold clone) - cloning → scanning → final (warm manifest cache) - Local sources: - scanning → skeleton → final (cold cache) - scanning → final (warm manifest cache) - - The ``cloning`` / ``scanning`` events are lightweight phase markers - (no manifest payload) so the loading-overlay can advance its step - indicator from real server state instead of a wall-clock timer. - - On client disconnect, the watchdog sets the cancel event within - ~500ms; the scan exits via ScanCancelledError and no cache write - happens. - - Pre-stream validation (missing/invalid src, missing local path) - still returns 4xx because no response has started yet. Errors that - arise after the first event is emitted (clone failure, scan failure) - are surfaced as ``{phase: "error"}`` NDJSON events — the HTTP status - is already 200 by then.""" - # Pre-stream validation: param parsing + classify + local-path stat. - # Anything caught here still gets a clean 4xx response. - # - # NOTE: this re-validates the same things as _resolve_scan_target (used - # by /api/manifest/signature). Duplicated deliberately — _serve_manifest - # must emit a chunked NDJSON stream with phase events (`cloning`, - # `scanning`), but _resolve_scan_target calls ensure_clone synchronously - # which would block before any stream byte reaches the client. If you - # change the validation rules, update BOTH places. - params = parse_qs(query) - raw_src = params.get("src", [""])[0] - raw_branch = params.get("branch", [""])[0] or None - - if not raw_src: - _send_json(handler, HTTPStatus.BAD_REQUEST, {"error": "missing 'src' query param"}) - return - - kind = _classify_source(raw_src) - if kind == "invalid": - _send_json( - handler, - HTTPStatus.BAD_REQUEST, - {"error": "unrecognized source — pass a local path or a git URL"}, - ) - return - - local_target: Path | None = None - if kind == "local": - if not _local_repos_allowed(): - _send_json( - handler, - HTTPStatus.FORBIDDEN, - {"error": _LOCAL_DISABLED_ERROR}, - ) - return - try: - local_target = Path(raw_src).resolve(strict=True) - except (OSError, RuntimeError): - _send_json(handler, HTTPStatus.NOT_FOUND, {"error": "path not found"}) - return - if not local_target.is_dir(): - _send_json( - handler, HTTPStatus.BAD_REQUEST, {"error": "path is not a directory"} - ) - return - if not _is_git_working_tree(local_target): - _send_json( - handler, HTTPStatus.BAD_REQUEST, {"error": _NOT_GIT_ERROR} - ) - return - - use_cache = not _parse_no_cache(query) - - cancel_event = threading.Event() - watchdog = _start_disconnect_watchdog(handler, cancel_event) - - def _stamp_display_root(m: "Manifest") -> "Manifest": - if kind == "git": - m["display_root"] = display_root - return m - - # Captured by the closure below so we can decide whether to write - # the manifest cache after _stream_events returns. - state: dict[str, Any] = {"final_manifest": None, "scan_target": None, "sig": None} - - # Display label for the in-flight scan. Hoisted above the first - # yield so the cloning/scanning event can carry it — the client - # uses this to set "{label} (pending)" before any manifest exists. - if kind == "git": - display_root = f"{raw_src}@{raw_branch}" if raw_branch else raw_src - else: - display_root = raw_src - - def _events() -> Iterable[ScanStreamEvent | dict[str, Any]]: - # Git sources: emit cloning, run ensure_clone, then continue. - # Errors during the clone become NDJSON error events because the - # response has already begun streaming by the time this runs. - if kind == "git": - yield {"phase": "cloning", "display_root": display_root} - # ensure_clone is synchronous, but we want its progress - # callbacks to stream out as additional `cloning` events. - # Run it on a worker thread that pushes events into a queue; - # the generator drains the queue until a sentinel arrives, - # then collects the result (or re-raises any exception). - clone_q: queue.Queue[dict[str, Any] | None] = queue.Queue() - clone_result: dict[str, Any] = {"target": None, "error": None} - - def _on_clone_progress(payload: tuple[str, int]) -> None: - stage, percent = payload - clone_q.put({ - "phase": "cloning", - "display_root": display_root, - "stage": stage, - "percent": percent, - }) - - def _run_clone() -> None: - try: - with _State.clone_lock: - clone_result["target"] = ensure_clone( - raw_src, raw_branch, on_progress=_on_clone_progress - ) - except Exception as e: # pylint: disable=broad-except - clone_result["error"] = e - finally: - clone_q.put(None) # sentinel - - clone_thread = threading.Thread(target=_run_clone, daemon=True) - clone_thread.start() - try: - while True: - ev = clone_q.get() - if ev is None: - break - yield ev - finally: - clone_thread.join() - - err = clone_result["error"] - if isinstance(err, (BranchNotFoundError, RepoNotFoundError, HostUnreachableError)): - yield {"phase": "error", "error": str(err)} - return - if isinstance(err, CloneError): - yield {"phase": "error", "error": str(err)} - return - if err is not None: - # Unexpected exception type — surface as an error event so - # the client sees a clean message, then re-raise so the - # outer handler logs it server-side. - yield {"phase": "error", "error": str(err)} - raise err - scan_target = clone_result["target"] - else: - assert local_target is not None - scan_target = local_target - - state["scan_target"] = scan_target - # Register trust root before any file-bearing event reaches the - # client. From this point on /api/file can serve content under - # scan_target. - with _State.allowed_roots_lock: - _State.allowed_roots.add(scan_target.resolve()) - - # For git sources this is the second event (after `cloning`); - # for local sources it's the first. Either way, display_root - # rides along so the client can show the pending label - # immediately for local sources too. - yield {"phase": "scanning", "display_root": display_root} - - # Cheap signature probe — same call the live-poll endpoint uses. - try: - sig_response = signature_tree( - str(scan_target), - use_cache=use_cache, - ) - except Exception as e: # pylint: disable=broad-except - yield {"phase": "error", "error": f"scan failed: {e}"} - return - sig = sig_response["signature"] - state["sig"] = sig - - # Cache lookup. - cached: Manifest | None = None - if use_cache: - cached = cache_load_manifest(scan_target.resolve(), sig) - if cached is not None: - cached = _stamp_display_root(cached) - - if cached is not None: - yield {"phase": "final", "manifest": cached} - return - - # Cache miss — stream live scan. scan_tree is synchronous and - # also yields its own events (skeleton + final); we want the - # heartbeat-driven scanning progress events to interleave with - # those. Same queue/thread pattern as the cloning phase: the - # worker pushes both kinds of events into one queue, the - # generator drains and yields in arrival order. - scan_q: queue.Queue[ScanStreamEvent | dict[str, Any] | None] = queue.Queue() - scan_error: list[BaseException] = [] - - def _on_scan_progress(files_scanned: int) -> None: - scan_q.put({ - "phase": "scanning", - "display_root": display_root, - "files_scanned": files_scanned, - }) - - def _run_scan() -> None: - try: - for ev in scan_tree( - str(scan_target), - use_cache=use_cache, - cancel_event=cancel_event, - on_scan_progress=_on_scan_progress, - ): - scan_q.put(ev) # skeleton + final flow through the same queue - except Exception as e: # pylint: disable=broad-except - scan_error.append(e) - finally: - scan_q.put(None) # sentinel - - scan_thread = threading.Thread(target=_run_scan, daemon=True) - scan_thread.start() - try: - while True: - ev = scan_q.get() - if ev is None: - break - if ev.get("phase") in ("skeleton", "final"): - m = _stamp_display_root(ev["manifest"]) - if ev["phase"] == "final": - state["final_manifest"] = m - yield ev - finally: - scan_thread.join() - - if scan_error: - err = scan_error[0] - if isinstance(err, ScanCancelledError): - # Cancellation isn't an error to surface to the client — - # they disconnected, so there's nobody to read a message. - # Re-raise so the outer try skips the cache write and logs - # the disconnect. - raise err - # Unexpected mid-stream failure (e.g., disk read error - # during _populate_file_metadata). Emit one final error - # event so the client sees a clear message instead of a - # truncated stream / parse error. - yield {"phase": "error", "error": f"scan failed: {err}"} - - try: - _stream_events(handler, _events(), cancel_event) - - # Always write the cache on a successful scan — `use_cache` only - # controls whether we READ from it. A skip-cache (no_cache) scan still - # persists its fresh result, so the next normal load is served the - # up-to-date manifest instead of a stale one. - final_manifest = state["final_manifest"] - scan_target = state["scan_target"] - sig = state["sig"] - if ( - final_manifest is not None - and scan_target is not None - and sig is not None - ): - cache_save_manifest(scan_target.resolve(), sig, final_manifest) - - except ScanCancelledError: - _log_quiet("[scan] cancelled (client disconnected)") - except (BrokenPipeError, ConnectionResetError): - # Writer noticed the disconnect first. handle_error from - # fix #1 swallows the propagated exception at the socketserver - # layer; we just need to skip the cache write. - _log_quiet("[scan] client disconnected mid-stream") - raise - finally: - cancel_event.set() - watchdog.join(timeout=1.0) - - -def _delete_manifest_cache(handler: BaseHTTPRequestHandler, query: str) -> None: - """Clear every cached manifest for the given source. - - Used by the frontend when the user removes an entry from the recents - list — they're done with this source, so its disk cache should go - too. Resolves git URLs to their clone-dir without actually cloning; - resolves local paths non-strictly so cleanup still works for paths - that no longer exist on disk. - - Note: this route is intentionally NOT gated by - `CODECITY_ALLOW_LOCAL_REPOS`. The gate exists to prevent fresh - scans of arbitrary host paths; cache cleanup only manipulates - files under ``CODECITY_CACHE_ROOT`` (a path derived from the - source, not the source itself) and is safe to leave open.""" - params = parse_qs(query) - raw_src = params.get("src", [""])[0] - raw_branch = params.get("branch", [""])[0] or None - - if not raw_src: - _send_json(handler, HTTPStatus.BAD_REQUEST, {"error": "missing 'src' query param"}) - return - - kind = _classify_source(raw_src) - if kind == "invalid": - _send_json( - handler, - HTTPStatus.BAD_REQUEST, - {"error": "unrecognized source — pass a local path or a git URL"}, - ) - return - - if kind == "git": - # Pure path derivation — no clone, no network. - from api.clone import clone_dir_for - abs_root = clone_dir_for(raw_src, raw_branch) - else: - # Local source: non-strict resolve so a recents entry for a - # since-deleted path still drops its cache. - abs_root = Path(raw_src).resolve(strict=False) - - deleted = cache_clear_manifests(abs_root) - _send_json(handler, HTTPStatus.OK, {"deleted": deleted}) - - -def _log_quiet(msg: str) -> None: - """Same env-gated logger as scan._log, duplicated here so server - doesn't import a private from scan. CODECITY_QUIET=1 silences.""" - if not env_bool("CODECITY_QUIET"): - print(msg, file=sys.stderr, flush=True) - - -def _serve_manifest_signature(handler: BaseHTTPRequestHandler, query: str) -> None: - """Cheap variant of /api/manifest — returns just {root, scanned_at, signature}. - - Used by the frontend's live-update poll: hitting this every few - seconds avoids paying for per-file content reads and per-file git - history walks on every tick. The client only fetches the full - manifest when the signature changes. - """ - resolved = _resolve_scan_target(handler, query) - if resolved is None: - return - scan_target, _raw_src, _raw_branch, _kind = resolved - use_cache = not _parse_no_cache(query) - - try: - sig = signature_tree( - str(scan_target), - use_cache=use_cache, - ) - except Exception as e: # pylint: disable=broad-except - _send_json( - handler, - HTTPStatus.INTERNAL_SERVER_ERROR, - {"error": f"signature failed: {e}"}, - ) - return - - _send_json(handler, HTTPStatus.OK, sig) - - -def _serve_file_api(handler: BaseHTTPRequestHandler, query: str) -> None: - """Serve a file from the user's filesystem, restricted to paths inside - any directory that has been successfully scanned this session. - Path-traversal and symlink-escape attempts are caught by - ``Path.resolve()`` + ``relative_to()``.""" - params = parse_qs(query) - raw = params.get("path", [""])[0] - if not raw: - _send_json(handler, HTTPStatus.BAD_REQUEST, {"error": "missing 'path' param"}) - return - - # Snapshot under the lock so a concurrent scan can't mutate the set - # mid-iteration. Set copy is O(roots) — typically a handful. - with _State.allowed_roots_lock: - roots_snapshot = set(_State.allowed_roots) - - if not roots_snapshot: - _send_json( - handler, - HTTPStatus.FORBIDDEN, - {"error": "no scan root registered yet — fetch /api/manifest first"}, - ) - return - - try: - target = Path(raw).resolve(strict=True) - except (OSError, RuntimeError): - _send_json(handler, HTTPStatus.NOT_FOUND, {"error": "not found"}) - return - - # Allow if the target is under ANY registered root. - inside = False - for root in roots_snapshot: - try: - target.relative_to(root) - except ValueError: - continue - inside = True - break - if not inside: - _send_json(handler, HTTPStatus.FORBIDDEN, {"error": "outside scan root"}) - return - - if not target.is_file(): - _send_json(handler, HTTPStatus.NOT_FOUND, {"error": "not a file"}) - return - - size = target.stat().st_size - if size > MAX_FILE_BYTES: - _send_json( - handler, - HTTPStatus.REQUEST_ENTITY_TOO_LARGE, - {"error": "file too large", "size": size, "limit": MAX_FILE_BYTES}, - ) - return - - guessed, _ = mimetypes.guess_type(str(target)) - # Media types (image/video/audio/pdf) keep their guessed MIME so the - # browser can hand them to /