Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: initial commit #1

Merged
merged 10 commits into from
Nov 5, 2023
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