diff --git a/pyproject.toml b/pyproject.toml index 689cd72..9259c75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ py4lexis = { index = "py4lexis" } [dependency-groups] dev = [ "pytest>=8.2.0", + "pytest-cov>=5.0.0", "build==0.10.0", ] docs = [ diff --git a/tests/owilix/http/test_routes.py b/tests/owilix/http/test_routes.py new file mode 100644 index 0000000..4f8f886 --- /dev/null +++ b/tests/owilix/http/test_routes.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import time +from types import SimpleNamespace + +from fastapi.testclient import TestClient + +import owilix.http_api.server as server +from owilix.core.types import CommandResult +from owilix.http_api.jobs import JobManager +from owilix.http_api.server import create_app, get_auth_manager, get_job_manager, get_manager, require_auth + + +class FakeConfig: + config_file = "/tmp/owilix.cfg" + + def __init__(self): + self.config = {"user": {"name": "tester"}} + self.updated = {} + self.saved = False + + def get_config(self, key): + value = self.config + for part in key.split("."): + value = value[part] + return value + + def add_config(self, key, value): + self.updated[key] = value + + def save_config(self): + self.saved = True + + +class FakeManager: + def __init__(self): + self.config = FakeConfig() + self.remote_data = SimpleNamespace(list=lambda **kwargs: []) + + def parse_specifier(self, specifier): + return {"query": None, "day": None, "duration": 0, "data_center": None} + + +def make_client(manager=None, job_manager=None): + manager = manager or FakeManager() + job_manager = job_manager or JobManager() + app = create_app() + app.dependency_overrides[get_manager] = lambda: manager + app.dependency_overrides[get_job_manager] = lambda: job_manager + app.dependency_overrides[require_auth] = lambda: None + return TestClient(app) + + +def wait_for_job(client: TestClient, job_id: str): + deadline = time.time() + 5 + while time.time() < deadline: + response = client.get(f"/jobs/{job_id}") + assert response.status_code == 200 + body = response.json() + if body["status"] in {"succeeded", "failed"}: + return body + time.sleep(0.01) + raise AssertionError(f"job {job_id} did not finish") + + +def fake_task(**kwargs): + serializable = { + key: value + for key, value in kwargs.items() + if key not in {"manager", "owi"} + } + return CommandResult(success=True, object=serializable, msg="ok") + + +class FakeAuth: + def __init__(self, authenticated=True): + self.authenticated = authenticated + + def has_refresh_token(self): + return self.authenticated + + def start(self): + return SimpleNamespace(public_dict=lambda: {"id": "login-1", "status": "pending"}) + + def get(self, state_id): + if state_id == "login-1": + return SimpleNamespace(public_dict=lambda: {"id": state_id, "status": "authenticated"}) + return None + + +def test_config_endpoints_get_and_set_values(): + manager = FakeManager() + client = make_client(manager=manager) + + listing = client.get("/config") + assert listing.status_code == 200 + assert listing.json()["config"] == {"user": {"name": "tester"}} + + got = client.post("/config/get", json={"keys": ["user.name", "missing"]}) + assert got.status_code == 200 + assert got.json()["values"] == {"user.name": "tester", "missing": None} + + updated = client.post("/config/set", json={"values": {"user.email": "t@example.test"}}) + assert updated.status_code == 200 + assert manager.config.updated == {"user.email": "t@example.test"} + assert manager.config.saved is True + + +def test_plugin_list_endpoint(): + client = make_client() + + response = client.get("/plugin/list") + + assert response.status_code == 200 + body = response.json() + assert body["count"] == 3 + assert any(plugin["class_path"].endswith("OpenSearchIndexer") for plugin in body["plugins"]) + + +def test_remote_command_endpoints_submit_jobs(monkeypatch): + import owilix.core.tasks.remote as remote_tasks + + for name in ( + "remote_pull", + "remote_push", + "remote_upload", + "remote_remove", + "remote_doctor", + "remote_catalog", + "remote_status", + "remote_diff", + ): + monkeypatch.setattr(remote_tasks, name, fake_task) + + client = make_client() + cases = [ + ("/remote/pull", {"specifier": "all", "files": "**/*.parquet"}, "remote.pull", {"specifier": "all"}), + ("/remote/push", {"specifier": "all", "datacenter": "lexis"}, "remote.push", {"data_center": "lexis"}), + ("/remote/upload", {"directory": "/tmp/data", "repository": "lexis"}, "remote.upload", {"directory": "/tmp/data"}), + ("/remote/remove", {"specifier": "all/id=abc"}, "remote.remove", {"specifier": "all/id=abc"}), + ("/remote/doctor", {}, "remote.doctor", {"as_json": True}), + ("/remote/catalog", {"specifier": "all", "file": "/tmp/catalog.csv"}, "remote.catalog", {"file": "/tmp/catalog.csv"}), + ("/remote/status", {"specifier": "all", "details": True}, "remote.status", {"details": True, "show_table": False}), + ("/remote/diff", {"specifier": "all", "files": "**/*"}, "remote.diff", {"files": "**/*"}), + ] + + for path, payload, operation, expected in cases: + response = client.post(path, json=payload) + assert response.status_code == 200 + job = wait_for_job(client, response.json()["id"]) + assert job["status"] == "succeeded" + assert job["operation"] == operation + result = job["result"]["result"] + for key, value in expected.items(): + assert result[key] == value + + +def test_local_command_endpoints_submit_jobs(monkeypatch): + import owilix.core.tasks.local as local_tasks + + monkeypatch.setattr(local_tasks, "remove_local_dataset", fake_task) + monkeypatch.setattr(local_tasks, "insert_local_dataset", fake_task) + monkeypatch.setattr(local_tasks, "export_and_merge", fake_task) + monkeypatch.setattr(server, "_local_analyze", fake_task) + client = make_client() + cases = [ + ("/local/free", {"specifier": "all/id=abc"}, "local.free", {"specifier": "all/id=abc", "auto_yes": True}), + ("/local/rm", {"specifier": "all/id=abc"}, "local.free", {"specifier": "all/id=abc", "auto_yes": True}), + ("/local/insert", {"path": "/tmp/input", "collection_name": "test"}, "local.insert", {"path": "/tmp/input", "collection_name": "test"}), + ("/local/export", {"specifier": "all", "outdir": "/tmp/out"}, "local.export", {"outdir": "/tmp/out"}), + ("/local/analyze", {"path": "/tmp/data.jsonl", "topk": 5}, "local.analyze", {"path": "/tmp/data.jsonl", "topk": 5}), + ] + + for path, payload, operation, expected in cases: + response = client.post(path, json=payload) + assert response.status_code == 200 + job = wait_for_job(client, response.json()["id"]) + assert job["status"] == "succeeded" + assert job["operation"] == operation + result = job["result"]["result"] + for key, value in expected.items(): + assert result[key] == value + + +def test_query_command_endpoints_submit_jobs(monkeypatch): + import owilix.core.tasks.query as query_tasks + + monkeypatch.setattr(query_tasks, "query_less", fake_task) + monkeypatch.setattr(query_tasks, "query_sites", fake_task) + monkeypatch.setattr(query_tasks, "query_analyze", fake_task) + monkeypatch.setattr(query_tasks, "query_aggregate", fake_task) + monkeypatch.setattr(query_tasks, "query_stats", fake_task) + monkeypatch.setattr(query_tasks, "slice_query", fake_task) + monkeypatch.setattr(query_tasks, "stream_query", fake_task) + client = make_client() + cases = [ + ("/query/less", {"local": "all", "limit": 1}, "query.less", {"local_specifier": "all", "limit": 1, "format": "json"}), + ("/query/sites", {"urls_file": "/tmp/urls.txt", "remote": "all"}, "query.sites", {"urls_file": "/tmp/urls.txt", "remote_specifier": "all"}), + ("/query/analyze", {"job_name": "job-1"}, "query.analyze", {"job_name": "job-1"}), + ("/query/aggregate", {"local": "all", "groupby": "url_domain"}, "query.aggregate", {"groupby": "url_domain"}), + ("/query/stats", {"local": "all", "topk": 3}, "query.stats", {"topk": 3, "format": "json"}), + ("/query/slice", {"local": "all", "collection_name": "slice"}, "query.slice", {"local_specifier": "all", "collection_name": "slice", "autoyes": True}), + ("/query/stream", {"local": "all", "limit": 1}, "query.stream", {"local_specifier": "all", "limit": 1}), + ] + + for path, payload, operation, expected in cases: + response = client.post(path, json=payload) + assert response.status_code == 200 + job = wait_for_job(client, response.json()["id"]) + assert job["status"] == "succeeded" + assert job["operation"] == operation + result = job["result"]["result"] + for key, value in expected.items(): + assert result[key] == value + + +def test_plugin_run_endpoint_submits_job(monkeypatch): + monkeypatch.setattr(server, "_plugin_run", fake_task) + client = make_client() + + response = client.post( + "/plugin/run", + json={"class_path": "pkg.Plugin", "command": "index", "args": ["a"], "kwargs": {"x": 1}}, + ) + + assert response.status_code == 200 + job = wait_for_job(client, response.json()["id"]) + assert job["status"] == "succeeded" + assert job["operation"] == "plugin.run" + assert job["result"]["result"]["class_path"] == "pkg.Plugin" + assert job["result"]["result"]["kwargs"] == {"x": 1} + + +def test_auth_endpoints_and_unknown_device_login(): + app = create_app() + app.dependency_overrides[get_auth_manager] = lambda: FakeAuth(authenticated=True) + client = TestClient(app) + + assert client.get("/auth/status").json() == {"authenticated": True} + assert client.post("/auth/device/start").json() == {"id": "login-1", "status": "pending"} + assert client.get("/auth/device/login-1").json() == {"id": "login-1", "status": "authenticated"} + + missing = client.get("/auth/device/missing") + assert missing.status_code == 404 + assert missing.json()["detail"] == {"error": "unknown_login"} + + +def test_protected_endpoint_requires_auth(): + app = create_app() + app.dependency_overrides[get_manager] = lambda: FakeManager() + app.dependency_overrides[get_auth_manager] = lambda: FakeAuth(authenticated=False) + client = TestClient(app) + + response = client.get("/remote/ls") + + assert response.status_code == 401 + assert response.json()["detail"] == {"error": "auth_required"} + + +def test_remote_ls_endpoint_with_fake_manager(): + dataset = SimpleNamespace( + metadata={ + "internalID": "remote-1", + "title": "Remote One", + "collectionName": "main", + "totalSize": 10, + }, + dataCenter="lexis", + zone="IT4ILexisV2", + access="public", + ) + calls = [] + manager = FakeManager() + manager.remote_data = SimpleNamespace(list=lambda **kwargs: calls.append(kwargs) or [dataset]) + client = make_client(manager=manager) + + response = client.get("/remote/ls?limit=1") + + assert response.status_code == 200 + body = response.json() + assert body["count"] == 1 + assert body["datasets"][0]["id"] == "remote-1" + assert calls == [{"datacenter": None, "access": "public", "day": None, "duration": 0, "query": {}}] + + +def test_remote_search_success_and_failure(monkeypatch): + class SearchResult: + success = True + json = [{"id": "doc-1"}] + msg = "ok" + error_type = "" + + monkeypatch.setattr(server, "remote_search", lambda **kwargs: SearchResult()) + client = make_client() + + response = client.post("/remote/search", json={"terms": "test"}) + assert response.status_code == 200 + assert response.json() == {"count": 1, "results": [{"id": "doc-1"}]} + + class FailedSearchResult: + success = False + json = [] + msg = "broken" + error_type = "data" + + monkeypatch.setattr(server, "remote_search", lambda **kwargs: FailedSearchResult()) + response = client.post("/remote/search", json={"terms": "test"}) + assert response.status_code == 500 + assert response.json()["detail"] == {"error": "broken", "type": "data"} + + +def test_job_failure_is_serialized(): + def boom(): + raise RuntimeError("boom") + + client = make_client() + response = client.app.dependency_overrides[get_job_manager]().submit("boom", boom) + job = wait_for_job(client, response.id) + + assert job["status"] == "failed" + assert job["error"] == "boom" + assert "RuntimeError" in job["traceback"] if "traceback" in job else True diff --git a/uv.lock b/uv.lock index 86132a4..c8d006c 100644 --- a/uv.lock +++ b/uv.lock @@ -338,6 +338,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cryptography" version = "46.0.3" @@ -1123,6 +1152,7 @@ bloom = [ dev = [ { name = "build" }, { name = "pytest" }, + { name = "pytest-cov" }, ] docs = [ { name = "myst-parser" }, @@ -1189,6 +1219,7 @@ bloom = [ dev = [ { name = "build", specifier = "==0.10.0" }, { name = "pytest", specifier = ">=8.2.0" }, + { name = "pytest-cov", specifier = ">=5.0.0" }, ] docs = [ { name = "myst-parser", specifier = ">=3.0.1" }, @@ -1616,6 +1647,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2246,6 +2291,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { 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 = "tqdm" version = "4.67.1"