From 4ba108d098132a4663ca659233725ced363fa419 Mon Sep 17 00:00:00 2001 From: Ronnie Villanueva Date: Tue, 31 Oct 2023 21:34:14 +0800 Subject: [PATCH 01/10] chore: remove starter code --- README.md | 2 +- pyproject.toml | 4 ++-- src/mymodule/__init__.py | 2 -- src/myproject/__init__.py | 5 ----- src/pyplayht/__init__.py | 1 + tests/mymodule_tests/__init__.py | 0 tests/mymodule_tests/mymodule_test.py | 13 ------------- tests/myproject_tests/__init__.py | 0 tests/myproject_tests/myproject_test.py | 5 ----- 9 files changed, 4 insertions(+), 28 deletions(-) delete mode 100644 src/mymodule/__init__.py delete mode 100644 src/myproject/__init__.py create mode 100644 src/pyplayht/__init__.py delete mode 100644 tests/mymodule_tests/__init__.py delete mode 100644 tests/mymodule_tests/mymodule_test.py delete mode 100644 tests/myproject_tests/__init__.py delete mode 100644 tests/myproject_tests/myproject_test.py diff --git a/README.md b/README.md index 31132f9..771d90d 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# python-project-template \ No newline at end of file +# pyplayht 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/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/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") From 4aa508fe39ea516a7558c57c3e05955144f0b7c2 Mon Sep 17 00:00:00 2001 From: Ronnie Villanueva Date: Tue, 31 Oct 2023 21:47:50 +0800 Subject: [PATCH 02/10] docs(README.md): add installation and dev instructions --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 771d90d..86501b2 100644 --- a/README.md +++ b/README.md @@ -1 +1,29 @@ # pyplayht + +Python wrapper for PlayHT API +https://docs.play.ht/reference/api-getting-started + +### Installation +```bash +pip install pyplayht +``` + + +### 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/ From 887450a695516c23687e2e8478ad66e82d89899e Mon Sep 17 00:00:00 2001 From: Ronnie Villanueva Date: Tue, 31 Oct 2023 23:19:08 +0800 Subject: [PATCH 03/10] feat: initial commit of classes and types --- requirements.txt | 2 ++ src/pyplayht/classes.py | 67 +++++++++++++++++++++++++++++++++++++++++ src/pyplayht/types.py | 42 ++++++++++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 src/pyplayht/classes.py create mode 100644 src/pyplayht/types.py 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/pyplayht/classes.py b/src/pyplayht/classes.py new file mode 100644 index 0000000..cf34c43 --- /dev/null +++ b/src/pyplayht/classes.py @@ -0,0 +1,67 @@ +import os +from typing import List, Union +from urllib.parse import urlencode, urljoin + +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]: + path = "/api/v1/getVoices" + url = urljoin(self.base_url, path) + response = self.session.get(url) + response.raise_for_status() + 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: + path = "/api/v1/convert" + url = urljoin(self.base_url, path) + content = text if isinstance(text, list) else [text] + payload = { + "content": content, + "voice": voice, + } + response = self.session.post(url, json=payload) + response.raise_for_status() + return response.json() + + def get_coversion_job_status(self, transcription_id: str) -> dict: + path = "/api/v1/articleStatus" + params = {"transcriptionId": transcription_id} + url = urljoin(self.base_url, path) + url = urljoin(url, "?" + urlencode(params)) + response = self.session.get(url) + response.raise_for_status() + return response.json() + + def download_file(self, uri: str): + response = self.session.get(uri) + response.raise_for_status() + return response.content 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) From bf64604c89c3e7e9aa95f8dbddd03c3f55a18b86 Mon Sep 17 00:00:00 2001 From: Ronnie Villanueva Date: Tue, 31 Oct 2023 23:49:19 +0800 Subject: [PATCH 04/10] refactor(classes.py): combined common lines in requests --- src/pyplayht/classes.py | 43 +++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/src/pyplayht/classes.py b/src/pyplayht/classes.py index cf34c43..bdab637 100644 --- a/src/pyplayht/classes.py +++ b/src/pyplayht/classes.py @@ -1,6 +1,6 @@ import os from typing import List, Union -from urllib.parse import urlencode, urljoin +from urllib.parse import urljoin, urlparse import requests @@ -28,9 +28,7 @@ def voices(self) -> List[VoiceType]: def get_voices(self) -> List[VoiceType]: path = "/api/v1/getVoices" - url = urljoin(self.base_url, path) - response = self.session.get(url) - response.raise_for_status() + response = self._request("GET", path) voices = response.json().get("voices") voices = [VoiceType(**voice) for voice in voices] self._voices = voices @@ -42,26 +40,43 @@ def new_conversion_job( voice: str = "en-US-JennyNeural", ) -> dict: path = "/api/v1/convert" - url = urljoin(self.base_url, path) content = text if isinstance(text, list) else [text] payload = { "content": content, "voice": voice, } - response = self.session.post(url, json=payload) - response.raise_for_status() + response = self._request("POST", path, json=payload) return response.json() def get_coversion_job_status(self, transcription_id: str) -> dict: path = "/api/v1/articleStatus" params = {"transcriptionId": transcription_id} - url = urljoin(self.base_url, path) - url = urljoin(url, "?" + urlencode(params)) - response = self.session.get(url) - response.raise_for_status() + response = self._request("GET", path, params=params) return response.json() - def download_file(self, uri: str): - response = self.session.get(uri) - response.raise_for_status() + def download_file(self, uri: str) -> bytes: + response = self._request("GET", uri) 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 From 7275fc8237a32d7447d15d6e5c620bc4c6bd7754 Mon Sep 17 00:00:00 2001 From: Ronnie Villanueva Date: Tue, 31 Oct 2023 23:58:57 +0800 Subject: [PATCH 05/10] docs(classes.py): add docstrings --- src/pyplayht/classes.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/pyplayht/classes.py b/src/pyplayht/classes.py index bdab637..d144095 100644 --- a/src/pyplayht/classes.py +++ b/src/pyplayht/classes.py @@ -27,6 +27,12 @@ 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") @@ -39,6 +45,17 @@ def new_conversion_job( 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 = { @@ -49,12 +66,30 @@ def new_conversion_job( 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 + """ response = self._request("GET", uri) return response.content From 44ef828bc095f5b5ea5504fdc2e331fec67f90ce Mon Sep 17 00:00:00 2001 From: Ronnie Villanueva Date: Wed, 1 Nov 2023 00:26:04 +0800 Subject: [PATCH 06/10] fix(classes.py): strip params from uri before downloading --- src/pyplayht/classes.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/pyplayht/classes.py b/src/pyplayht/classes.py index d144095..1a99058 100644 --- a/src/pyplayht/classes.py +++ b/src/pyplayht/classes.py @@ -1,6 +1,6 @@ import os from typing import List, Union -from urllib.parse import urljoin, urlparse +from urllib.parse import urljoin, urlparse, urlunparse import requests @@ -90,7 +90,19 @@ def download_file(self, uri: str) -> bytes: Returns: bytes: byte data of file """ - response = self._request("GET", uri) + 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( From 92e3317edc9ecd0b39d91e7b0972daa4d22e6788 Mon Sep 17 00:00:00 2001 From: Ronnie Villanueva Date: Wed, 1 Nov 2023 00:26:33 +0800 Subject: [PATCH 07/10] docs(README.md): add sample code and envars needed --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index 86501b2..00665b3 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,39 @@ https://docs.play.ht/reference/api-getting-started 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 From 2131b834ee13455e0dd70aa20386df2fcd15415c Mon Sep 17 00:00:00 2001 From: Ronnie Villanueva Date: Sun, 5 Nov 2023 15:03:35 +0800 Subject: [PATCH 08/10] test(types_test.py): add initial tests --- tests/types_test.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/types_test.py 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 From 513b5f66537423c8b4c27a22d247e83237a0c8f9 Mon Sep 17 00:00:00 2001 From: Ronnie Villanueva Date: Sun, 5 Nov 2023 15:10:34 +0800 Subject: [PATCH 09/10] test(classes_test.py): add initial tests --- tests/classes_test.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/classes_test.py diff --git a/tests/classes_test.py b/tests/classes_test.py new file mode 100644 index 0000000..7683fbb --- /dev/null +++ b/tests/classes_test.py @@ -0,0 +1,14 @@ +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) From 0a1c75a8fe27a17d7db6f64406391371c6a6ad49 Mon Sep 17 00:00:00 2001 From: Ronnie Villanueva Date: Sun, 5 Nov 2023 15:43:46 +0800 Subject: [PATCH 10/10] test(classes_test.py): add tests for _request and download_file --- tests/classes_test.py | 32 ++++++++++++++++++++++++++++++-- tests/conftest.py | 8 ++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/tests/classes_test.py b/tests/classes_test.py index 7683fbb..eb57bf3 100644 --- a/tests/classes_test.py +++ b/tests/classes_test.py @@ -1,8 +1,13 @@ +from http import HTTPStatus +from unittest.mock import Mock, patch + +import pytest +import requests + from pyplayht.classes import Client -def test_client(): - client = 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( @@ -12,3 +17,26 @@ def test_client(): 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()