diff --git a/pyproject.toml b/pyproject.toml index 6f88bc0b..6b3c4719 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,8 +5,11 @@ description = "Exo" readme = "README.md" requires-python = ">=3.13" dependencies = [ + "aiofiles>=24.1.0", + "aiohttp>=3.12.14", "exo-master", "exo-worker", + "types-aiofiles>=24.1.0.20250708", ] # dependencies only required for development @@ -108,4 +111,4 @@ extend-select = ["I", "N", "B", "A", "PIE", "SIM"] [tool.pytest.ini_options] pythonpath = "." -asyncio_mode = "auto" \ No newline at end of file +asyncio_mode = "auto" diff --git a/shared/graphs.py b/shared/graphs.py index 892f3558..a48474da 100644 --- a/shared/graphs.py +++ b/shared/graphs.py @@ -1,8 +1,9 @@ from dataclasses import dataclass -from typing import Mapping, Set +from typing import Any, Callable, Mapping, Set import rustworkx as rx from pydantic import TypeAdapter +from pydantic_core import core_schema from shared.types.graphs.common import ( Edge, @@ -15,6 +16,7 @@ from shared.types.graphs.common import ( VertexIdT, VertexTypeT, ) +from shared.types.graphs.pydantic import PydanticGraph @dataclass(frozen=True) @@ -52,6 +54,44 @@ class Graph(MutableGraphProtocol[EdgeTypeT, VertexTypeT, EdgeIdT, VertexIdT]): self._vertex_id_to_index = {} self._edge_id_to_endpoints = {} + # TODO: I'm not sure if this is the right thing, but we'll simplify the graph stuff anyway so fine for now. + @classmethod + def __get_pydantic_core_schema__( + cls, + _source: type[Any], + handler: Callable[[Any], core_schema.CoreSchema], + ) -> core_schema.CoreSchema: + pydantic_graph_schema = handler(PydanticGraph) + + def serializer( + instance: "Graph[EdgeTypeT, VertexTypeT, EdgeIdT, VertexIdT]", + ) -> dict[str, Any]: + return { + "vertices": list(instance.get_vertex_data(instance.list_vertices())), + "edges": list(instance.get_edge_data(instance.list_edges())), + } + + return core_schema.json_or_python_schema( + json_schema=pydantic_graph_schema, + python_schema=core_schema.no_info_plain_validator_function(cls.validate), + serialization=core_schema.plain_serializer_function_ser_schema(serializer), + ) + + @classmethod + def validate( + cls, value: Any # type: ignore + ) -> "Graph[EdgeTypeT, VertexTypeT, EdgeIdT, VertexIdT]": + if isinstance(value, cls): + return value + + if isinstance(value, dict): + raise NotImplementedError( + "Deserializing a Graph from a dictionary is not yet supported. " + "Please initialize the Graph object directly." + ) + + raise TypeError("Unsupported type for Graph validation") + ### # GraphProtocol methods ### diff --git a/shared/types/graphs/pydantic.py b/shared/types/graphs/pydantic.py new file mode 100644 index 00000000..2ff9e557 --- /dev/null +++ b/shared/types/graphs/pydantic.py @@ -0,0 +1,8 @@ +from typing import Any, List + +from pydantic import BaseModel + + +class PydanticGraph(BaseModel): + vertices: List[Any] + edges: List[Any] \ No newline at end of file diff --git a/shared/types/state.py b/shared/types/state.py index 59a7b1c9..59d51957 100644 --- a/shared/types/state.py +++ b/shared/types/state.py @@ -1,8 +1,8 @@ from collections.abc import Mapping, Sequence from enum import Enum -from queue import Queue +from typing import List -from pydantic import BaseModel, TypeAdapter +from pydantic import BaseModel, Field, TypeAdapter from shared.types.common import NodeId from shared.types.graphs.topology import ( @@ -36,6 +36,6 @@ class State(BaseModel): edge_base=TypeAdapter(TopologyEdge), vertex_base=TypeAdapter(TopologyNode) ) history: Sequence[OrphanedPartOfTopology] = [] - task_inbox: Queue[Task] = Queue() - task_outbox: Queue[Task] = Queue() + task_inbox: List[Task] = Field(default_factory=list) + task_outbox: List[Task] = Field(default_factory=list) cache_policy: CachePolicy = CachePolicy.KeepAll diff --git a/shared/types/worker/shards.py b/shared/types/worker/shards.py index 3decee54..a8fe5526 100644 --- a/shared/types/worker/shards.py +++ b/shared/types/worker/shards.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Annotated, Generic, Literal, TypeAlias, TypeVar -from pydantic import BaseModel, DirectoryPath, Field, TypeAdapter +from pydantic import BaseModel, Field, TypeAdapter from shared.types.common import NodeId from shared.types.models import ModelId @@ -24,7 +24,6 @@ class BaseShardMetadata(BaseModel, Generic[PartitionStrategyT]): device_rank: int world_size: int model_id: ModelId - model_path: DirectoryPath class PipelineShardMetadata(BaseShardMetadata[Literal[PartitionStrategy.pipeline]]): @@ -37,6 +36,18 @@ class PipelineShardMetadata(BaseShardMetadata[Literal[PartitionStrategy.pipeline ) start_layer: Annotated[int, Field(ge=0)] end_layer: Annotated[int, Field(ge=0)] + n_layers: Annotated[int, Field(ge=0)] + + @property + def is_first_layer(self) -> bool: + return self.start_layer == 0 + + @property + def is_last_layer(self) -> bool: + return self.end_layer == self.n_layers - 1 + + def __hash__(self) -> int: + return hash((self.model_id, self.start_layer, self.end_layer, self.n_layers)) ShardMetadata = Annotated[ diff --git a/uv.lock b/uv.lock index c10602aa..7bbee8d9 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,68 @@ members = [ "exo-worker", ] +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.12.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "aiosignal", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "frozenlist", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "multidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "propcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "yarl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/0b/e39ad954107ebf213a2325038a3e7a506be3d98e1435e1f82086eec4cde2/aiohttp-3.12.14.tar.gz", hash = "sha256:6e06e120e34d93100de448fd941522e11dafa78ef1a893c179901b7d66aa29f2", size = 7822921, upload-time = "2025-07-10T13:05:33.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/48/e0d2fa8ac778008071e7b79b93ab31ef14ab88804d7ba71b5c964a7c844e/aiohttp-3.12.14-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3143a7893d94dc82bc409f7308bc10d60285a3cd831a68faf1aa0836c5c3c767", size = 695471, upload-time = "2025-07-10T13:04:20.124Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e7/f73206afa33100804f790b71092888f47df65fd9a4cd0e6800d7c6826441/aiohttp-3.12.14-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3d62ac3d506cef54b355bd34c2a7c230eb693880001dfcda0bf88b38f5d7af7e", size = 473128, upload-time = "2025-07-10T13:04:21.928Z" }, + { url = "https://files.pythonhosted.org/packages/df/e2/4dd00180be551a6e7ee979c20fc7c32727f4889ee3fd5b0586e0d47f30e1/aiohttp-3.12.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48e43e075c6a438937c4de48ec30fa8ad8e6dfef122a038847456bfe7b947b63", size = 465426, upload-time = "2025-07-10T13:04:24.071Z" }, + { url = "https://files.pythonhosted.org/packages/de/dd/525ed198a0bb674a323e93e4d928443a680860802c44fa7922d39436b48b/aiohttp-3.12.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:077b4488411a9724cecc436cbc8c133e0d61e694995b8de51aaf351c7578949d", size = 1704252, upload-time = "2025-07-10T13:04:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b1/01e542aed560a968f692ab4fc4323286e8bc4daae83348cd63588e4f33e3/aiohttp-3.12.14-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d8c35632575653f297dcbc9546305b2c1133391089ab925a6a3706dfa775ccab", size = 1685514, upload-time = "2025-07-10T13:04:28.186Z" }, + { url = "https://files.pythonhosted.org/packages/b3/06/93669694dc5fdabdc01338791e70452d60ce21ea0946a878715688d5a191/aiohttp-3.12.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b8ce87963f0035c6834b28f061df90cf525ff7c9b6283a8ac23acee6502afd4", size = 1737586, upload-time = "2025-07-10T13:04:30.195Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3a/18991048ffc1407ca51efb49ba8bcc1645961f97f563a6c480cdf0286310/aiohttp-3.12.14-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a2cf66e32a2563bb0766eb24eae7e9a269ac0dc48db0aae90b575dc9583026", size = 1786958, upload-time = "2025-07-10T13:04:32.482Z" }, + { url = "https://files.pythonhosted.org/packages/30/a8/81e237f89a32029f9b4a805af6dffc378f8459c7b9942712c809ff9e76e5/aiohttp-3.12.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdea089caf6d5cde975084a884c72d901e36ef9c2fd972c9f51efbbc64e96fbd", size = 1709287, upload-time = "2025-07-10T13:04:34.493Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e3/bd67a11b0fe7fc12c6030473afd9e44223d456f500f7cf526dbaa259ae46/aiohttp-3.12.14-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7865f27db67d49e81d463da64a59365ebd6b826e0e4847aa111056dcb9dc88", size = 1622990, upload-time = "2025-07-10T13:04:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/83/ba/e0cc8e0f0d9ce0904e3cf2d6fa41904e379e718a013c721b781d53dcbcca/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0ab5b38a6a39781d77713ad930cb5e7feea6f253de656a5f9f281a8f5931b086", size = 1676015, upload-time = "2025-07-10T13:04:38.958Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b3/1e6c960520bda094c48b56de29a3d978254637ace7168dd97ddc273d0d6c/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b3b15acee5c17e8848d90a4ebc27853f37077ba6aec4d8cb4dbbea56d156933", size = 1707678, upload-time = "2025-07-10T13:04:41.275Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/929a3eb8c35b7f9f076a462eaa9830b32c7f27d3395397665caa5e975614/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e4c972b0bdaac167c1e53e16a16101b17c6d0ed7eac178e653a07b9f7fad7151", size = 1650274, upload-time = "2025-07-10T13:04:43.483Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/81682a6f20dd1b18ce3d747de8eba11cbef9b270f567426ff7880b096b48/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7442488b0039257a3bdbc55f7209587911f143fca11df9869578db6c26feeeb8", size = 1726408, upload-time = "2025-07-10T13:04:45.577Z" }, + { url = "https://files.pythonhosted.org/packages/8c/17/884938dffaa4048302985483f77dfce5ac18339aad9b04ad4aaa5e32b028/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f68d3067eecb64c5e9bab4a26aa11bd676f4c70eea9ef6536b0a4e490639add3", size = 1759879, upload-time = "2025-07-10T13:04:47.663Z" }, + { url = "https://files.pythonhosted.org/packages/95/78/53b081980f50b5cf874359bde707a6eacd6c4be3f5f5c93937e48c9d0025/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f88d3704c8b3d598a08ad17d06006cb1ca52a1182291f04979e305c8be6c9758", size = 1708770, upload-time = "2025-07-10T13:04:49.944Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "aiosqlite" version = "0.21.0" @@ -54,6 +116,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + [[package]] name = "certifi" version = "2025.7.14" @@ -97,8 +168,11 @@ name = "exo" version = "0.2.0" source = { editable = "." } dependencies = [ + { name = "aiofiles", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "exo-master", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "exo-worker", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "types-aiofiles", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] [package.optional-dependencies] @@ -116,9 +190,12 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiofiles", specifier = ">=24.1.0" }, + { name = "aiohttp", specifier = ">=3.12.14" }, { name = "exo-master", editable = "master" }, { name = "exo-worker", editable = "worker" }, { name = "mlx", marker = "extra == 'darwin'" }, + { name = "types-aiofiles", specifier = ">=24.1.0.20250708" }, ] provides-extras = ["darwin"] @@ -234,6 +311,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, ] +[[package]] +name = "frozenlist" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, +] + [[package]] name = "fsspec" version = "2025.7.0" @@ -487,6 +603,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/e7/d0e576397b61bf90a0bb27819443f723258acd8dd1207684fdef29243ce4/mlx_lm-0.26.0-py3-none-any.whl", hash = "sha256:b00294c26242cd50db4b6e3ec3a2baf1cfdf8ca49a5e6057dce14642fabe0d21", size = 217671, upload-time = "2025-07-08T20:21:29.448Z" }, ] +[[package]] +name = "multidict" +version = "6.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/2c/5dad12e82fbdf7470f29bff2171484bf07cb3b16ada60a6589af8f376440/multidict-6.6.3.tar.gz", hash = "sha256:798a9eb12dab0a6c2e29c1de6f3468af5cb2da6053a20dfa3344907eed0937cc", size = 101006, upload-time = "2025-06-30T15:53:46.929Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/1d/0bebcbbb4f000751fbd09957257903d6e002943fc668d841a4cf2fb7f872/multidict-6.6.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:540d3c06d48507357a7d57721e5094b4f7093399a0106c211f33540fdc374d55", size = 75843, upload-time = "2025-06-30T15:52:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/07/8f/cbe241b0434cfe257f65c2b1bcf9e8d5fb52bc708c5061fb29b0fed22bdf/multidict-6.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c19cea2a690f04247d43f366d03e4eb110a0dc4cd1bbeee4d445435428ed35b", size = 45053, upload-time = "2025-06-30T15:52:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/32/d2/0b3b23f9dbad5b270b22a3ac3ea73ed0a50ef2d9a390447061178ed6bdb8/multidict-6.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7af039820cfd00effec86bda5d8debef711a3e86a1d3772e85bea0f243a4bd65", size = 43273, upload-time = "2025-06-30T15:52:19.346Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fe/6eb68927e823999e3683bc49678eb20374ba9615097d085298fd5b386564/multidict-6.6.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:500b84f51654fdc3944e936f2922114349bf8fdcac77c3092b03449f0e5bc2b3", size = 237124, upload-time = "2025-06-30T15:52:20.773Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/320d8507e7726c460cb77117848b3834ea0d59e769f36fdae495f7669929/multidict-6.6.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3fc723ab8a5c5ed6c50418e9bfcd8e6dceba6c271cee6728a10a4ed8561520c", size = 256892, upload-time = "2025-06-30T15:52:22.242Z" }, + { url = "https://files.pythonhosted.org/packages/76/60/38ee422db515ac69834e60142a1a69111ac96026e76e8e9aa347fd2e4591/multidict-6.6.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:94c47ea3ade005b5976789baaed66d4de4480d0a0bf31cef6edaa41c1e7b56a6", size = 240547, upload-time = "2025-06-30T15:52:23.736Z" }, + { url = "https://files.pythonhosted.org/packages/27/fb/905224fde2dff042b030c27ad95a7ae744325cf54b890b443d30a789b80e/multidict-6.6.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dbc7cf464cc6d67e83e136c9f55726da3a30176f020a36ead246eceed87f1cd8", size = 266223, upload-time = "2025-06-30T15:52:25.185Z" }, + { url = "https://files.pythonhosted.org/packages/76/35/dc38ab361051beae08d1a53965e3e1a418752fc5be4d3fb983c5582d8784/multidict-6.6.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:900eb9f9da25ada070f8ee4a23f884e0ee66fe4e1a38c3af644256a508ad81ca", size = 267262, upload-time = "2025-06-30T15:52:26.969Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a3/0a485b7f36e422421b17e2bbb5a81c1af10eac1d4476f2ff92927c730479/multidict-6.6.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c6df517cf177da5d47ab15407143a89cd1a23f8b335f3a28d57e8b0a3dbb884", size = 254345, upload-time = "2025-06-30T15:52:28.467Z" }, + { url = "https://files.pythonhosted.org/packages/b4/59/bcdd52c1dab7c0e0d75ff19cac751fbd5f850d1fc39172ce809a74aa9ea4/multidict-6.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ef421045f13879e21c994b36e728d8e7d126c91a64b9185810ab51d474f27e7", size = 252248, upload-time = "2025-06-30T15:52:29.938Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a4/2d96aaa6eae8067ce108d4acee6f45ced5728beda55c0f02ae1072c730d1/multidict-6.6.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c1e61bb4f80895c081790b6b09fa49e13566df8fbff817da3f85b3a8192e36b", size = 250115, upload-time = "2025-06-30T15:52:31.416Z" }, + { url = "https://files.pythonhosted.org/packages/25/d2/ed9f847fa5c7d0677d4f02ea2c163d5e48573de3f57bacf5670e43a5ffaa/multidict-6.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e5e8523bb12d7623cd8300dbd91b9e439a46a028cd078ca695eb66ba31adee3c", size = 249649, upload-time = "2025-06-30T15:52:32.996Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/9155850372563fc550803d3f25373308aa70f59b52cff25854086ecb4a79/multidict-6.6.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ef58340cc896219e4e653dade08fea5c55c6df41bcc68122e3be3e9d873d9a7b", size = 261203, upload-time = "2025-06-30T15:52:34.521Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/c6a728f699896252cf309769089568a33c6439626648843f78743660709d/multidict-6.6.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc9dc435ec8699e7b602b94fe0cd4703e69273a01cbc34409af29e7820f777f1", size = 258051, upload-time = "2025-06-30T15:52:35.999Z" }, + { url = "https://files.pythonhosted.org/packages/d0/60/689880776d6b18fa2b70f6cc74ff87dd6c6b9b47bd9cf74c16fecfaa6ad9/multidict-6.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9e864486ef4ab07db5e9cb997bad2b681514158d6954dd1958dfb163b83d53e6", size = 249601, upload-time = "2025-06-30T15:52:37.473Z" }, + { url = "https://files.pythonhosted.org/packages/3a/58/aaf8114cf34966e084a8cc9517771288adb53465188843d5a19862cb6dc3/multidict-6.6.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:02fd8f32d403a6ff13864b0851f1f523d4c988051eea0471d4f1fd8010f11134", size = 82811, upload-time = "2025-06-30T15:52:43.281Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/5402e7b58a1f5b987a07ad98f2501fdba2a4f4b4c30cf114e3ce8db64c87/multidict-6.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f3aa090106b1543f3f87b2041eef3c156c8da2aed90c63a2fbed62d875c49c37", size = 48304, upload-time = "2025-06-30T15:52:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/39/65/ab3c8cafe21adb45b24a50266fd747147dec7847425bc2a0f6934b3ae9ce/multidict-6.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e924fb978615a5e33ff644cc42e6aa241effcf4f3322c09d4f8cebde95aff5f8", size = 46775, upload-time = "2025-06-30T15:52:46.459Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/9fcc1b332f67cc0c0c8079e263bfab6660f87fe4e28a35921771ff3eea0d/multidict-6.6.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b9fe5a0e57c6dbd0e2ce81ca66272282c32cd11d31658ee9553849d91289e1c1", size = 229773, upload-time = "2025-06-30T15:52:47.88Z" }, + { url = "https://files.pythonhosted.org/packages/a4/14/0145a251f555f7c754ce2dcbcd012939bbd1f34f066fa5d28a50e722a054/multidict-6.6.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b24576f208793ebae00280c59927c3b7c2a3b1655e443a25f753c4611bc1c373", size = 250083, upload-time = "2025-06-30T15:52:49.366Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d4/d5c0bd2bbb173b586c249a151a26d2fb3ec7d53c96e42091c9fef4e1f10c/multidict-6.6.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:135631cb6c58eac37d7ac0df380294fecdc026b28837fa07c02e459c7fb9c54e", size = 228980, upload-time = "2025-06-30T15:52:50.903Z" }, + { url = "https://files.pythonhosted.org/packages/21/32/c9a2d8444a50ec48c4733ccc67254100c10e1c8ae8e40c7a2d2183b59b97/multidict-6.6.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:274d416b0df887aef98f19f21578653982cfb8a05b4e187d4a17103322eeaf8f", size = 257776, upload-time = "2025-06-30T15:52:52.764Z" }, + { url = "https://files.pythonhosted.org/packages/68/d0/14fa1699f4ef629eae08ad6201c6b476098f5efb051b296f4c26be7a9fdf/multidict-6.6.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e252017a817fad7ce05cafbe5711ed40faeb580e63b16755a3a24e66fa1d87c0", size = 256882, upload-time = "2025-06-30T15:52:54.596Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/84a27570fbe303c65607d517a5f147cd2fc046c2d1da02b84b17b9bdc2aa/multidict-6.6.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4cc8d848cd4fe1cdee28c13ea79ab0ed37fc2e89dd77bac86a2e7959a8c3bc", size = 247816, upload-time = "2025-06-30T15:52:56.175Z" }, + { url = "https://files.pythonhosted.org/packages/1c/60/dca352a0c999ce96a5d8b8ee0b2b9f729dcad2e0b0c195f8286269a2074c/multidict-6.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9e236a7094b9c4c1b7585f6b9cca34b9d833cf079f7e4c49e6a4a6ec9bfdc68f", size = 245341, upload-time = "2025-06-30T15:52:57.752Z" }, + { url = "https://files.pythonhosted.org/packages/50/ef/433fa3ed06028f03946f3993223dada70fb700f763f70c00079533c34578/multidict-6.6.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e0cb0ab69915c55627c933f0b555a943d98ba71b4d1c57bc0d0a66e2567c7471", size = 235854, upload-time = "2025-06-30T15:52:59.74Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1f/487612ab56fbe35715320905215a57fede20de7db40a261759690dc80471/multidict-6.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:81ef2f64593aba09c5212a3d0f8c906a0d38d710a011f2f42759704d4557d3f2", size = 243432, upload-time = "2025-06-30T15:53:01.602Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/ce8b79de16cd885c6f9052c96a3671373d00c59b3ee635ea93e6e81b8ccf/multidict-6.6.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:b9cbc60010de3562545fa198bfc6d3825df430ea96d2cc509c39bd71e2e7d648", size = 252731, upload-time = "2025-06-30T15:53:03.517Z" }, + { url = "https://files.pythonhosted.org/packages/bb/fe/a2514a6aba78e5abefa1624ca85ae18f542d95ac5cde2e3815a9fbf369aa/multidict-6.6.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70d974eaaa37211390cd02ef93b7e938de564bbffa866f0b08d07e5e65da783d", size = 247086, upload-time = "2025-06-30T15:53:05.48Z" }, + { url = "https://files.pythonhosted.org/packages/8c/22/b788718d63bb3cce752d107a57c85fcd1a212c6c778628567c9713f9345a/multidict-6.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3713303e4a6663c6d01d648a68f2848701001f3390a030edaaf3fc949c90bf7c", size = 243338, upload-time = "2025-06-30T15:53:07.522Z" }, + { url = "https://files.pythonhosted.org/packages/d8/30/9aec301e9772b098c1f5c0ca0279237c9766d94b97802e9888010c64b0ed/multidict-6.6.3-py3-none-any.whl", hash = "sha256:8db10f29c7541fc5da4defd8cd697e1ca429db743fa716325f236079b96f775a", size = 12313, upload-time = "2025-06-30T15:53:45.437Z" }, +] + [[package]] name = "networkx" version = "3.5" @@ -566,6 +721,43 @@ 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 = "propcache" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +] + [[package]] name = "protobuf" version = "6.31.1" @@ -889,6 +1081,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/88/beb33a79a382fcd2aed0be5222bdc47f41e4bfe7aaa90ae1374f1d8ea2af/transformers-4.53.2-py3-none-any.whl", hash = "sha256:db8f4819bb34f000029c73c3c557e7d06fc1b8e612ec142eecdae3947a9c78bf", size = 10826609, upload-time = "2025-07-11T12:39:05.461Z" }, ] +[[package]] +name = "types-aiofiles" +version = "24.1.0.20250708" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/d6/5c44761bc11cb5c7505013a39f397a9016bfb3a5c932032b2db16c38b87b/types_aiofiles-24.1.0.20250708.tar.gz", hash = "sha256:c8207ed7385491ce5ba94da02658164ebd66b69a44e892288c9f20cbbf5284ff", size = 14322, upload-time = "2025-07-08T03:14:44.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/e9/4e0cc79c630040aae0634ac9393341dc2aff1a5be454be9741cc6cc8989f/types_aiofiles-24.1.0.20250708-py3-none-any.whl", hash = "sha256:07f8f06465fd415d9293467d1c66cd074b2c3b62b679e26e353e560a8cf63720", size = 14320, upload-time = "2025-07-08T03:14:44.009Z" }, +] + [[package]] name = "types-protobuf" version = "6.30.2.20250703" @@ -927,3 +1128,47 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599 wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] + +[[package]] +name = "yarl" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "multidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "propcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +] diff --git a/worker/download/download_utils.py b/worker/download/download_utils.py new file mode 100644 index 00000000..ce7f2090 --- /dev/null +++ b/worker/download/download_utils.py @@ -0,0 +1,430 @@ +import asyncio +import hashlib +import os +import shutil +import tempfile +import time +import traceback +from datetime import timedelta +from pathlib import Path +from typing import Annotated, Callable, Dict, List, Literal, Optional, Tuple, Union +from urllib.parse import urljoin + +import aiofiles +import aiofiles.os as aios +import aiohttp +from pydantic import BaseModel, DirectoryPath, Field, TypeAdapter + +from shared.constants import EXO_HOME +from shared.types.worker.shards import ShardMetadata +from worker.download.huggingface_utils import ( + filter_repo_objects, + get_allow_patterns, + get_auth_headers, + get_hf_endpoint, +) + + +class ModelSafetensorsIndexMetadata(BaseModel): + total_size: Annotated[int, Field(ge=0)] + +class ModelSafetensorsIndex(BaseModel): + metadata: Optional[ModelSafetensorsIndexMetadata] + weight_map: Dict[str, str] + +class FileListEntry(BaseModel): + type: Literal["file", "directory"] + path: str + size: int | None = None + +class RepoFileDownloadProgress(BaseModel): + """Progress information for an individual file within a repository download.""" + + repo_id: str + repo_revision: str + file_path: str + downloaded: int + downloaded_this_session: int + total: int + speed: float # bytes per second + eta: timedelta + status: Literal["not_started", "in_progress", "complete"] + start_time: float + + class Config: + frozen = True + +class RepoDownloadProgress(BaseModel): + """Aggregated download progress information for a repository/shard combination. + + This structure captures the overall progress of downloading the files + required to materialise a particular *shard* of a model. It purposely + mirrors the key summary fields emitted by the `RepoProgressEvent` so that + the event payload can be cleanly projected onto the long-lived cluster + state. + """ + + repo_id: str + repo_revision: str + shard: ShardMetadata + + # progress totals + completed_files: int + total_files: int + downloaded_bytes: int + downloaded_bytes_this_session: int + total_bytes: int + + # speed / eta + overall_speed: float # bytes per second + overall_eta: timedelta + + # lifecycle status + status: Literal["not_started", "in_progress", "complete"] + + # fine-grained file progress keyed by file_path + file_progress: Dict[str, RepoFileDownloadProgress] = Field(default_factory=dict) + + class Config: + frozen = True # allow use as dict keys if desired + +def build_model_path(model_id: str) -> DirectoryPath: + return EXO_HOME / "models" / model_id.replace("/", "--") + +def exo_tmp() -> Path: + return Path(tempfile.gettempdir())/"exo" + +async def resolve_model_path_for_repo(repo_id: str) -> Path: + return (await ensure_models_dir())/repo_id.replace("/", "--") + +async def ensure_exo_home() -> Path: + await aios.makedirs(EXO_HOME, exist_ok=True) + return EXO_HOME + +async def ensure_exo_tmp() -> Path: + await aios.makedirs(exo_tmp(), exist_ok=True) + return exo_tmp() + +async def has_exo_home_read_access() -> bool: + try: + return await aios.access(EXO_HOME, os.R_OK) + except OSError: + return False + +async def has_exo_home_write_access() -> bool: + try: + return await aios.access(EXO_HOME, os.W_OK) + except OSError: + return False + +async def ensure_models_dir() -> Path: + models_dir = EXO_HOME/"models" + await aios.makedirs(models_dir, exist_ok=True) + return models_dir + +async def delete_model(repo_id: str) -> bool: + model_dir = await ensure_models_dir()/repo_id.replace("/", "--") + if not await aios.path.exists(model_dir): + return False + await asyncio.to_thread(shutil.rmtree, model_dir, ignore_errors=False) + return True + +async def seed_models(seed_dir: Union[str, Path]): + """Move model in resources folder of app to .cache/huggingface/hub""" + source_dir = Path(seed_dir) + dest_dir = await ensure_models_dir() + for path in source_dir.iterdir(): + if path.is_dir() and path.name.startswith("models--"): + dest_path = dest_dir/path.name + if await aios.path.exists(dest_path): + print('Skipping moving model to .cache directory') + else: + try: + await aios.rename(str(path), str(dest_path)) + except Exception: + print(f"Error seeding model {path} to {dest_path}") + traceback.print_exc() + +async def fetch_file_list_with_cache(repo_id: str, revision: str = "main", recursive: bool = False) -> List[FileListEntry]: + cache_file = (await ensure_exo_tmp())/f"{repo_id.replace('/', '--')}--{revision}--file_list.json" + if await aios.path.exists(cache_file): + async with aiofiles.open(cache_file, 'r') as f: + return TypeAdapter(List[FileListEntry]).validate_json(await f.read()) + file_list = await fetch_file_list_with_retry(repo_id, revision, recursive=recursive) + await aios.makedirs(cache_file.parent, exist_ok=True) + async with aiofiles.open(cache_file, 'w') as f: + await f.write(TypeAdapter(List[FileListEntry]).dump_json(file_list).decode()) + return file_list + + +async def fetch_file_list_with_retry(repo_id: str, revision: str = "main", path: str = "", recursive: bool = False) -> List[FileListEntry]: + n_attempts = 30 + for attempt in range(n_attempts): + try: + return await _fetch_file_list(repo_id, revision, path, recursive) + except Exception as e: + if attempt == n_attempts - 1: + raise e + await asyncio.sleep(min(8, 0.1 * float(2.0 ** int(attempt)))) + raise Exception(f"Failed to fetch file list for {repo_id=} {revision=} {path=} {recursive=}") + +async def _fetch_file_list(repo_id: str, revision: str = "main", path: str = "", recursive: bool = False) -> List[FileListEntry]: + api_url = f"{get_hf_endpoint()}/api/models/{repo_id}/tree/{revision}" + url = f"{api_url}/{path}" if path else api_url + + headers = await get_auth_headers() + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30, connect=10, sock_read=30, sock_connect=10)) as session, session.get(url, headers=headers) as response: + if response.status == 200: + data_json = await response.text() + data = TypeAdapter(list[FileListEntry]).validate_json(data_json) + files: list[FileListEntry] = [] + for item in data: + if item.type == "file": + files.append(FileListEntry.model_validate(item)) + elif item.type == "directory" and recursive: + subfiles = await _fetch_file_list(repo_id, revision, item.path, recursive) + files.extend(subfiles) + return files + else: + raise Exception(f"Failed to fetch file list: {response.status}") + +async def calc_hash(path: Path, hash_type: Literal["sha1", "sha256"] = "sha1") -> str: + hasher = hashlib.sha1() if hash_type == "sha1" else hashlib.sha256() + if hash_type == "sha1": + header = f"blob {(await aios.stat(path)).st_size}\0".encode() + hasher.update(header) + async with aiofiles.open(path, 'rb') as f: + while chunk := await f.read(8 * 1024 * 1024): + hasher.update(chunk) + return hasher.hexdigest() + +async def file_meta(repo_id: str, revision: str, path: str) -> Tuple[int, str]: + url = urljoin(f"{get_hf_endpoint()}/{repo_id}/resolve/{revision}/", path) + headers = await get_auth_headers() + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=1800, connect=60, sock_read=1800, sock_connect=60)) as session, session.head(url, headers=headers) as r: + content_length = int(r.headers.get('x-linked-size') or r.headers.get('content-length') or 0) + etag = r.headers.get('X-Linked-ETag') or r.headers.get('ETag') or r.headers.get('Etag') + assert content_length > 0, f"No content length for {url}" + assert etag is not None, f"No remote hash for {url}" + if (etag[0] == '"' and etag[-1] == '"') or (etag[0] == "'" and etag[-1] == "'"): + etag = etag[1:-1] + return content_length, etag + +async def download_file_with_retry(repo_id: str, revision: str, path: str, target_dir: Path, on_progress: Callable[[int, int], None] = lambda _, __: None) -> Path: + n_attempts = 30 + for attempt in range(n_attempts): + try: + return await _download_file(repo_id, revision, path, target_dir, on_progress) + except Exception as e: + if isinstance(e, FileNotFoundError) or attempt == n_attempts - 1: + raise e + print(f"Download error on attempt {attempt}/{n_attempts} for {repo_id=} {revision=} {path=} {target_dir=}") + traceback.print_exc() + await asyncio.sleep(min(8, 0.1 * (2.0 ** attempt))) + raise Exception(f"Failed to download file {repo_id=} {revision=} {path=} {target_dir=}") + +async def _download_file(repo_id: str, revision: str, path: str, target_dir: Path, on_progress: Callable[[int, int], None] = lambda _, __: None) -> Path: + if await aios.path.exists(target_dir/path): + return target_dir/path + await aios.makedirs((target_dir/path).parent, exist_ok=True) + length, etag = await file_meta(repo_id, revision, path) + remote_hash = etag[:-5] if etag.endswith("-gzip") else etag + partial_path = target_dir/f"{path}.partial" + resume_byte_pos = (await aios.stat(partial_path)).st_size if (await aios.path.exists(partial_path)) else None + if resume_byte_pos != length: + url = urljoin(f"{get_hf_endpoint()}/{repo_id}/resolve/{revision}/", path) + headers = await get_auth_headers() + if resume_byte_pos: + headers['Range'] = f'bytes={resume_byte_pos}-' + n_read = resume_byte_pos or 0 + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=1800, connect=60, sock_read=1800, sock_connect=60)) as session, session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=1800, connect=60, sock_read=1800, sock_connect=60)) as r: + if r.status == 404: + raise FileNotFoundError(f"File not found: {url}") + assert r.status in [200, 206], f"Failed to download {path} from {url}: {r.status}" + async with aiofiles.open(partial_path, 'ab' if resume_byte_pos else 'wb') as f: + while chunk := await r.content.read(8 * 1024 * 1024): + on_progress(n_read := n_read + await f.write(chunk), length) + + final_hash = await calc_hash(partial_path, hash_type="sha256" if len(remote_hash) == 64 else "sha1") + integrity = final_hash == remote_hash + if not integrity: + try: + await aios.remove(partial_path) + except Exception as e: + print(f"Error removing partial file {partial_path}: {e}") + raise Exception(f"Downloaded file {target_dir/path} has hash {final_hash} but remote hash is {remote_hash}") + await aios.rename(partial_path, target_dir/path) + return target_dir/path + + +def calculate_repo_progress(shard: ShardMetadata, repo_id: str, revision: str, file_progress: Dict[str, RepoFileDownloadProgress], all_start_time: float) -> RepoDownloadProgress: + all_total_bytes = sum(p.total for p in file_progress.values()) + all_downloaded_bytes = sum(p.downloaded for p in file_progress.values()) + all_downloaded_bytes_this_session = sum(p.downloaded_this_session for p in file_progress.values()) + elapsed_time = time.time() - all_start_time + all_speed = all_downloaded_bytes_this_session / elapsed_time if elapsed_time > 0 else 0 + all_eta = timedelta(seconds=(all_total_bytes - all_downloaded_bytes) / all_speed) if all_speed > 0 else timedelta(seconds=0) + status = ( + "complete" + if all(p.status == "complete" for p in file_progress.values()) + else "in_progress" if any(p.status == "in_progress" for p in file_progress.values()) else "not_started" + ) + return RepoDownloadProgress( + repo_id=repo_id, + repo_revision=revision, + shard=shard, + completed_files=len([p for p in file_progress.values() if p.downloaded == p.total]), + total_files=len(file_progress), + downloaded_bytes=all_downloaded_bytes, + downloaded_bytes_this_session=all_downloaded_bytes_this_session, + total_bytes=all_total_bytes, + overall_speed=all_speed, + overall_eta=all_eta, + status=status, + file_progress=file_progress, + ) + +async def get_weight_map(repo_id: str, revision: str = "main") -> Dict[str, str]: + target_dir = (await ensure_exo_tmp())/repo_id.replace("/", "--") + index_file = await download_file_with_retry(repo_id, revision, "model.safetensors.index.json", target_dir) + async with aiofiles.open(index_file, 'r') as f: + index_data = ModelSafetensorsIndex.model_validate_json(await f.read()) + return index_data.weight_map + +async def resolve_allow_patterns(shard: ShardMetadata) -> List[str]: + try: + weight_map = await get_weight_map(str(shard.model_id)) + return get_allow_patterns(weight_map, shard) + except Exception: + print(f"Error getting weight map for {shard.model_id=}") + traceback.print_exc() + return ["*"] + +async def get_downloaded_size(path: Path) -> int: + partial_path = path.with_suffix(path.suffix + ".partial") + if await aios.path.exists(path): + return (await aios.stat(path)).st_size + if await aios.path.exists(partial_path): + return (await aios.stat(partial_path)).st_size + return 0 + +async def download_progress_for_local_path(repo_id: str, shard: ShardMetadata, local_path: Path) -> RepoDownloadProgress: + # Scan local files for accurate progress reporting + file_progress: Dict[str, RepoFileDownloadProgress] = {} + total_files = 0 + total_bytes = 0 + + if await aios.path.isdir(local_path): + # Recursively count files and sizes + for root, _, files in os.walk(local_path): + for f in files: + if f.endswith(('.safetensors', '.bin', '.pt', '.gguf', '.json')): + file_path = Path(root) / f + size = (await aios.stat(file_path)).st_size + rel_path = str(file_path.relative_to(local_path)) + file_progress[rel_path] = RepoFileDownloadProgress( + repo_id=repo_id, + repo_revision="local", + file_path=rel_path, + downloaded=size, + downloaded_this_session=0, + total=size, + speed=0, + eta=timedelta(0), + status="complete", + start_time=time.time() + ) + total_files += 1 + total_bytes += size + else: + raise ValueError(f"Local path {local_path} is not a directory") + + return RepoDownloadProgress( + repo_id=repo_id, + repo_revision="local", + shard=shard, + completed_files=total_files, + total_files=total_files, + downloaded_bytes=total_bytes, + downloaded_bytes_this_session=0, + total_bytes=total_bytes, + overall_speed=0, + overall_eta=timedelta(0), + status="complete", + file_progress=file_progress, + ) + +async def download_shard(shard: ShardMetadata, + on_progress: Callable[[ShardMetadata, RepoDownloadProgress], None], + max_parallel_downloads: int = 8, + skip_download: bool = False, + allow_patterns: List[str] | None = None) -> tuple[Path, RepoDownloadProgress]: + if not skip_download: + print(f"Downloading {shard.model_id=}") + + # Handle local paths + if await aios.path.exists(str(shard.model_id)): + print(f"Using local model path {shard.model_id}") + local_path = Path(str(shard.model_id)) + return local_path, await download_progress_for_local_path(str(shard.model_id), shard, local_path) + + revision = "main" + target_dir = await ensure_models_dir()/str(shard.model_id).replace("/", "--") + if not skip_download: + await aios.makedirs(target_dir, exist_ok=True) + + if not allow_patterns: + allow_patterns = await resolve_allow_patterns(shard) + + print(f"Downloading {shard.model_id=} with {allow_patterns=}") + + all_start_time = time.time() + # TODO: currently not recursive. Some models might require subdirectories - thus this will need to be changed. + file_list = await fetch_file_list_with_cache(str(shard.model_id), revision, recursive=False) + filtered_file_list = list(filter_repo_objects(file_list, allow_patterns=allow_patterns, key=lambda x: x.path)) + file_progress: Dict[str, RepoFileDownloadProgress] = {} + def on_progress_wrapper(file: FileListEntry, curr_bytes: int, total_bytes: int): + start_time = file_progress[file.path].start_time if file.path in file_progress else time.time() + downloaded_this_session = file_progress[file.path].downloaded_this_session + (curr_bytes - file_progress[file.path].downloaded) if file.path in file_progress else curr_bytes + speed = downloaded_this_session / (time.time() - start_time) if time.time() - start_time > 0 else 0 + eta = timedelta(seconds=(total_bytes - curr_bytes) / speed) if speed > 0 else timedelta(seconds=0) + file_progress[file.path] = RepoFileDownloadProgress( + repo_id=str(shard.model_id), + repo_revision=revision, + file_path=file.path, + downloaded=curr_bytes, + downloaded_this_session=downloaded_this_session, + total=total_bytes, + speed=speed, + eta=eta, + status="complete" if curr_bytes == total_bytes else "in_progress", + start_time=start_time, + ) + on_progress(shard, calculate_repo_progress(shard, str(shard.model_id), revision, file_progress, all_start_time)) + for file in filtered_file_list: + downloaded_bytes = await get_downloaded_size(target_dir/file.path) + file_progress[file.path] = RepoFileDownloadProgress( + repo_id=str(shard.model_id), + repo_revision=revision, + file_path=file.path, + downloaded=downloaded_bytes, + downloaded_this_session=0, + total=file.size or 0, + speed=0, + eta=timedelta(0), + status="complete" if downloaded_bytes == file.size else "not_started", + start_time=time.time(), + ) + + semaphore = asyncio.Semaphore(max_parallel_downloads) + async def download_with_semaphore(file: FileListEntry): + async with semaphore: + await download_file_with_retry(str(shard.model_id), revision, file.path, target_dir, lambda curr_bytes, total_bytes: on_progress_wrapper(file, curr_bytes, total_bytes)) + if not skip_download: + await asyncio.gather(*[download_with_semaphore(file) for file in filtered_file_list]) + final_repo_progress = calculate_repo_progress(shard, str(shard.model_id), revision, file_progress, all_start_time) + on_progress(shard, final_repo_progress) + if gguf := next((f for f in filtered_file_list if f.path.endswith(".gguf")), None): + return target_dir/gguf.path, final_repo_progress + else: + return target_dir, final_repo_progress diff --git a/worker/download/huggingface_utils.py b/worker/download/huggingface_utils.py new file mode 100644 index 00000000..a3d8a781 --- /dev/null +++ b/worker/download/huggingface_utils.py @@ -0,0 +1,97 @@ +import os +from fnmatch import fnmatch +from pathlib import Path +from typing import Callable, Dict, Generator, Iterable, List, Optional, TypeVar, Union + +import aiofiles +import aiofiles.os as aios + +from shared.types.worker.shards import ShardMetadata + +T = TypeVar("T") + +def filter_repo_objects( + items: Iterable[T], + *, + allow_patterns: Optional[Union[List[str], str]] = None, + ignore_patterns: Optional[Union[List[str], str]] = None, + key: Optional[Callable[[T], str]] = None, +) -> Generator[T, None, None]: + if isinstance(allow_patterns, str): + allow_patterns = [allow_patterns] + if isinstance(ignore_patterns, str): + ignore_patterns = [ignore_patterns] + if allow_patterns is not None: + allow_patterns = [_add_wildcard_to_directories(p) for p in allow_patterns] + if ignore_patterns is not None: + ignore_patterns = [_add_wildcard_to_directories(p) for p in ignore_patterns] + + if key is None: + def _identity(item: T) -> str: + if isinstance(item, str): + return item + if isinstance(item, Path): + return str(item) + raise ValueError(f"Please provide `key` argument in `filter_repo_objects`: `{item}` is not a string.") + key = _identity + + for item in items: + path = key(item) + if allow_patterns is not None and not any(fnmatch(path, r) for r in allow_patterns): + continue + if ignore_patterns is not None and any(fnmatch(path, r) for r in ignore_patterns): + continue + yield item + +def _add_wildcard_to_directories(pattern: str) -> str: + if pattern[-1] == "/": + return pattern + "*" + return pattern + +def get_hf_endpoint() -> str: + return os.environ.get('HF_ENDPOINT', "https://huggingface.co") + +def get_hf_home() -> Path: + """Get the Hugging Face home directory.""" + return Path(os.environ.get("HF_HOME", Path.home()/".cache"/"huggingface")) + +async def get_hf_token() -> Optional[str]: + """Retrieve the Hugging Face token from the user's HF_HOME directory.""" + token_path = get_hf_home()/"token" + if await aios.path.exists(token_path): + async with aiofiles.open(token_path, 'r') as f: + return (await f.read()).strip() + return None + +async def get_auth_headers() -> dict[str, str]: + """Get authentication headers if a token is available.""" + token = await get_hf_token() + if token: + return {"Authorization": f"Bearer {token}"} + return {} + +def extract_layer_num(tensor_name: str) -> Optional[int]: + # This is a simple example and might need to be adjusted based on the actual naming convention + parts = tensor_name.split('.') + for part in parts: + if part.isdigit(): + return int(part) + return None + +def get_allow_patterns(weight_map: Dict[str, str], shard: ShardMetadata) -> List[str]: + default_patterns = set(["*.json", "*.py", "tokenizer.model", "*.tiktoken", "*.txt"]) + shard_specific_patterns: set[str] = set() + if weight_map: + for tensor_name, filename in weight_map.items(): + layer_num = extract_layer_num(tensor_name) + if layer_num is not None and shard.start_layer <= layer_num <= shard.end_layer: + shard_specific_patterns.add(filename) + sorted_file_names = sorted(weight_map.values()) + if shard.is_first_layer: + shard_specific_patterns.add(sorted_file_names[0]) + elif shard.is_last_layer: + shard_specific_patterns.add(sorted_file_names[-1]) + else: + shard_specific_patterns = set(["*.safetensors"]) + print(f"get_allow_patterns {shard=} {shard_specific_patterns=}") + return list(default_patterns | shard_specific_patterns) diff --git a/worker/download/impl_shard_downloader.py b/worker/download/impl_shard_downloader.py new file mode 100644 index 00000000..d8e329e3 --- /dev/null +++ b/worker/download/impl_shard_downloader.py @@ -0,0 +1,128 @@ +import asyncio +from pathlib import Path +from typing import AsyncIterator, Callable, Dict, List, Optional + +from shared.types.worker.shards import ( + PartitionStrategy, + PipelineShardMetadata, + ShardMetadata, +) +from worker.download.download_utils import RepoDownloadProgress, download_shard +from worker.download.model_cards import MODEL_CARDS +from worker.download.model_meta import get_model_meta +from worker.download.shard_downloader import ShardDownloader + + +def exo_shard_downloader(max_parallel_downloads: int = 8) -> ShardDownloader: + return SingletonShardDownloader(CachedShardDownloader(ResumableShardDownloader(max_parallel_downloads))) + +async def build_base_shard(model_id: str) -> Optional[ShardMetadata]: + model_meta = await get_model_meta(model_id) + # print(f"build_base_shard {model_id=} {model_meta=}") + return PipelineShardMetadata( + model_id=model_id, + partition_strategy=PartitionStrategy.pipeline, + device_rank=0, + world_size=1, + start_layer=0, + end_layer=model_meta.n_layers - 1, + n_layers=model_meta.n_layers, + ) + +async def build_full_shard(model_id: str) -> Optional[PipelineShardMetadata]: + base_shard = await build_base_shard(model_id) + if base_shard is None: + return None + return PipelineShardMetadata( + model_id=base_shard.model_id, + partition_strategy=base_shard.partition_strategy, + device_rank=base_shard.device_rank, + world_size=base_shard.world_size, + start_layer=base_shard.start_layer, + end_layer=base_shard.n_layers - 1, + n_layers=base_shard.n_layers, + ) + +class SingletonShardDownloader(ShardDownloader): + def __init__(self, shard_downloader: ShardDownloader): + self.shard_downloader = shard_downloader + self.active_downloads: Dict[ShardMetadata, asyncio.Task[Path]] = {} + + def on_progress(self, callback: Callable[[ShardMetadata, RepoDownloadProgress], None]) -> None: + self.shard_downloader.on_progress(callback) + + async def ensure_shard(self, shard: ShardMetadata, config_only: bool = False) -> Path: + if shard not in self.active_downloads: + self.active_downloads[shard] = asyncio.create_task(self.shard_downloader.ensure_shard(shard, config_only)) + try: + return await self.active_downloads[shard] + finally: + if shard in self.active_downloads and self.active_downloads[shard].done(): + del self.active_downloads[shard] + + async def get_shard_download_status(self) -> AsyncIterator[tuple[Path, RepoDownloadProgress]]: + async for path, status in self.shard_downloader.get_shard_download_status(): + yield path, status + +class CachedShardDownloader(ShardDownloader): + def __init__(self, shard_downloader: ShardDownloader): + self.shard_downloader = shard_downloader + self.cache: Dict[tuple[str, ShardMetadata], Path] = {} + + def on_progress(self, callback: Callable[[ShardMetadata, RepoDownloadProgress], None]) -> None: + self.shard_downloader.on_progress(callback) + + async def ensure_shard(self, shard: ShardMetadata, config_only: bool = False) -> Path: + if (shard.model_id, shard) in self.cache: + # print(f"ensure_shard cache hit {shard=}") + return self.cache[(shard.model_id, shard)] + + # print(f"ensure_shard cache miss {shard=}") + target_dir = await self.shard_downloader.ensure_shard(shard, config_only) + self.cache[(shard.model_id, shard)] = target_dir + return target_dir + + async def get_shard_download_status(self) -> AsyncIterator[tuple[Path, RepoDownloadProgress]]: + async for path, status in self.shard_downloader.get_shard_download_status(): + yield path, status + +class ResumableShardDownloader(ShardDownloader): + def __init__(self, max_parallel_downloads: int = 8): + self.max_parallel_downloads = max_parallel_downloads + self.on_progress_callbacks: List[Callable[[ShardMetadata, RepoDownloadProgress], None]] = [] + + def on_progress_wrapper(self, shard: ShardMetadata, progress: RepoDownloadProgress) -> None: + for callback in self.on_progress_callbacks: + callback(shard, progress) + + def on_progress(self, callback: Callable[[ShardMetadata, RepoDownloadProgress], None]) -> None: + self.on_progress_callbacks.append(callback) + + async def ensure_shard(self, shard: ShardMetadata, config_only: bool = False) -> Path: + allow_patterns = ["config.json"] if config_only else None + + # print(f"ensure_shard {shard=} {config_only=} {allow_patterns=}") + target_dir, _ = await download_shard(shard, self.on_progress_wrapper, max_parallel_downloads=self.max_parallel_downloads, allow_patterns=allow_patterns) + return target_dir + + async def get_shard_download_status(self) -> AsyncIterator[tuple[Path, RepoDownloadProgress]]: + # print("get_shard_download_status") + async def _status_for_model(model_id: str) -> Optional[tuple[Path, RepoDownloadProgress]]: + """Helper coroutine that builds the shard for a model and gets its download status.""" + shard = await build_full_shard(model_id) + if shard is None: + return None + return await download_shard(shard, self.on_progress_wrapper, skip_download=True) + + # Kick off download status coroutines concurrently + tasks = [asyncio.create_task(_status_for_model(model_id)) for model_id in MODEL_CARDS] + + for task in asyncio.as_completed(tasks): + try: + result = await task + if result is None: + continue + path, progress = result + yield (path, progress) + except Exception as e: + print("Error downloading shard:", e) diff --git a/worker/download/model_cards.py b/worker/download/model_cards.py new file mode 100644 index 00000000..b0ac69df --- /dev/null +++ b/worker/download/model_cards.py @@ -0,0 +1,133 @@ +from typing import List + +from pydantic import BaseModel + + +class ModelCard(BaseModel): + id: str + repo_id: str + name: str + description: str + tags: List[str] + +MODEL_CARDS = { + "llama-3.3": ModelCard( + id="llama-3.3", + repo_id="mlx-community/Llama-3.3-70B-Instruct-4bit", + name="Llama 3.3 70B", + description="The Meta Llama 3.3 multilingual large language model (LLM) is an instruction tuned generative model in 70B (text in/text out)", + tags=[]), + "llama-3.3:70b": ModelCard( + id="llama-3.3:70b", + repo_id="mlx-community/Llama-3.3-70B-Instruct-4bit", + name="Llama 3.3 70B", + description="The Meta Llama 3.3 multilingual large language model (LLM) is an instruction tuned generative model in 70B (text in/text out)", + tags=[]), + "llama-3.2": ModelCard( + id="llama-3.2", + repo_id="mlx-community/Llama-3.2-1B-Instruct-4bit", + name="Llama 3.2 1B", + description="Llama 3.2 is a large language model trained on the Llama 3.2 dataset.", + tags=[]), + "llama-3.2:1b": ModelCard( + id="llama-3.2:1b", + repo_id="mlx-community/Llama-3.2-1B-Instruct-4bit", + name="Llama 3.2 1B", + description="Llama 3.2 is a large language model trained on the Llama 3.2 dataset.", + tags=[]), + "llama-3.2:3b": ModelCard( + id="llama-3.2:3b", + repo_id="mlx-community/Llama-3.2-3B-Instruct-4bit", + name="Llama 3.2 3B", + description="Llama 3.2 is a large language model trained on the Llama 3.2 dataset.", + tags=[]), + "llama-3.1:8b": ModelCard( + id="llama-3.1:8b", + repo_id="mlx-community/Meta-Llama-3.1-8B-Instruct-4bit", + name="Llama 3.1 8B", + description="Llama 3.1 is a large language model trained on the Llama 3.1 dataset.", + tags=[]), + "llama-3.1-70b": ModelCard( + id="llama-3.1-70b", + repo_id="mlx-community/Meta-Llama-3.1-70B-Instruct-4bit", + name="Llama 3.1 70B", + description="Llama 3.1 is a large language model trained on the Llama 3.1 dataset.", + tags=[]), + "deepseek-r1": ModelCard( + id="deepseek-r1", + repo_id="mlx-community/DeepSeek-R1-4bit", + name="DeepSeek R1 671B (4-bit)", + description="DeepSeek R1 is a large language model trained on the DeepSeek R1 dataset.", + tags=[]), + "deepseek-r1:671b": ModelCard( + id="deepseek-r1:671b", # TODO: make sure model_id matches up for identical models + repo_id="mlx-community/DeepSeek-R1-4bit", + name="DeepSeek R1 671B", + description="DeepSeek R1 is a large language model trained on the DeepSeek R1 dataset.", + tags=[]), + "deepseek-v3": ModelCard( + id="deepseek-v3", + repo_id="mlx-community/DeepSeek-V3-0324-4bit", + name="DeepSeek V3 4B", + description="DeepSeek V3 is a large language model trained on the DeepSeek V3 dataset.", + tags=[]), + "deepseek-v3:671b": ModelCard( + id="deepseek-v3:671b", + repo_id="mlx-community/DeepSeek-V3-0324-4bit", + name="DeepSeek V3 671B", + description="DeepSeek V3 is a large language model trained on the DeepSeek V3 dataset.", + tags=[]), + "phi-3-mini": ModelCard( + id="phi-3-mini", + repo_id="mlx-community/Phi-3-mini-128k-instruct-4bit", + name="Phi 3 Mini 128k", + description="Phi 3 Mini is a large language model trained on the Phi 3 Mini dataset.", + tags=[]), + "phi-3-mini:128k": ModelCard( + id="phi-3-mini:128k", + repo_id="mlx-community/Phi-3-mini-128k-instruct-4bit", + name="Phi 3 Mini 128k", + description="Phi 3 Mini is a large language model trained on the Phi 3 Mini dataset.", + tags=[]), + "qwen3-0.6b": ModelCard( + id="qwen3-0.6b", + repo_id="mlx-community/Qwen3-0.6B-4bit", + name="Qwen3 0.6B", + description="Qwen3 0.6B is a large language model trained on the Qwen3 0.6B dataset.", + tags=[]), + "qwen3-30b": ModelCard( + id="qwen3-30b", + repo_id="mlx-community/Qwen3-30B-A3B-4bit", + name="Qwen3 30B (Active 3B)", + description="Qwen3 30B is a large language model trained on the Qwen3 30B dataset.", + tags=[]), + "granite-3.3-2b": ModelCard( + id="granite-3.3-2b", + repo_id="mlx-community/granite-3.3-2b-instruct-fp16", + name="Granite 3.3 2B", + description="Granite-3.3-2B-Instruct is a 2-billion parameter 128K context length language model fine-tuned for improved reasoning and instruction-following capabilities.", + tags=[]), + "granite-3.3-8b": ModelCard( + id="granite-3.3-8b", + repo_id="mlx-community/granite-3.3-8b-instruct-fp16", + name="Granite 3.3 8B", + description="Granite-3.3-8B-Instruct is a 8-billion parameter 128K context length language model fine-tuned for improved reasoning and instruction-following capabilities.", + tags=[]), + "smol-lm-135m": ModelCard( + id="smol-lm-135m", + repo_id="mlx-community/SmolLM-135M-4bit", + name="Smol LM 135M", + description="SmolLM is a series of state-of-the-art small language models available in three sizes: 135M, 360M, and 1.7B parameters. ", + tags=[]), +} + +def get_huggingface_id(model: str) -> str: + if "mlx-community/" in model: + return model + if model not in MODEL_CARDS: + raise ValueError(f"Model {model} not found") + return MODEL_CARDS[model].repo_id + +if __name__ == "__main__": + for model in MODEL_CARDS: + print(f"{model} -> {get_huggingface_id(model)}") diff --git a/worker/download/model_meta.py b/worker/download/model_meta.py new file mode 100644 index 00000000..f0022723 --- /dev/null +++ b/worker/download/model_meta.py @@ -0,0 +1,124 @@ +import json +from typing import Annotated, Dict, Optional + +import aiofiles +from huggingface_hub import model_info +from huggingface_hub.errors import EntryNotFoundError, HfHubHTTPError +from pydantic import BaseModel, Field + +from shared.types.models import ModelMetadata +from worker.download.download_utils import ( + ModelSafetensorsIndex, + download_file_with_retry, + ensure_exo_tmp, +) +from worker.download.model_cards import MODEL_CARDS + + +class ConfigData(BaseModel): + num_hidden_layers: Optional[Annotated[int, Field(ge=0)]] + num_layers: Optional[Annotated[int, Field(ge=0)]] + n_layer: Optional[Annotated[int, Field(ge=0)]] + +async def get_config_data(model_id: str) -> Optional[ConfigData]: + """Downloads and parses config.json for a model.""" + try: + model_card = MODEL_CARDS[model_id] + target_dir = (await ensure_exo_tmp())/model_card.repo_id.replace("/", "--") + config_path = await download_file_with_retry(model_card.repo_id, "main", "config.json", target_dir, lambda curr_bytes, total_bytes: print(f"Downloading config.json for {model_id}: {curr_bytes}/{total_bytes}")) + async with aiofiles.open(config_path, 'r') as f: + return ConfigData.model_validate_json(await f.read()) + except EntryNotFoundError: + print(f"Warning: config.json not found for {model_id}. Layers/type from config unavailable.") + except json.JSONDecodeError: + print(f"Error: Failed to parse config.json for {model_id}.") + except Exception as e: + print(f"Error: Error processing config.json for {model_id}: {e}") + return None + +def get_num_layers(config_data: Optional[ConfigData], model_id: str) -> Optional[int]: + """Extracts number of layers from config data.""" + if not config_data: + return None + + if config_data.num_hidden_layers is not None: + return config_data.num_hidden_layers + if config_data.num_layers is not None: + return config_data.num_layers + if config_data.n_layer is not None: + return config_data.n_layer + + print(f"Warning: No known layer key or valid number in config.json for {model_id}. Config: {config_data.model_dump_json()}") + return None + +async def get_safetensors_size(model_id: str) -> Optional[int]: + """Gets model size from safetensors index or falls back to HF API.""" + try: + model_card = MODEL_CARDS[model_id] + target_dir = (await ensure_exo_tmp())/model_card.repo_id.replace("/", "--") + index_path = await download_file_with_retry(model_card.repo_id, "main", "model.safetensors.index.json", target_dir, lambda curr_bytes, total_bytes: print(f"Downloading model.safetensors.index.json for {model_id}: {curr_bytes}/{total_bytes}")) + async with aiofiles.open(index_path, 'r') as f: + index_data = ModelSafetensorsIndex.model_validate_json(await f.read()) + + metadata = index_data.metadata + if metadata is not None: + return metadata.total_size + print(f"Warning: Could not extract total_size from safetensors index metadata for {model_id}. Metadata: {index_data.model_dump_json()}") + + except EntryNotFoundError: + print(f"Warning: model.safetensors.index.json not found for {model_id}.") + except json.JSONDecodeError: + print(f"Error: Failed to parse model.safetensors.index.json for {model_id}.") + except Exception as e: + print(f"Error: Error processing model.safetensors.index.json for {model_id}: {e}") + + print(f"Warning: Could not determine safetensors total size from index for {model_id}. Falling back to model_info API call.") + try: + info = model_info(model_id) + if info.safetensors is not None: + return info.safetensors.total + print(f"Warning: Could not get safetensors total size from model_info API for {model_id}. Safetensors info: {info}") + except HfHubHTTPError as e: + print(f"Error: HTTP Error while fetching model info from API for {model_id}: {e}") + except Exception as e: + print(f"Error: Error getting total size from huggingface info API for {model_id}: {e}") + return None + +_model_meta_cache: Dict[str, ModelMetadata] = {} +async def get_model_meta(model_id: str) -> ModelMetadata: + if model_id in _model_meta_cache: + return _model_meta_cache[model_id] + model_meta = await _get_model_meta(model_id) + _model_meta_cache[model_id] = model_meta + return model_meta + +async def _get_model_meta(model_id: str) -> ModelMetadata: + """Fetches storage size and number of layers for a Hugging Face model, returns Pydantic ModelMeta.""" + model_card = MODEL_CARDS[model_id] + num_layers_val: Optional[int] = None + mem_size_bytes_val: Optional[int] = None + try: + config_data = await get_config_data(model_id) + # get_num_layers is synchronous + num_layers_val = get_num_layers(config_data, model_id) + mem_size_bytes_val = await get_safetensors_size(model_id) + + except HfHubHTTPError as e: + print(f"Error: HTTP Error encountered for '{model_id}': {e}") + except Exception as e: + print(f"Error: Unexpected error during metadata fetching for '{model_id}': {e}") + + # Fallbacks for missing metadata + if mem_size_bytes_val is None: + print(f"Warning: Could not determine model size for {model_id}. Defaulting to 0 bytes.") + mem_size_bytes_val = 0 + if num_layers_val is None: + print(f"Warning: Could not determine number of layers for {model_id}. Defaulting to 0 layers.") + num_layers_val = 0 + + return ModelMetadata( + model_id=model_id, + pretty_name=model_card.name, + storage_size_kilobytes=mem_size_bytes_val // 1024, + n_layers=num_layers_val, + ) diff --git a/worker/download/shard_downloader.py b/worker/download/shard_downloader.py new file mode 100644 index 00000000..b76aa9ec --- /dev/null +++ b/worker/download/shard_downloader.py @@ -0,0 +1,96 @@ +from abc import ABC, abstractmethod +from datetime import timedelta +from pathlib import Path +from typing import AsyncIterator, Callable + +from shared.types.worker.shards import ( + PartitionStrategy, + PipelineShardMetadata, + ShardMetadata, +) +from worker.download.download_utils import RepoDownloadProgress + + +class ShardDownloader(ABC): + @abstractmethod + async def ensure_shard(self, shard: ShardMetadata, config_only: bool = False) -> Path: + """ + Ensures that the shard is downloaded. + Does not allow multiple overlapping downloads at once. + If you try to download a Shard which overlaps a Shard that is already being downloaded, + the download will be cancelled and a new download will start. + + Args: + shard (Shard): The shard to download. + inference_engine_name (str): The inference engine used on the node hosting the shard + """ + + @abstractmethod + def on_progress(self, callback: Callable[[ShardMetadata, RepoDownloadProgress], None]) -> None: + pass + + @abstractmethod + async def get_shard_download_status(self) -> AsyncIterator[tuple[Path, RepoDownloadProgress]]: + """Get the download status of shards. + + Yields: + tuple[Path, RepoDownloadProgress]: The path and progress of a shard download. + """ + yield ( + Path("/tmp/noop_shard"), + RepoDownloadProgress( + repo_id="noop", + repo_revision="noop", + shard=PipelineShardMetadata( + model_id="noop", + partition_strategy=PartitionStrategy.pipeline, + device_rank=0, + world_size=1, + start_layer=0, + end_layer=0, + n_layers=1, + ), + completed_files=0, + total_files=0, + downloaded_bytes=0, + downloaded_bytes_this_session=0, + total_bytes=0, + overall_speed=0, + overall_eta=timedelta(seconds=0), + status="complete", + ) + ) + + +class NoopShardDownloader(ShardDownloader): + async def ensure_shard(self, shard: ShardMetadata, config_only: bool = False) -> Path: + return Path("/tmp/noop_shard") + + def on_progress(self, callback: Callable[[ShardMetadata, RepoDownloadProgress], None]) -> None: + pass + + async def get_shard_download_status(self) -> AsyncIterator[tuple[Path, RepoDownloadProgress]]: + yield ( + Path("/tmp/noop_shard"), + RepoDownloadProgress( + repo_id="noop", + repo_revision="noop", + shard=PipelineShardMetadata( + model_id="noop", + partition_strategy=PartitionStrategy.pipeline, + device_rank=0, + world_size=1, + start_layer=0, + end_layer=0, + n_layers=1, + ), + completed_files=0, + total_files=0, + downloaded_bytes=0, + downloaded_bytes_this_session=0, + total_bytes=0, + overall_speed=0, + overall_eta=timedelta(seconds=0), + status="complete", + ) + ) diff --git a/worker/download/test_download.py b/worker/download/test_download.py new file mode 100644 index 00000000..db38313f --- /dev/null +++ b/worker/download/test_download.py @@ -0,0 +1,54 @@ +import time + +import pytest + +from shared.types.models import ModelId +from shared.types.worker.shards import PartitionStrategy, PipelineShardMetadata +from worker.download.impl_shard_downloader import exo_shard_downloader +from worker.download.shard_downloader import ShardDownloader + + +@pytest.mark.asyncio +async def test_shard_downloader(): + shard_downloader: ShardDownloader = exo_shard_downloader() + shard_downloader.on_progress( + lambda shard, progress: print(f"Download progress: {progress}") + ) + + shard_metadata = PipelineShardMetadata( + model_id=ModelId("mlx-community/Llama-3.2-1B-Instruct-4bit"), + partition_strategy=PartitionStrategy.pipeline, + device_rank=0, + world_size=1, + start_layer=0, + end_layer=100, + n_layers=100, + ) + path = await shard_downloader.ensure_shard(shard_metadata) + assert path.exists() + + downloaded_model_path = path.parent / "mlx-community--Llama-3.2-1B-Instruct-4bit" + assert (downloaded_model_path / "config.json").exists() + assert (downloaded_model_path / "model.safetensors").exists() + assert (downloaded_model_path / "model.safetensors.index.json").exists() + assert (downloaded_model_path / "special_tokens_map.json").exists() + assert (downloaded_model_path / "tokenizer.json").exists() + assert (downloaded_model_path / "tokenizer_config.json").exists() + + expected_files_and_sizes = [ + ("config.json", 1121), + ("model.safetensors", 695283921), + ("model.safetensors.index.json", 26159), + ("special_tokens_map.json", 296), + ("tokenizer.json", 17209920), + ("tokenizer_config.json", 54558), + ] + for filename, expected_size in expected_files_and_sizes: + file_path = downloaded_model_path / filename + assert file_path.stat().st_size == expected_size, f"{filename} size mismatch" + + start_time = time.monotonic() + path_again = await shard_downloader.ensure_shard(shard_metadata) + duration = time.monotonic() - start_time + assert path_again == path + assert duration < 5, f"Second call to ensure_shard took too long: {duration:.2f}s" diff --git a/worker/main.py b/worker/main.py index 52094970..e7f7f21a 100644 --- a/worker/main.py +++ b/worker/main.py @@ -1,6 +1,6 @@ import asyncio import os -from asyncio.queues import Queue +from asyncio import Queue from functools import partial from logging import Logger from typing import AsyncGenerator, Optional @@ -39,6 +39,7 @@ from shared.types.worker.runners import ( RunningRunnerStatus, ) from shared.types.worker.shards import ShardMetadata +from worker.download.download_utils import build_model_path from worker.runner.runner_supervisor import RunnerSupervisor @@ -56,7 +57,7 @@ class AssignedRunner(BaseModel): @property def is_downloaded(self) -> bool: # TODO: Do this properly with huggingface validating each of the files. - return os.path.exists(self.shard_metadata.model_path) + return os.path.exists(build_model_path(self.shard_metadata.model_id)) def status_update_event(self) -> RunnerStatusUpdated: return RunnerStatusUpdated( @@ -334,7 +335,9 @@ class Worker: # Handle state updates async def _loop(self): while True: - state_copy = self.state.model_copy(deep=True) + state_copy = self.state.model_copy(deep=False) + state_copy.task_inbox = [] + state_copy.task_outbox = [] op: RunnerOp | None = self.plan(state_copy) diff --git a/worker/tests/conftest.py b/worker/tests/conftest.py index 4fae4868..f5d2f93b 100644 --- a/worker/tests/conftest.py +++ b/worker/tests/conftest.py @@ -30,7 +30,7 @@ from worker.main import Worker @pytest.fixture -def pipeline_shard_meta(): +def pipeline_shard_meta(tmp_path: Path): def _pipeline_shard_meta( num_nodes: int = 1, device_rank: int = 0 ) -> PipelineShardMetadata: @@ -46,9 +46,7 @@ def pipeline_shard_meta(): return PipelineShardMetadata( device_rank=device_rank, model_id=ModelId(uuid.uuid4()), - model_path=Path( - "~/.exo/models/mlx-community--Llama-3.2-1B-Instruct-4bit/" - ).expanduser(), + n_layers=total_layers, start_layer=start_layer, end_layer=end_layer, world_size=num_nodes, diff --git a/worker/tests/test_serdes.py b/worker/tests/test_serdes.py index a90552db..7ae81bf3 100644 --- a/worker/tests/test_serdes.py +++ b/worker/tests/test_serdes.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import Callable, TypeVar from pydantic import BaseModel, TypeAdapter @@ -26,6 +27,7 @@ def assert_equal_serdes(obj: T, typeadapter: TypeAdapter[T]): def test_supervisor_setup_message_serdes( pipeline_shard_meta: Callable[..., PipelineShardMetadata], hosts: Callable[..., list[Host]], + tmp_path: Path, ): setup_message = SetupMessage( model_shard_meta=pipeline_shard_meta(1, 0), diff --git a/worker/tests/test_supervisor.py b/worker/tests/test_supervisor.py index c5df37e9..686630e5 100644 --- a/worker/tests/test_supervisor.py +++ b/worker/tests/test_supervisor.py @@ -1,4 +1,5 @@ import asyncio +from pathlib import Path from typing import Callable import pytest @@ -26,6 +27,7 @@ async def test_supervisor_single_node_response( pipeline_shard_meta: Callable[..., PipelineShardMetadata], hosts: Callable[..., list[Host]], chat_task: Task, + tmp_path: Path, ): """Test that asking for the capital of France returns 'Paris' in the response""" model_shard_meta = pipeline_shard_meta(1, 0) @@ -62,6 +64,7 @@ async def test_supervisor_two_node_response( pipeline_shard_meta: Callable[..., PipelineShardMetadata], hosts: Callable[..., list[Host]], chat_task: Task, + tmp_path: Path, ): """Test that asking for the capital of France returns 'Paris' in the response""" supervisor_0 = await RunnerSupervisor.create( @@ -116,6 +119,7 @@ async def test_supervisor_early_stopping( pipeline_shard_meta: Callable[..., PipelineShardMetadata], hosts: Callable[..., list[Host]], chat_task: Task, + tmp_path: Path, ): """Test that asking for the capital of France returns 'Paris' in the response""" model_shard_meta = pipeline_shard_meta(1, 0) @@ -166,6 +170,7 @@ async def test_supervisor_handles_terminated_runner( pipeline_shard_meta: Callable[..., PipelineShardMetadata], hosts: Callable[..., list[Host]], chat_task: Task, + tmp_path: Path, ): """Test that the supervisor handles a terminated runner""" model_shard_meta = pipeline_shard_meta(1, 0) @@ -190,6 +195,7 @@ async def test_supervisor_handles_killed_runner( pipeline_shard_meta: Callable[..., PipelineShardMetadata], hosts: Callable[..., list[Host]], chat_task: Task, + tmp_path: Path, ): """Test that the supervisor handles a killed runner""" model_shard_meta = pipeline_shard_meta(1, 0) diff --git a/worker/tests/test_worker_handlers.py b/worker/tests/test_worker_handlers.py index e676cb3f..04390658 100644 --- a/worker/tests/test_worker_handlers.py +++ b/worker/tests/test_worker_handlers.py @@ -1,6 +1,7 @@ ## Tests for worker state handlers import asyncio +from pathlib import Path from typing import Callable import pytest @@ -35,7 +36,7 @@ def user_message(): return "What, according to Douglas Adams, is the meaning of life, the universe and everything?" @pytest.mark.asyncio -async def test_assign_op(worker: Worker, instance: Callable[[NodeId], Instance]): +async def test_assign_op(worker: Worker, instance: Callable[[NodeId], Instance], tmp_path: Path): await worker.start() await asyncio.sleep(0.01) @@ -67,7 +68,7 @@ async def test_assign_op(worker: Worker, instance: Callable[[NodeId], Instance]) assert isinstance(worker.assigned_runners[runner_id].status, ReadyRunnerStatus) @pytest.mark.asyncio -async def test_unassign_op(worker_with_assigned_runner: tuple[Worker, RunnerId, Instance]): +async def test_unassign_op(worker_with_assigned_runner: tuple[Worker, RunnerId, Instance], tmp_path: Path): worker, runner_id, _ = worker_with_assigned_runner unassign_op = UnassignRunnerOp( @@ -84,7 +85,7 @@ async def test_unassign_op(worker_with_assigned_runner: tuple[Worker, RunnerId, assert len(events) == 0 @pytest.mark.asyncio -async def test_runner_up_op(worker_with_assigned_runner: tuple[Worker, RunnerId, Instance], chat_task: Task): +async def test_runner_up_op(worker_with_assigned_runner: tuple[Worker, RunnerId, Instance], chat_task: Task, tmp_path: Path): worker, runner_id, _ = worker_with_assigned_runner runner_up_op = RunnerUpOp(runner_id=runner_id) @@ -117,7 +118,7 @@ async def test_runner_up_op(worker_with_assigned_runner: tuple[Worker, RunnerId, await runner.astop() # Neat cleanup. @pytest.mark.asyncio -async def test_runner_down_op(worker_with_running_runner: tuple[Worker, RunnerId, Instance]): +async def test_runner_down_op(worker_with_running_runner: tuple[Worker, RunnerId, Instance], tmp_path: Path): worker, runner_id, _ = worker_with_running_runner runner_down_op = RunnerDownOp(runner_id=runner_id) @@ -130,7 +131,7 @@ async def test_runner_down_op(worker_with_running_runner: tuple[Worker, RunnerId assert isinstance(events[0].runner_status, ReadyRunnerStatus) @pytest.mark.asyncio -async def test_download_op(worker_with_assigned_runner: tuple[Worker, RunnerId, Instance]): +async def test_download_op(worker_with_assigned_runner: tuple[Worker, RunnerId, Instance], tmp_path: Path): worker, runner_id, instance_obj = worker_with_assigned_runner print(f'{worker.assigned_runners=}') @@ -153,7 +154,7 @@ async def test_download_op(worker_with_assigned_runner: tuple[Worker, RunnerId, @pytest.mark.asyncio async def test_execute_task_op( worker_with_running_runner: tuple[Worker, RunnerId, Instance], - chat_task: Task): + chat_task: Task, tmp_path: Path): worker, runner_id, _ = worker_with_running_runner execute_task_op = ExecuteTaskOp( @@ -187,7 +188,7 @@ async def test_execute_task_op( @pytest.mark.asyncio async def test_execute_task_fails( worker_with_running_runner: tuple[Worker, RunnerId, Instance], - chat_task: Task): + chat_task: Task, tmp_path: Path): worker, runner_id, _ = worker_with_running_runner messages = chat_task.task_params.messages diff --git a/worker/tests/test_worker_plan.py b/worker/tests/test_worker_plan.py index cdc59623..c2c71508 100644 --- a/worker/tests/test_worker_plan.py +++ b/worker/tests/test_worker_plan.py @@ -23,6 +23,7 @@ from shared.types.worker.runners import ( ShardAssignments, ) from shared.types.worker.shards import PipelineShardMetadata +from worker.download.download_utils import build_model_path from worker.main import AssignedRunner, Worker @@ -148,9 +149,9 @@ def _build_worker_state( device_rank=0, world_size=1, model_id=model_id, - model_path=model_subdir, start_layer=0, end_layer=0, + n_layers=1, ) shard_assignments = ShardAssignments( @@ -233,7 +234,7 @@ def test_worker_plan(case: PlanTestCase, tmp_path: Path, monkeypatch: pytest.Mon ) worker.assigned_runners[ctx.runner_id] = assigned_runner - path_downloaded_map[str(ctx.shard_metadata.model_path)] = runner_case.downloaded + path_downloaded_map[str(build_model_path(ctx.shard_metadata.model_id))] = runner_case.downloaded # Stub filesystem existence check ------------------------------------------------------ from worker import main as worker_main # local import for module-scoped os