Skip to content

Commit

Permalink
feat: initial commit (#1)
Browse files Browse the repository at this point in the history
* chore: remove starter code

* docs(README.md): add installation and dev instructions

* feat: initial commit of classes and types

* refactor(classes.py): combined common lines in requests

* docs(classes.py): add docstrings

* fix(classes.py): strip params from uri before downloading

* docs(README.md): add sample code and envars needed

* test(types_test.py): add initial tests

* test(classes_test.py): add initial tests

* test(classes_test.py): add tests for _request and download_file
  • Loading branch information
dobizz committed Nov 5, 2023
1 parent f5cfad8 commit d98e8e0
Show file tree
Hide file tree
Showing 15 changed files with 309 additions and 28 deletions.
63 changes: 62 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,62 @@
# python-project-template
# 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/
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[project]
name = "myproject"
name = "pyplayht"
version = "0.0.0"

[tool.pytest.ini_options]
Expand All @@ -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__",
]
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
python-dotenv>=0.20.0
requests>=2.28.2
2 changes: 0 additions & 2 deletions src/mymodule/__init__.py

This file was deleted.

5 changes: 0 additions & 5 deletions src/myproject/__init__.py

This file was deleted.

1 change: 1 addition & 0 deletions src/pyplayht/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__ = "0.0.0"
129 changes: 129 additions & 0 deletions src/pyplayht/classes.py
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions src/pyplayht/types.py
Original file line number Diff line number Diff line change
@@ -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)
42 changes: 42 additions & 0 deletions tests/classes_test.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import pytest

from pyplayht.classes import Client


@pytest.fixture
def client() -> Client:
return Client()
Empty file removed tests/mymodule_tests/__init__.py
Empty file.
13 changes: 0 additions & 13 deletions tests/mymodule_tests/mymodule_test.py

This file was deleted.

Empty file removed tests/myproject_tests/__init__.py
Empty file.
5 changes: 0 additions & 5 deletions tests/myproject_tests/myproject_test.py

This file was deleted.

21 changes: 21 additions & 0 deletions tests/types_test.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit d98e8e0

Please sign in to comment.