video-to-text-python 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ .venv
2
+ dist
3
+ .tmp
4
+ build
5
+ *.egg-info
6
+ .pytest_cache
7
+ .ruff_cache
8
+ .mypy_cache
9
+ __pycache__
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: video-to-text-python
3
+ Version: 1.0.0
4
+ Summary: Official Python client for the Video To Text API
5
+ Author: Video To Text
6
+ License: MIT
7
+ Keywords: AI transcription,api,audio to text,audio transcription,speech to text,transcription,video to text,video transcription
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: httpx<1.0.0,>=0.27.0
10
+ Requires-Dist: pydantic<3.0.0,>=2.9.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: mypy<2.0.0,>=1.13.0; extra == 'dev'
13
+ Requires-Dist: pytest<9.0.0,>=8.3.0; extra == 'dev'
14
+ Requires-Dist: ruff<0.10.0,>=0.8.0; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # video-to-text-python
18
+
19
+ Official Python client for the [Video To Text](https://videototext.dev/) API.
20
+
21
+ - API docs: [Video To Text API](https://docs.videototext.dev)
22
+ - Supports Python 3.10+
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ pip install video-to-text-python
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ```python
33
+ import os
34
+ from pathlib import Path
35
+
36
+ from video_to_text import VideoToTextClient
37
+
38
+ client = VideoToTextClient(api_key=os.environ["VTT_API_KEY"])
39
+
40
+ task = client.transcriptions.transcribe_file(
41
+ Path("meeting.mp4"),
42
+ transcription_mode="balanced",
43
+ )
44
+
45
+ print(task.status, task.full_text, task.billed_credits)
46
+ ```
@@ -0,0 +1,30 @@
1
+ # video-to-text-python
2
+
3
+ Official Python client for the [Video To Text](https://videototext.dev/) API.
4
+
5
+ - API docs: [Video To Text API](https://docs.videototext.dev)
6
+ - Supports Python 3.10+
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pip install video-to-text-python
12
+ ```
13
+
14
+ ## Quick Start
15
+
16
+ ```python
17
+ import os
18
+ from pathlib import Path
19
+
20
+ from video_to_text import VideoToTextClient
21
+
22
+ client = VideoToTextClient(api_key=os.environ["VTT_API_KEY"])
23
+
24
+ task = client.transcriptions.transcribe_file(
25
+ Path("meeting.mp4"),
26
+ transcription_mode="balanced",
27
+ )
28
+
29
+ print(task.status, task.full_text, task.billed_credits)
30
+ ```
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "video-to-text-python"
7
+ version = "1.0.0"
8
+ description = "Official Python client for the Video To Text API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Video To Text" }]
13
+ keywords = [
14
+ "video to text",
15
+ "audio to text",
16
+ "AI transcription",
17
+ "video transcription",
18
+ "audio transcription",
19
+ "speech to text",
20
+ "transcription",
21
+ "api"
22
+ ]
23
+ dependencies = [
24
+ "httpx>=0.27.0,<1.0.0",
25
+ "pydantic>=2.9.0,<3.0.0"
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=8.3.0,<9.0.0",
31
+ "ruff>=0.8.0,<0.10.0",
32
+ "mypy>=1.13.0,<2.0.0"
33
+ ]
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/video_to_text"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+
41
+ [tool.ruff]
42
+ line-length = 100
43
+ target-version = "py310"
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "I", "UP", "B"]
47
+
48
+ [tool.mypy]
49
+ python_version = "3.10"
50
+ warn_return_any = true
51
+ warn_unused_configs = true
52
+ ignore_missing_imports = true
53
+ strict_optional = true
@@ -0,0 +1,27 @@
1
+ from video_to_text.async_client import AsyncVideoToTextClient
2
+ from video_to_text.client import VideoToTextClient
3
+ from video_to_text.errors import APIError, AuthError, RateLimitError, UploadError, ValidationError
4
+ from video_to_text.types import (
5
+ TranscriptChunk,
6
+ TranscriptTask,
7
+ TranscriptTaskCreateResponse,
8
+ TranscriptWord,
9
+ UploadCompleteResponse,
10
+ UploadCreateResponse,
11
+ )
12
+
13
+ __all__ = [
14
+ "APIError",
15
+ "AsyncVideoToTextClient",
16
+ "AuthError",
17
+ "RateLimitError",
18
+ "TranscriptChunk",
19
+ "TranscriptTask",
20
+ "TranscriptTaskCreateResponse",
21
+ "TranscriptWord",
22
+ "UploadCompleteResponse",
23
+ "UploadCreateResponse",
24
+ "UploadError",
25
+ "ValidationError",
26
+ "VideoToTextClient",
27
+ ]
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from video_to_text.errors import APIError, UploadError, create_api_error
10
+ from video_to_text.resources.tasks import AsyncTasksResource
11
+ from video_to_text.resources.transcriptions import AsyncTranscriptionsResource
12
+ from video_to_text.resources.uploads import AsyncUploadsResource
13
+
14
+
15
+ class AsyncVideoToTextClient:
16
+ def __init__(
17
+ self,
18
+ api_key: str,
19
+ *,
20
+ base_url: str = "https://api.videototext.dev",
21
+ timeout: float = 60,
22
+ max_retries: int = 2,
23
+ poll_interval: float = 1.5,
24
+ transport: httpx.AsyncBaseTransport | None = None,
25
+ ) -> None:
26
+ if not api_key:
27
+ raise ValueError("api_key is required")
28
+
29
+ self._api_key = api_key
30
+ self._base_url = base_url.rstrip("/")
31
+ self._timeout = timeout
32
+ self._max_retries = max_retries
33
+ self._poll_interval = poll_interval
34
+ self._client = httpx.AsyncClient(
35
+ base_url=self._base_url,
36
+ timeout=self._timeout,
37
+ transport=transport,
38
+ )
39
+
40
+ self.uploads = AsyncUploadsResource(self._request, self._put_upload_url)
41
+ self.tasks = AsyncTasksResource(self._request, self._poll_interval)
42
+ self.transcriptions = AsyncTranscriptionsResource(self.uploads, self.tasks)
43
+
44
+ async def aclose(self) -> None:
45
+ await self._client.aclose()
46
+
47
+ async def __aenter__(self) -> AsyncVideoToTextClient:
48
+ return self
49
+
50
+ async def __aexit__(self, *_: object) -> None:
51
+ await self.aclose()
52
+
53
+ async def _request(
54
+ self,
55
+ method: str,
56
+ path: str,
57
+ *,
58
+ json: dict[str, Any] | None = None,
59
+ headers: dict[str, str] | None = None,
60
+ idempotency_key: str | None = None,
61
+ skip_retry: bool = False,
62
+ unwrap_data: bool = True,
63
+ ) -> Any:
64
+ attempt = 0
65
+
66
+ while True:
67
+ try:
68
+ request_headers = {
69
+ "Authorization": f"Bearer {self._api_key}",
70
+ "Accept": "application/json",
71
+ **(headers or {}),
72
+ }
73
+ if idempotency_key:
74
+ request_headers["Idempotency-Key"] = idempotency_key
75
+
76
+ response = await self._client.request(
77
+ method,
78
+ path,
79
+ json=json,
80
+ headers=request_headers,
81
+ )
82
+ payload = self._parse_json_response(response)
83
+
84
+ if response.status_code >= 400:
85
+ raise create_api_error(response.status_code, payload)
86
+
87
+ if not isinstance(payload, dict) or "data" not in payload or "meta" not in payload:
88
+ raise create_api_error(response.status_code, payload)
89
+
90
+ if not unwrap_data:
91
+ return payload
92
+
93
+ return payload["data"]
94
+ except APIError as error:
95
+ if skip_retry:
96
+ raise
97
+
98
+ if not self._should_retry(error.status) or attempt >= self._max_retries:
99
+ raise
100
+
101
+ await self._sleep_with_backoff(attempt)
102
+ attempt += 1
103
+ except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout):
104
+ if skip_retry or attempt >= self._max_retries:
105
+ raise
106
+ await self._sleep_with_backoff(attempt)
107
+ attempt += 1
108
+
109
+ async def _put_upload_url(self, upload_url: str, content: bytes, mimetype: str) -> None:
110
+ response = await self._client.request(
111
+ "PUT",
112
+ upload_url,
113
+ content=content,
114
+ headers={"Content-Type": mimetype},
115
+ )
116
+ if response.status_code >= 400:
117
+ raise UploadError(response.status_code, response.text)
118
+
119
+ @staticmethod
120
+ def _parse_json_response(response: httpx.Response) -> Any:
121
+ if not response.content:
122
+ return {}
123
+ try:
124
+ return response.json()
125
+ except ValueError:
126
+ return {}
127
+
128
+ @staticmethod
129
+ def _should_retry(status: int) -> bool:
130
+ return status == 429 or status >= 500
131
+
132
+ @staticmethod
133
+ async def _sleep_with_backoff(attempt: int) -> None:
134
+ base = min(3.0, 0.3 * (2**attempt))
135
+ jitter = random.random() * 0.1
136
+ await asyncio.sleep(base + jitter)
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ import time
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from video_to_text.errors import APIError, UploadError, create_api_error
10
+ from video_to_text.resources.tasks import TasksResource
11
+ from video_to_text.resources.transcriptions import TranscriptionsResource
12
+ from video_to_text.resources.uploads import UploadsResource
13
+
14
+
15
+ class VideoToTextClient:
16
+ def __init__(
17
+ self,
18
+ api_key: str,
19
+ *,
20
+ base_url: str = "https://api.videototext.dev",
21
+ timeout: float = 60,
22
+ max_retries: int = 2,
23
+ poll_interval: float = 1.5,
24
+ transport: httpx.BaseTransport | None = None,
25
+ ) -> None:
26
+ if not api_key:
27
+ raise ValueError("api_key is required")
28
+
29
+ self._api_key = api_key
30
+ self._base_url = base_url.rstrip("/")
31
+ self._timeout = timeout
32
+ self._max_retries = max_retries
33
+ self._poll_interval = poll_interval
34
+ self._client = httpx.Client(
35
+ base_url=self._base_url,
36
+ timeout=self._timeout,
37
+ transport=transport,
38
+ )
39
+
40
+ self.uploads = UploadsResource(self._request, self._put_upload_url)
41
+ self.tasks = TasksResource(self._request, self._poll_interval)
42
+ self.transcriptions = TranscriptionsResource(self.uploads, self.tasks)
43
+
44
+ def close(self) -> None:
45
+ self._client.close()
46
+
47
+ def __enter__(self) -> VideoToTextClient:
48
+ return self
49
+
50
+ def __exit__(self, *_: object) -> None:
51
+ self.close()
52
+
53
+ def _request(
54
+ self,
55
+ method: str,
56
+ path: str,
57
+ *,
58
+ json: dict[str, Any] | None = None,
59
+ headers: dict[str, str] | None = None,
60
+ idempotency_key: str | None = None,
61
+ skip_retry: bool = False,
62
+ unwrap_data: bool = True,
63
+ ) -> Any:
64
+ attempt = 0
65
+
66
+ while True:
67
+ try:
68
+ request_headers = {
69
+ "Authorization": f"Bearer {self._api_key}",
70
+ "Accept": "application/json",
71
+ **(headers or {}),
72
+ }
73
+ if idempotency_key:
74
+ request_headers["Idempotency-Key"] = idempotency_key
75
+
76
+ response = self._client.request(
77
+ method,
78
+ path,
79
+ json=json,
80
+ headers=request_headers,
81
+ )
82
+ payload = self._parse_json_response(response)
83
+
84
+ if response.status_code >= 400:
85
+ raise create_api_error(response.status_code, payload)
86
+
87
+ if not isinstance(payload, dict) or "data" not in payload or "meta" not in payload:
88
+ raise create_api_error(response.status_code, payload)
89
+
90
+ if not unwrap_data:
91
+ return payload
92
+
93
+ return payload["data"]
94
+ except APIError as error:
95
+ if skip_retry:
96
+ raise
97
+
98
+ if not self._should_retry(error.status) or attempt >= self._max_retries:
99
+ raise
100
+
101
+ self._sleep_with_backoff(attempt)
102
+ attempt += 1
103
+ except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout):
104
+ if skip_retry or attempt >= self._max_retries:
105
+ raise
106
+ self._sleep_with_backoff(attempt)
107
+ attempt += 1
108
+
109
+ def _put_upload_url(self, upload_url: str, content: bytes, mimetype: str) -> None:
110
+ response = self._client.request(
111
+ "PUT",
112
+ upload_url,
113
+ content=content,
114
+ headers={"Content-Type": mimetype},
115
+ )
116
+ if response.status_code >= 400:
117
+ raise UploadError(response.status_code, response.text)
118
+
119
+ @staticmethod
120
+ def _parse_json_response(response: httpx.Response) -> Any:
121
+ if not response.content:
122
+ return {}
123
+ try:
124
+ return response.json()
125
+ except ValueError:
126
+ return {}
127
+
128
+ @staticmethod
129
+ def _should_retry(status: int) -> bool:
130
+ return status == 429 or status >= 500
131
+
132
+ @staticmethod
133
+ def _sleep_with_backoff(attempt: int) -> None:
134
+ # Use exponential backoff with light jitter so clients do not retry in lockstep.
135
+ base = min(3.0, 0.3 * (2**attempt))
136
+ jitter = random.random() * 0.1
137
+ time.sleep(base + jitter)
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class Problem:
9
+ type: str
10
+ title: str
11
+ status: int
12
+ detail: str
13
+ error_code: str | None = None
14
+
15
+
16
+ class APIError(Exception):
17
+ def __init__(self, problem: Problem):
18
+ super().__init__(problem.detail)
19
+ self.problem = problem
20
+ self.status = problem.status
21
+ self.type = problem.type
22
+ self.error_code = problem.error_code
23
+
24
+
25
+ class AuthError(APIError):
26
+ pass
27
+
28
+
29
+ class ValidationError(APIError):
30
+ pass
31
+
32
+
33
+ class RateLimitError(APIError):
34
+ pass
35
+
36
+
37
+ class UploadError(Exception):
38
+ def __init__(self, status: int, detail: str):
39
+ super().__init__(detail or f"Upload failed with status {status}")
40
+ self.status = status
41
+ self.detail = detail
42
+
43
+
44
+ def parse_problem(status: int, payload: Any) -> Problem:
45
+ if isinstance(payload, dict):
46
+ maybe_type = payload.get("type")
47
+ maybe_title = payload.get("title")
48
+ if isinstance(maybe_type, str) and isinstance(maybe_title, str):
49
+ return Problem(
50
+ type=maybe_type,
51
+ title=maybe_title,
52
+ status=int(payload.get("status") or status),
53
+ detail=str(payload.get("detail") or maybe_title),
54
+ error_code=payload.get("errorCode"),
55
+ )
56
+
57
+ return Problem(
58
+ type="INTERNAL_ERROR",
59
+ title="Internal server error",
60
+ status=status,
61
+ detail="Unexpected API response",
62
+ error_code="SDK_UNEXPECTED_RESPONSE",
63
+ )
64
+
65
+
66
+ def create_api_error(status: int, payload: Any) -> APIError:
67
+ problem = parse_problem(status, payload)
68
+
69
+ if status in (401, 403):
70
+ return AuthError(problem)
71
+ if status in (400, 422):
72
+ return ValidationError(problem)
73
+ if status == 429:
74
+ return RateLimitError(problem)
75
+
76
+ return APIError(problem)
@@ -0,0 +1,15 @@
1
+ from video_to_text.resources.tasks import AsyncTasksResource, TasksResource
2
+ from video_to_text.resources.transcriptions import (
3
+ AsyncTranscriptionsResource,
4
+ TranscriptionsResource,
5
+ )
6
+ from video_to_text.resources.uploads import AsyncUploadsResource, UploadsResource
7
+
8
+ __all__ = [
9
+ "AsyncTasksResource",
10
+ "AsyncTranscriptionsResource",
11
+ "AsyncUploadsResource",
12
+ "TasksResource",
13
+ "TranscriptionsResource",
14
+ "UploadsResource",
15
+ ]
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from video_to_text.errors import APIError, Problem
9
+ from video_to_text.types import TranscriptTask, TranscriptTaskCreateResponse
10
+
11
+ TERMINAL_STATUSES = {"SUCCEEDED", "FAILED", "CANCELED"}
12
+
13
+
14
+ class TasksResource:
15
+ def __init__(self, request: Callable[..., Any], poll_interval: float) -> None:
16
+ self._request = request
17
+ self._poll_interval = poll_interval
18
+
19
+ def create(
20
+ self,
21
+ payload: dict[str, Any],
22
+ *,
23
+ idempotency_key: str | None = None,
24
+ ) -> TranscriptTaskCreateResponse:
25
+ key = idempotency_key.strip() if idempotency_key else None
26
+ data = self._request(
27
+ "POST",
28
+ "/v1/tasks",
29
+ json=payload,
30
+ idempotency_key=key,
31
+ skip_retry=key is None,
32
+ )
33
+ return TranscriptTaskCreateResponse.model_validate(data["task"])
34
+
35
+ def get(self, transcript_id: str) -> TranscriptTask:
36
+ data = self._request("GET", f"/v1/tasks/{transcript_id}")
37
+ return TranscriptTask.model_validate(data["task"])
38
+
39
+ def wait(
40
+ self,
41
+ transcript_id: str,
42
+ *,
43
+ timeout: float = 600,
44
+ poll_interval: float | None = None,
45
+ ) -> TranscriptTask:
46
+ interval = poll_interval if poll_interval is not None else self._poll_interval
47
+ started = time.time()
48
+
49
+ # Polling only observes server task state; failed terminal states become SDK errors.
50
+ while True:
51
+ if time.time() - started > timeout:
52
+ raise TimeoutError(f"wait timeout after {timeout} seconds")
53
+
54
+ task = self.get(transcript_id)
55
+ if task.status in TERMINAL_STATUSES:
56
+ if task.status in {"FAILED", "CANCELED"}:
57
+ raise APIError(
58
+ Problem(
59
+ type="TASK_TERMINAL_FAILURE",
60
+ title="Task failed",
61
+ status=422,
62
+ detail=task.show_error or f"Task ended with status {task.status}",
63
+ error_code="TASK_TERMINAL_FAILURE",
64
+ )
65
+ )
66
+ return task
67
+
68
+ time.sleep(interval)
69
+
70
+
71
+ class AsyncTasksResource:
72
+ def __init__(self, request: Callable[..., Any], poll_interval: float) -> None:
73
+ self._request = request
74
+ self._poll_interval = poll_interval
75
+
76
+ async def create(
77
+ self,
78
+ payload: dict[str, Any],
79
+ *,
80
+ idempotency_key: str | None = None,
81
+ ) -> TranscriptTaskCreateResponse:
82
+ key = idempotency_key.strip() if idempotency_key else None
83
+ data = await self._request(
84
+ "POST",
85
+ "/v1/tasks",
86
+ json=payload,
87
+ idempotency_key=key,
88
+ skip_retry=key is None,
89
+ )
90
+ return TranscriptTaskCreateResponse.model_validate(data["task"])
91
+
92
+ async def get(self, transcript_id: str) -> TranscriptTask:
93
+ data = await self._request("GET", f"/v1/tasks/{transcript_id}")
94
+ return TranscriptTask.model_validate(data["task"])
95
+
96
+ async def wait(
97
+ self,
98
+ transcript_id: str,
99
+ *,
100
+ timeout: float = 600,
101
+ poll_interval: float | None = None,
102
+ ) -> TranscriptTask:
103
+ interval = poll_interval if poll_interval is not None else self._poll_interval
104
+ loop = asyncio.get_running_loop()
105
+ started = loop.time()
106
+
107
+ while True:
108
+ if loop.time() - started > timeout:
109
+ raise TimeoutError(f"wait timeout after {timeout} seconds")
110
+
111
+ task = await self.get(transcript_id)
112
+ if task.status in TERMINAL_STATUSES:
113
+ if task.status in {"FAILED", "CANCELED"}:
114
+ raise APIError(
115
+ Problem(
116
+ type="TASK_TERMINAL_FAILURE",
117
+ title="Task failed",
118
+ status=422,
119
+ detail=task.show_error or f"Task ended with status {task.status}",
120
+ error_code="TASK_TERMINAL_FAILURE",
121
+ )
122
+ )
123
+ return task
124
+
125
+ await asyncio.sleep(interval)