diff --git a/README.md b/README.md index 31132f9..00665b3 100644 --- a/README.md +++ b/README.md @@ -1 +1,62 @@ -# python-project-template \ No newline at end of file +# pyplayht + +Python wrapper for PlayHT API +https://docs.play.ht/reference/api-getting-started + +### Installation +```bash +pip install pyplayht +``` + +### Environmental Variables +Get your keys from https://play.ht/app/api-access +| Name | Value | +| --- | --- | +| `PLAY_HT_USER_ID` | account user id | +| `PLAY_HT_API_KEY` | account secret key | + +### Sample Code +```python +from pathlib import Path + +from pyplayht.classes import Client + +# create new client +client = Client() + +# create new conversion job +job = client.new_conversion_job( + text="Hello, World!", + voice="en-US-JennyNeural", +) + +# check job status +job = client.get_coversion_job_status(job.get("transcriptionId")) + +# download audio from job +data = client.download_file(job.get('audioUrl')) + +# do something with audio bytes +path = Path("demo.mp3") +path.write_bytes(data) +``` + + +### Developer Instructions +Run the dev setup scripts inside `scripts` directory +```bash +├── scripts +│ ├── setup-dev.bat # windows +│ └── setup-dev.sh # linux +``` + +Install the `pyplayht` package as editable +https://setuptools.pypa.io/en/latest/userguide/development_mode.html +```bash +pip install -e . +``` + +When making a commit, use the command `cz commit` or `cz c` + +You may also use the regular `git commit` command but make sure to follow the `Conventional Commits` specification +https://www.conventionalcommits.org/en/v1.0.0/ diff --git a/pyproject.toml b/pyproject.toml index 21103cc..af441d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "myproject" +name = "pyplayht" version = "0.0.0" [tool.pytest.ini_options] @@ -14,5 +14,5 @@ version_scheme = "pep440" version_provider = "pep621" major_version_zero = true version_files = [ - "src/myproject/__init__.py:__version__", + "src/pyplayht/__init__.py:__version__", ] diff --git a/requirements.txt b/requirements.txt index e69de29..62bd824 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,2 @@ +python-dotenv>=0.20.0 +requests>=2.28.2 diff --git a/src/mymodule/__init__.py b/src/mymodule/__init__.py deleted file mode 100644 index 3a18d03..0000000 --- a/src/mymodule/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -def myfunction(*args) -> int: - return sum(args) diff --git a/src/myproject/__init__.py b/src/myproject/__init__.py deleted file mode 100644 index b681205..0000000 --- a/src/myproject/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -__version__ = "0.0.0" - - -def info() -> str: - return f"Using myproject v{__version__}" diff --git a/src/pyplayht/__init__.py b/src/pyplayht/__init__.py new file mode 100644 index 0000000..6c8e6b9 --- /dev/null +++ b/src/pyplayht/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.0" diff --git a/src/pyplayht/classes.py b/src/pyplayht/classes.py new file mode 100644 index 0000000..1a99058 --- /dev/null +++ b/src/pyplayht/classes.py @@ -0,0 +1,129 @@ +import os +from typing import List, Union +from urllib.parse import urljoin, urlparse, urlunparse + +import requests + +from pyplayht.types import VoiceType + + +class Client: + base_url: str = "https://api.play.ht/" + + def __init__(self) -> None: + self.session = requests.Session() + # Set default headers for the session + headers = { + "AUTHORIZATION": os.getenv("PLAY_HT_API_KEY"), + "X-USER-ID": os.getenv("PLAY_HT_USER_ID"), + "accept": "application/json", + "content-type": "application/json", + } + self.session.headers.update(headers) + self._voices = [] + + @property + def voices(self) -> List[VoiceType]: + return self._voices if self._voices else self.get_voices() + + def get_voices(self) -> List[VoiceType]: + """ + Get list of available voices from server + + Returns: + List[VoiceType]: list of available voice types + """ + path = "/api/v1/getVoices" + response = self._request("GET", path) + voices = response.json().get("voices") + voices = [VoiceType(**voice) for voice in voices] + self._voices = voices + return voices + + def new_conversion_job( + self, + text: Union[str, List[str]], + voice: str = "en-US-JennyNeural", + ) -> dict: + """ + Create new transcription job + + Args: + text (Union[str, List[str]]): text to transcribe + voice (str, optional): voice model to use. + Defaults to "en-US-JennyNeural". + + Returns: + dict: new transcription job details + """ + path = "/api/v1/convert" + content = text if isinstance(text, list) else [text] + payload = { + "content": content, + "voice": voice, + } + response = self._request("POST", path, json=payload) + return response.json() + + def get_coversion_job_status(self, transcription_id: str) -> dict: + """ + Check status of job specified by transcription_id + + Args: + transcription_id (str): job to check + + Returns: + dict: status of specified job + """ + path = "/api/v1/articleStatus" + params = {"transcriptionId": transcription_id} + response = self._request("GET", path, params=params) + return response.json() + + def download_file(self, uri: str) -> bytes: + """ + Download bytes of given file + + Args: + uri (str): location of file + + Returns: + bytes: byte data of file + """ + parsed_url = urlparse(uri) + # Create a new URL without the query string + new_url = urlunparse( + ( + parsed_url.scheme, + parsed_url.netloc, + parsed_url.path, + "", + "", + "", + ), + ) + response = self._request("GET", new_url) + return response.content + + def _request( + self, + method: str, + path: str, + params: dict = None, + json: dict = None, + stream: bool = False, + timeout: int = 30, + ) -> requests.Response: + url = urljoin(self.base_url, path) + if urlparse(path).scheme: + url = path + response = self.session.request( + method=method, + url=url, + params=params, + json=json, + stream=stream, + timeout=timeout, + ) + response.raise_for_status() + return response diff --git a/src/pyplayht/types.py b/src/pyplayht/types.py new file mode 100644 index 0000000..8dc00de --- /dev/null +++ b/src/pyplayht/types.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass, field +from typing import List + + +@dataclass(frozen=True) +class OutputFormat: + MP3 = "mp3" + WAV = "wav" + OGG = "ogg" + FLAC = "flac" + MULAW = "mulaw" + + +@dataclass(frozen=True) +class OutputQuality: + DRAFT = "draft" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + PREMIUM = "premium" + + +@dataclass(frozen=True) +class GenerateStatus: + GENERATING = "generating" + COMPLETED = "completed" + ERROR = "error" + + +@dataclass +class VoiceType: + value: str + name: str + language: str + voiceType: str + languageCode: str + gender: str + service: str + sample: str + isKid: bool = False + isNew: bool = False + styles: List[str] = field(default_factory=list) diff --git a/tests/classes_test.py b/tests/classes_test.py new file mode 100644 index 0000000..eb57bf3 --- /dev/null +++ b/tests/classes_test.py @@ -0,0 +1,42 @@ +from http import HTTPStatus +from unittest.mock import Mock, patch + +import pytest +import requests + +from pyplayht.classes import Client + + +def test_client(client: Client): + # check for available methods + assert hasattr(client, "get_voices") and callable(client.get_voices) + assert hasattr(client, "new_conversion_job") and callable( + client.new_conversion_job, + ) + assert hasattr(client, "get_coversion_job_status") and callable( + client.get_coversion_job_status, + ) + assert hasattr(client, "download_file") and callable(client.download_file) + + +@pytest.mark.parametrize( + "method", + [ + "GET", + "POST", + ], +) +def test_request(method: str, client: Client): + response = client._request( + method=method, path=f"https://postman-echo.com/{method.lower()}" + ) + assert isinstance(response, requests.Response) + assert response.status_code == HTTPStatus.OK + + +def test_download_file(client: Client): + mock_request = Mock() + mock_request.content = bytes() + with patch("pyplayht.classes.Client._request", return_value=mock_request): + response = client.download_file("http://127.0.0.1/test_path") + assert isinstance(response, bytes) diff --git a/tests/conftest.py b/tests/conftest.py index e69de29..ed6bd81 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from pyplayht.classes import Client + + +@pytest.fixture +def client() -> Client: + return Client() diff --git a/tests/mymodule_tests/__init__.py b/tests/mymodule_tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/mymodule_tests/mymodule_test.py b/tests/mymodule_tests/mymodule_test.py deleted file mode 100644 index bc9c35b..0000000 --- a/tests/mymodule_tests/mymodule_test.py +++ /dev/null @@ -1,13 +0,0 @@ -import pytest - -from mymodule import myfunction - - -@pytest.mark.parametrize( - "test_input,expected", - [ - ([1, 2, 3, 4, 5], 15), - ], -) -def test_myfunction(test_input, expected): - assert myfunction(*test_input, expected) diff --git a/tests/myproject_tests/__init__.py b/tests/myproject_tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/myproject_tests/myproject_test.py b/tests/myproject_tests/myproject_test.py deleted file mode 100644 index f04a7b6..0000000 --- a/tests/myproject_tests/myproject_test.py +++ /dev/null @@ -1,5 +0,0 @@ -from myproject import info - - -def test_myfunction(): - assert info().startswith("Using myproject") diff --git a/tests/types_test.py b/tests/types_test.py new file mode 100644 index 0000000..cbded7b --- /dev/null +++ b/tests/types_test.py @@ -0,0 +1,21 @@ +import pytest + +from pyplayht.types import GenerateStatus, OutputFormat, OutputQuality + + +@pytest.mark.parametrize( + "type_class, fields", + [ + (OutputFormat, {"FLAC", "MP3", "MULAW", "OGG", "WAV"}), + (GenerateStatus, {"GENERATING", "COMPLETED", "ERROR"}), + (OutputQuality, {"DRAFT", "LOW", "MEDIUM", "HIGH", "PREMIUM"}), + ], +) +def test_static_types(type_class, fields): + test_fields = set() + for attr in dir(type_class): + test_condition_1 = not callable(getattr(type_class, attr)) + test_condition_2 = not attr.startswith("__") + if test_condition_1 and test_condition_2: + test_fields.add(attr) + assert test_fields == fields