diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml new file mode 100644 index 0000000..923cbf6 --- /dev/null +++ b/.github/workflows/mypy.yml @@ -0,0 +1,27 @@ +name: Mypy +on: [push] + +jobs: + Static-Type-Checking: + runs-on: ubuntu-latest + strategy: + max-parallel: 5 + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install system packages + run: sudo apt-get install -y portaudio19-dev + - name: Display Python version + run: python -c "import sys; print(sys.version)" + - name: Install dependencies + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Type-checking package with mypy + run: | + uv run --all-extras mypy --strict . diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..4941c63 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,17 @@ +name: pre-commit + +on: + pull_request: + push: + branches: [main] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11.2 + - uses: pre-commit/action@v3.0.0 diff --git a/.gitignore b/.gitignore index efa407c..82f9275 100644 --- a/.gitignore +++ b/.gitignore @@ -159,4 +159,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ \ No newline at end of file +#.idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..caae235 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +exclude: '^(body|open_gopro)/' + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files +- repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.0.1 # Use the sha / tag you want to point at + hooks: + - id: prettier + types_or: [html] +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.3.5 + hooks: + # Run the linter. + - id: ruff + types_or: [ python, pyi, jupyter ] + args: [ --fix ] + # Run the formatter. + - id: ruff-format + types_or: [ python, pyi, jupyter ] +- repo: https://github.com/kynan/nbstripout + rev: 0.6.0 + hooks: + - id: nbstripout diff --git a/README.md b/README.md index 659df01..a5b67b6 100644 --- a/README.md +++ b/README.md @@ -27,4 +27,4 @@ You will see a tick printed every second. pip install aact[audio] docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest aact run-dataflow examples/speaker_listener.toml -``` \ No newline at end of file +``` diff --git a/examples/example.toml b/examples/example.toml index a2fc9d9..60a490e 100644 --- a/examples/example.toml +++ b/examples/example.toml @@ -9,4 +9,4 @@ node_class = "print" [[nodes]] node_name = "tick" -node_class = "tick" \ No newline at end of file +node_class = "tick" diff --git a/examples/speaker_listener.toml b/examples/speaker_listener.toml index 9ea1846..ecf3a26 100644 --- a/examples/speaker_listener.toml +++ b/examples/speaker_listener.toml @@ -14,4 +14,4 @@ node_class = "listener" [nodes.node_args] -output_channel = "audio_input" \ No newline at end of file +output_channel = "audio_input" diff --git a/pyproject.toml b/pyproject.toml index 38ae926..f6348d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,27 +1,30 @@ [project] name = "aact" -version = "0.0.2" description = "An actor model library for multi-agent/environment interaction in Python based on Redis." readme = "README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.8.2", "redis>=5.0.8", - "types-requests>=2.32.0.20240712", "requests>=2.32.3", - "toml>=0.10.2", - "types-toml>=0.10.8.20240310", "aiofiles>=24.1.0", - "types-aiofiles>=24.1.0.20240626", "aiostream>=0.6.2", - "google-cloud-speech>=2.27.0", - "google-cloud-texttospeech>=2.17.2", "rq>=1.16.2", "typer>=0.12.5", "numpy>=2.1.0", + "tomlkit>=0.13.0; python_version <= '3.10'", ] +dynamic = ["version"] + +[tool.hatch] +version = {path = "src/aact/__about__.py"} [project.optional-dependencies] +typing = [ + "mypy>=0.910", + "types-requests>=2.32.0.20240712", + "types-aiofiles>=24.1.0.20240626", +] vision = [ "opencv-python" ] @@ -29,6 +32,10 @@ audio = [ "pyaudio >= 0.2.14", "types-pyaudio >= 0.2.16.20240516", ] +google = [ + "google-cloud-speech>=2.27.0", + "google-cloud-texttospeech>=2.17.2", +] [build-system] requires = ["hatchling"] @@ -46,4 +53,4 @@ plugins = [ ] [project.scripts] -aact = 'aact.cli:app' \ No newline at end of file +aact = 'aact.cli:app' diff --git a/src/aact/__about__.py b/src/aact/__about__.py new file mode 100644 index 0000000..27fdca4 --- /dev/null +++ b/src/aact/__about__.py @@ -0,0 +1 @@ +__version__ = "0.0.3" diff --git a/src/aact/cli/launch/launch.py b/src/aact/cli/launch/launch.py index aae5580..48852ba 100644 --- a/src/aact/cli/launch/launch.py +++ b/src/aact/cli/launch/launch.py @@ -2,6 +2,7 @@ import logging import os import signal +import sys import time from typing import Annotated, Any, Optional, TypeVar from ..app import app @@ -12,7 +13,10 @@ from subprocess import Popen -import toml +if sys.version_info >= (3, 11): + import tomllib +else: + import tomlkit as tomllib from rq import Queue from rq.exceptions import InvalidJobOperation from rq.job import Job @@ -59,7 +63,7 @@ def run_node( redis_url: str = typer.Option(), ) -> None: logger = logging.getLogger(__name__) - config = Config.model_validate(toml.load(dataflow_toml)) + config = Config.model_validate(tomllib.load(open(dataflow_toml, "rb"))) logger.info(f"Starting dataflow with config {config}") # dynamically import extra modules for module in config.extra_modules: @@ -86,11 +90,10 @@ def run_dataflow( ), ) -> None: logger = logging.getLogger(__name__) - config = Config.model_validate(toml.load(dataflow_toml)) + config = Config.model_validate(tomllib.load(open(dataflow_toml, "rb"))) logger.info(f"Starting dataflow with config {config}") if with_rq: - redis = Redis.from_url(config.redis_url) queue = Queue(connection=redis) job_ids: list[str] = [] @@ -100,22 +103,28 @@ def run_dataflow( try: # Wait for all jobs to finish - while not all(Job.fetch(job_id, connection=redis).get_status() == "finished" for job_id in job_ids): + while not all( + Job.fetch(job_id, connection=redis).get_status() == "finished" + for job_id in job_ids + ): time.sleep(1) except KeyboardInterrupt: logger.warning("Terminating RQ jobs.") for job_id in job_ids: logger.info(f"Terminating job {job_id}") try: - send_stop_job_command(redis, job_id) # stop the job if it's running + send_stop_job_command(redis, job_id) # stop the job if it's running except InvalidJobOperation: - logger.info(f"Job {job_id} is not currently executing. Trying to delete it from queue.") + logger.info( + f"Job {job_id} is not currently executing. Trying to delete it from queue." + ) job = Job.fetch(job_id, connection=redis) - job.delete() # remove job from redis - logger.info(f"Job {job_id} has been terminated. Job status: {job.get_status()}") + job.delete() # remove job from redis + logger.info( + f"Job {job_id} has been terminated. Job status: {job.get_status()}" + ) finally: return - subprocesses: list[Popen[bytes]] = [] diff --git a/src/aact/cli/reader/dataflow_reader.py b/src/aact/cli/reader/dataflow_reader.py index aa2a980..ed15ec4 100644 --- a/src/aact/cli/reader/dataflow_reader.py +++ b/src/aact/cli/reader/dataflow_reader.py @@ -1,7 +1,12 @@ import base64 from collections import defaultdict import logging -import toml +import sys + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomlkit as tomllib from pydantic import BaseModel, ConfigDict, Field import requests @@ -36,7 +41,7 @@ def get_dataflow_config(dataflow_toml: str) -> Config: """ logger = logging.getLogger(__name__) - config = Config.model_validate(toml.load(dataflow_toml)) + config = Config.model_validate(tomllib.load(open(dataflow_toml, "rb"))) logger.info(f"Starting dataflow with config {config}") return config diff --git a/src/aact/nodes/base.py b/src/aact/nodes/base.py index 4a2ab9c..188e033 100644 --- a/src/aact/nodes/base.py +++ b/src/aact/nodes/base.py @@ -1,4 +1,5 @@ import sys + if sys.version_info >= (3, 11): from typing import Self else: diff --git a/src/aact/nodes/listener.py b/src/aact/nodes/listener.py index 9e44f65..37b8897 100644 --- a/src/aact/nodes/listener.py +++ b/src/aact/nodes/listener.py @@ -19,16 +19,19 @@ # Runtime import try: import pyaudio + PYAUDIO_AVAILABLE = True except ImportError: PYAUDIO_AVAILABLE = False + @NodeFactory.register("listener") class ListenerNode(Node[Zero, Audio]): def __init__(self, output_channel: str, redis_url: str): if not PYAUDIO_AVAILABLE: raise ImportError( - "PyAudio is not available. Please install it to use the ListenerNode." + "PyAudio is not available." + "Please install aact with `pip install aact[audio]` to use the ListenerNode." ) super().__init__( @@ -37,14 +40,14 @@ def __init__(self, output_channel: str, redis_url: str): redis_url=redis_url, ) self.output_channel = output_channel - self.audio: 'pyaudio.PyAudio' = pyaudio.PyAudio() - self.stream: Optional['pyaudio.Stream'] = None + self.audio: "pyaudio.PyAudio" = pyaudio.PyAudio() + self.stream: Optional["pyaudio.Stream"] = None self.queue: asyncio.Queue[bytes] = asyncio.Queue() self.task: Optional[asyncio.Task[None]] = None async def __aenter__(self) -> Self: if PYAUDIO_AVAILABLE: - self.stream = self.audio.open( + self.stream = self.audio.open( format=pyaudio.paInt16, channels=1, rate=44100, @@ -57,10 +60,10 @@ async def __aenter__(self) -> Self: async def __aexit__(self, _: Any, __: Any, ___: Any) -> None: if self.stream: - self.stream.stop_stream() - self.stream.close() + self.stream.stop_stream() + self.stream.close() if PYAUDIO_AVAILABLE: - self.audio.terminate() + self.audio.terminate() if self.task: self.task.cancel() try: @@ -92,4 +95,4 @@ async def event_handler( self, _: str, __: Message[Zero] ) -> AsyncIterator[tuple[str, Message[Audio]]]: raise NotImplementedError("ListenerNode does not have an event handler.") - yield "", Message[DataModel](data=Zero()) \ No newline at end of file + yield "", Message[DataModel](data=Zero()) diff --git a/src/aact/nodes/print.py b/src/aact/nodes/print.py index 876a496..ee7a7c1 100644 --- a/src/aact/nodes/print.py +++ b/src/aact/nodes/print.py @@ -1,5 +1,6 @@ import asyncio import sys + if sys.version_info >= (3, 11): from typing import Self else: diff --git a/src/aact/nodes/record.py b/src/aact/nodes/record.py index 3522d48..a1a465f 100644 --- a/src/aact/nodes/record.py +++ b/src/aact/nodes/record.py @@ -1,6 +1,7 @@ import asyncio from datetime import datetime import sys + if sys.version_info >= (3, 11): from typing import Self else: diff --git a/src/aact/nodes/speaker.py b/src/aact/nodes/speaker.py index d88f59f..3e6c4f2 100644 --- a/src/aact/nodes/speaker.py +++ b/src/aact/nodes/speaker.py @@ -1,5 +1,6 @@ import sys from typing import Any, AsyncIterator, Optional, TYPE_CHECKING + if sys.version_info >= (3, 11): from typing import Self else: @@ -16,10 +17,12 @@ # Runtime import try: import pyaudio + PYAUDIO_AVAILABLE = True except ImportError: PYAUDIO_AVAILABLE = False + @NodeFactory.register("speaker") class SpeakerNode(Node[Audio, Zero]): def __init__( @@ -29,29 +32,30 @@ def __init__( ): if not PYAUDIO_AVAILABLE: raise ImportError( - "PyAudio is not available. Please install it to use the SpeakerNode." + "PyAudio is not available." + "Please install aact with `pip install aact[audio]` to use the SpeakerNode." ) - + super().__init__( input_channel_types=[(input_channel, Audio)], output_channel_types=[], redis_url=redis_url, ) self.input_channel = input_channel - self.audio: 'pyaudio.PyAudio' = pyaudio.PyAudio() - self.stream: Optional['pyaudio.Stream'] = None + self.audio: "pyaudio.PyAudio" = pyaudio.PyAudio() + self.stream: Optional["pyaudio.Stream"] = None async def __aenter__(self) -> Self: if PYAUDIO_AVAILABLE: - self.stream = self.audio.open( + self.stream = self.audio.open( format=pyaudio.paInt16, channels=1, rate=44100, output=True ) return await super().__aenter__() async def __aexit__(self, _: Any, __: Any, ___: Any) -> None: if self.stream: - self.stream.stop_stream() - self.stream.close() + self.stream.stop_stream() + self.stream.close() if PYAUDIO_AVAILABLE: self.audio.terminate() return await super().__aexit__(_, __, ___) @@ -67,4 +71,4 @@ async def event_handler( "Stream is not initialized. Please use the async context manager." ) else: - yield "", Message[Zero](data=Zero()) \ No newline at end of file + yield "", Message[Zero](data=Zero()) diff --git a/src/aact/nodes/tick.py b/src/aact/nodes/tick.py index 3220cb3..7b10f94 100644 --- a/src/aact/nodes/tick.py +++ b/src/aact/nodes/tick.py @@ -1,5 +1,6 @@ import asyncio import sys + if sys.version_info >= (3, 11): from typing import Self else: diff --git a/src/aact/nodes/transcriber.py b/src/aact/nodes/transcriber.py index a915771..63d1a52 100644 --- a/src/aact/nodes/transcriber.py +++ b/src/aact/nodes/transcriber.py @@ -1,19 +1,29 @@ import asyncio import sys + if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self -from typing import Any, AsyncIterator +from typing import TYPE_CHECKING, Any, AsyncIterator from ..messages.base import Message from ..messages.commons import Audio, Text from .base import Node from .registry import NodeFactory -from google.cloud import speech_v1p1beta1 as speech -from google.api_core import exceptions -from google.api_core.client_options import ClientOptions +if TYPE_CHECKING: + from google.cloud import speech_v1p1beta1 as speech + from google.api_core import exceptions + from google.api_core.client_options import ClientOptions +try: + from google.cloud import speech_v1p1beta1 as speech + from google.api_core import exceptions + from google.api_core.client_options import ClientOptions + + GOOLE_CLOUD_SPEECH_AVAILABLE = True +except ImportError: + GOOLE_CLOUD_SPEECH_AVAILABLE = False @NodeFactory.register("transcriber") @@ -26,6 +36,11 @@ def __init__( api_key: str, redis_url: str, ) -> None: + if not GOOLE_CLOUD_SPEECH_AVAILABLE: + raise ImportError( + "Google Cloud Speech is not available. Please install" + "aact with `pip install aact[google]` to use the TranscriberNode." + ) super().__init__( input_channel_types=[(input_channel, Audio)], output_channel_types=[(output_channel, Text)], diff --git a/src/aact/nodes/tts.py b/src/aact/nodes/tts.py index 048b368..5b66afa 100644 --- a/src/aact/nodes/tts.py +++ b/src/aact/nodes/tts.py @@ -6,14 +6,25 @@ else: from typing_extensions import Self -from typing import Any, AsyncIterator +from typing import TYPE_CHECKING, Any, AsyncIterator from ..messages.base import Message from ..messages.commons import Audio, Text from .base import Node from .registry import NodeFactory -from google.cloud import texttospeech -from google.api_core import exceptions -from google.api_core.client_options import ClientOptions + +if TYPE_CHECKING: + from google.cloud import texttospeech + from google.api_core import exceptions + from google.api_core.client_options import ClientOptions + +try: + from google.cloud import texttospeech + from google.api_core import exceptions + from google.api_core.client_options import ClientOptions + + GOOGLE_CLOUD_TEXTTOSPEECH_AVAILABLE = True +except ImportError: + GOOGLE_CLOUD_TEXTTOSPEECH_AVAILABLE = False @NodeFactory.register("tts") @@ -26,6 +37,11 @@ def __init__( rate: int, redis_url: str, ) -> None: + if not GOOGLE_CLOUD_TEXTTOSPEECH_AVAILABLE: + raise ImportError( + "Google Cloud Text-to-Speech is not available. Please install" + "aact with `pip install aact[google]` to use the TTSNode." + ) super().__init__( input_channel_types=[(input_channel, Text)], output_channel_types=[(output_channel, Audio)], diff --git a/uv.lock b/uv.lock index 6f359c5..c3775a7 100644 --- a/uv.lock +++ b/uv.lock @@ -25,18 +25,13 @@ source = { editable = "." } dependencies = [ { name = "aiofiles" }, { name = "aiostream" }, - { name = "google-cloud-speech" }, - { name = "google-cloud-texttospeech" }, { name = "numpy" }, { name = "pydantic" }, { name = "redis" }, { name = "requests" }, { name = "rq" }, - { name = "toml" }, + { name = "tomlkit", marker = "python_full_version < '3.11'" }, { name = "typer" }, - { name = "types-aiofiles" }, - { name = "types-requests" }, - { name = "types-toml" }, ] [package.optional-dependencies] @@ -44,6 +39,15 @@ audio = [ { name = "pyaudio" }, { name = "types-pyaudio" }, ] +google = [ + { name = "google-cloud-speech" }, + { name = "google-cloud-texttospeech" }, +] +typing = [ + { name = "mypy" }, + { name = "types-aiofiles" }, + { name = "types-requests" }, +] vision = [ { name = "opencv-python" }, ] @@ -57,8 +61,9 @@ dev = [ requires-dist = [ { name = "aiofiles", specifier = ">=24.1.0" }, { name = "aiostream", specifier = ">=0.6.2" }, - { name = "google-cloud-speech", specifier = ">=2.27.0" }, - { name = "google-cloud-texttospeech", specifier = ">=2.17.2" }, + { name = "google-cloud-speech", marker = "extra == 'google'", specifier = ">=2.27.0" }, + { name = "google-cloud-texttospeech", marker = "extra == 'google'", specifier = ">=2.17.2" }, + { name = "mypy", marker = "extra == 'typing'", specifier = ">=0.910" }, { name = "numpy", specifier = ">=2.1.0" }, { name = "opencv-python", marker = "extra == 'vision'" }, { name = "pyaudio", marker = "extra == 'audio'", specifier = ">=0.2.14" }, @@ -66,12 +71,11 @@ requires-dist = [ { name = "redis", specifier = ">=5.0.8" }, { name = "requests", specifier = ">=2.32.3" }, { name = "rq", specifier = ">=1.16.2" }, - { name = "toml", specifier = ">=0.10.2" }, + { name = "tomlkit", marker = "python_full_version < '3.11'", specifier = ">=0.13.0" }, { name = "typer", specifier = ">=0.12.5" }, - { name = "types-aiofiles", specifier = ">=24.1.0.20240626" }, + { name = "types-aiofiles", marker = "extra == 'typing'", specifier = ">=24.1.0.20240626" }, { name = "types-pyaudio", marker = "extra == 'audio'", specifier = ">=0.2.16.20240516" }, - { name = "types-requests", specifier = ">=2.32.0.20240712" }, - { name = "types-toml", specifier = ">=0.10.8.20240310" }, + { name = "types-requests", marker = "extra == 'typing'", specifier = ">=2.32.0.20240712" }, ] [package.metadata.requires-dev] @@ -706,21 +710,21 @@ wheels = [ ] [[package]] -name = "toml" -version = "0.10.2" +name = "tomli" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253 } +sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f", size = 15164 } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588 }, + { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, ] [[package]] -name = "tomli" -version = "2.0.1" +name = "tomlkit" +version = "0.13.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f", size = 15164 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, + { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 }, ] [[package]] @@ -768,15 +772,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/4d/cbed87a6912fbd9259ce23a5d4aa1de9816edf75eec6ed9a757c00906c8e/types_requests-2.32.0.20240712-py3-none-any.whl", hash = "sha256:f754283e152c752e46e70942fa2a146b5bc70393522257bb85bd1ef7e019dcc3", size = 15816 }, ] -[[package]] -name = "types-toml" -version = "0.10.8.20240310" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/47/3e4c75042792bff8e90d7991aa5c51812cc668828cc6cce711e97f63a607/types-toml-0.10.8.20240310.tar.gz", hash = "sha256:3d41501302972436a6b8b239c850b26689657e25281b48ff0ec06345b8830331", size = 4392 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/a2/d32ab58c0b216912638b140ab2170ee4b8644067c293b170e19fba340ccc/types_toml-0.10.8.20240310-py3-none-any.whl", hash = "sha256:627b47775d25fa29977d9c70dc0cbab3f314f32c8d8d0c012f2ef5de7aaec05d", size = 4777 }, -] - [[package]] name = "typing-extensions" version = "4.12.2"