runapi-happyhorse 0.1.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,29 @@
1
+ # Build artifacts
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ *.egg
6
+
7
+ # Bytecode
8
+ __pycache__/
9
+ *.py[cod]
10
+
11
+ # Virtual environments
12
+ .venv/
13
+ venv/
14
+
15
+ # uv
16
+ uv.lock
17
+
18
+ # Test / type caches
19
+ .pytest_cache/
20
+ .mypy_cache/
21
+ .ruff_cache/
22
+ .coverage
23
+ htmlcov/
24
+
25
+ # IDE / OS
26
+ .idea/
27
+ .vscode/
28
+ *.swp
29
+ .DS_Store
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: runapi-happyhorse
3
+ Version: 0.1.0
4
+ Summary: HappyHorse text-to-video, image-to-video, and edit-video client for RunAPI
5
+ Project-URL: Homepage, https://runapi.ai/models/happyhorse
6
+ Project-URL: Documentation, https://runapi.ai/docs#sdk-happyhorse
7
+ Author-email: RunAPI <contact@runapi.ai>
8
+ License-Expression: Apache-2.0
9
+ Keywords: ai,edit-video,happyhorse,image-to-video,runapi,sdk,text-to-video
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: runapi-core
12
+ Description-Content-Type: text/markdown
13
+
14
+ # HappyHorse Python SDK for RunAPI
15
+
16
+ The HappyHorse Python SDK is the language-specific package for HappyHorse on
17
+ RunAPI. Use it for text-to-video, image-to-video, and edit-video flows when your
18
+ application needs JSON request bodies, task status lookup, and consistent RunAPI
19
+ errors in Python.
20
+
21
+ For model details, use https://runapi.ai/models/happyhorse; for API reference,
22
+ use https://runapi.ai/docs#happyhorse; for SDK docs, use
23
+ https://runapi.ai/docs#sdk-happyhorse.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install runapi-happyhorse
29
+ ```
30
+
31
+ ## Quick start
32
+
33
+ ```python
34
+ from runapi.happyhorse import HappyHorseClient
35
+
36
+ client = HappyHorseClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
37
+
38
+ task = client.text_to_video.create(
39
+ model="happyhorse-text-to-video",
40
+ prompt="A horse galloping along a beach at sunset, cinematic",
41
+ aspect_ratio="16:9",
42
+ output_resolution="1080p",
43
+ )
44
+ status = client.text_to_video.get(task.id)
45
+
46
+ clip = client.image_to_video.create(
47
+ model="happyhorse-image-to-video",
48
+ first_frame_image_url="https://example.com/first-frame.jpg",
49
+ )
50
+ ```
51
+
52
+ Use `create` to submit a task and return quickly, `get` to fetch the latest task
53
+ state, and `run` to create and poll until completion:
54
+
55
+ ```python
56
+ result = client.text_to_video.run(
57
+ model="happyhorse-text-to-video",
58
+ prompt="A serene mountain river at dawn",
59
+ )
60
+ print(result.videos[0].url)
61
+ ```
62
+
63
+ In web request handlers, prefer `create` plus webhook or later `get` polling so a
64
+ worker is not held open.
65
+
66
+ RunAPI-generated file URLs are temporary. Download and store generated videos in
67
+ your own durable storage within 7 days; do not treat returned URLs as long-term
68
+ assets.
69
+
70
+ ## Language notes
71
+
72
+ Pass parameters as keyword arguments and catch the `runapi.happyhorse` error
73
+ classes when building video jobs or scripts. The available resources are
74
+ `text_to_video`, `image_to_video`, and `edit_video`. Keep `RUNAPI_API_KEY` in the
75
+ environment or your secret manager; never commit API keys or callback secrets.
76
+
77
+ ## Links
78
+
79
+ - Model page: https://runapi.ai/models/happyhorse
80
+ - SDK docs: https://runapi.ai/docs#sdk-happyhorse
81
+ - Product docs: https://runapi.ai/docs#happyhorse
82
+ - Pricing and rate limits: https://runapi.ai/models/happyhorse
83
+ - Full catalog: https://runapi.ai/models
84
+
85
+ ## License
86
+
87
+ Licensed under the Apache License, Version 2.0.
@@ -0,0 +1,74 @@
1
+ # HappyHorse Python SDK for RunAPI
2
+
3
+ The HappyHorse Python SDK is the language-specific package for HappyHorse on
4
+ RunAPI. Use it for text-to-video, image-to-video, and edit-video flows when your
5
+ application needs JSON request bodies, task status lookup, and consistent RunAPI
6
+ errors in Python.
7
+
8
+ For model details, use https://runapi.ai/models/happyhorse; for API reference,
9
+ use https://runapi.ai/docs#happyhorse; for SDK docs, use
10
+ https://runapi.ai/docs#sdk-happyhorse.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install runapi-happyhorse
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ ```python
21
+ from runapi.happyhorse import HappyHorseClient
22
+
23
+ client = HappyHorseClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
24
+
25
+ task = client.text_to_video.create(
26
+ model="happyhorse-text-to-video",
27
+ prompt="A horse galloping along a beach at sunset, cinematic",
28
+ aspect_ratio="16:9",
29
+ output_resolution="1080p",
30
+ )
31
+ status = client.text_to_video.get(task.id)
32
+
33
+ clip = client.image_to_video.create(
34
+ model="happyhorse-image-to-video",
35
+ first_frame_image_url="https://example.com/first-frame.jpg",
36
+ )
37
+ ```
38
+
39
+ Use `create` to submit a task and return quickly, `get` to fetch the latest task
40
+ state, and `run` to create and poll until completion:
41
+
42
+ ```python
43
+ result = client.text_to_video.run(
44
+ model="happyhorse-text-to-video",
45
+ prompt="A serene mountain river at dawn",
46
+ )
47
+ print(result.videos[0].url)
48
+ ```
49
+
50
+ In web request handlers, prefer `create` plus webhook or later `get` polling so a
51
+ worker is not held open.
52
+
53
+ RunAPI-generated file URLs are temporary. Download and store generated videos in
54
+ your own durable storage within 7 days; do not treat returned URLs as long-term
55
+ assets.
56
+
57
+ ## Language notes
58
+
59
+ Pass parameters as keyword arguments and catch the `runapi.happyhorse` error
60
+ classes when building video jobs or scripts. The available resources are
61
+ `text_to_video`, `image_to_video`, and `edit_video`. Keep `RUNAPI_API_KEY` in the
62
+ environment or your secret manager; never commit API keys or callback secrets.
63
+
64
+ ## Links
65
+
66
+ - Model page: https://runapi.ai/models/happyhorse
67
+ - SDK docs: https://runapi.ai/docs#sdk-happyhorse
68
+ - Product docs: https://runapi.ai/docs#happyhorse
69
+ - Pricing and rate limits: https://runapi.ai/models/happyhorse
70
+ - Full catalog: https://runapi.ai/models
71
+
72
+ ## License
73
+
74
+ Licensed under the Apache License, Version 2.0.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runapi-happyhorse"
7
+ version = "0.1.0"
8
+ description = "HappyHorse text-to-video, image-to-video, and edit-video client for RunAPI"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "RunAPI", email = "contact@runapi.ai" }]
13
+ keywords = ["runapi", "happyhorse", "text-to-video", "image-to-video", "edit-video", "ai", "sdk"]
14
+ dependencies = ["runapi-core"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://runapi.ai/models/happyhorse"
18
+ Documentation = "https://runapi.ai/docs#sdk-happyhorse"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/runapi"]
22
+
23
+ [tool.uv]
24
+ package = true
25
+
26
+ [tool.uv.sources]
27
+ runapi-core = { path = "../runapi-core", editable = true }
28
+
29
+ [dependency-groups]
30
+ dev = ["pytest>=8"]
@@ -0,0 +1,24 @@
1
+ """HappyHorse client for RunAPI."""
2
+
3
+ from runapi.core import (
4
+ AuthenticationError,
5
+ InsufficientCreditsError,
6
+ NotFoundError,
7
+ RateLimitError,
8
+ TaskFailedError,
9
+ TaskTimeoutError,
10
+ ValidationError,
11
+ )
12
+
13
+ from .client import HappyHorseClient
14
+
15
+ __all__ = [
16
+ "HappyHorseClient",
17
+ "AuthenticationError",
18
+ "RateLimitError",
19
+ "InsufficientCreditsError",
20
+ "NotFoundError",
21
+ "ValidationError",
22
+ "TaskFailedError",
23
+ "TaskTimeoutError",
24
+ ]
@@ -0,0 +1,31 @@
1
+ """HappyHorse client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ from runapi.core import ClientOptions, HttpClient, resolve_api_key
8
+
9
+ from .resources.edit_video import EditVideo
10
+ from .resources.image_to_video import ImageToVideo
11
+ from .resources.text_to_video import TextToVideo
12
+
13
+
14
+ class HappyHorseClient:
15
+ """HappyHorse text-to-video, image-to-video, and edit-video client.
16
+
17
+ Example::
18
+
19
+ client = HappyHorseClient(api_key="sk-...")
20
+ result = client.text_to_video.run(
21
+ model="happyhorse-text-to-video", prompt="A horse galloping on a beach"
22
+ )
23
+ """
24
+
25
+ def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
26
+ resolved_api_key = resolve_api_key(api_key)
27
+ client_options = ClientOptions(api_key=resolved_api_key, **options)
28
+ http = client_options.http_client or HttpClient(client_options)
29
+ self.text_to_video = TextToVideo(http)
30
+ self.image_to_video = ImageToVideo(http)
31
+ self.edit_video = EditVideo(http)
File without changes
@@ -0,0 +1,5 @@
1
+ from .edit_video import EditVideo
2
+ from .image_to_video import ImageToVideo
3
+ from .text_to_video import TextToVideo
4
+
5
+ __all__ = ["TextToVideo", "ImageToVideo", "EditVideo"]
@@ -0,0 +1,94 @@
1
+ """HappyHorse edit-video resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+ from runapi.core import Resource, ValidationError
8
+
9
+ from ..types import (
10
+ AUDIO_SETTINGS,
11
+ EDIT_VIDEO_MODEL,
12
+ OUTPUT_RESOLUTIONS,
13
+ SEED_RANGE,
14
+ CompletedEditVideoResponse,
15
+ EditVideoResponse,
16
+ )
17
+
18
+
19
+ class EditVideo(Resource):
20
+ """Edit a source video from a prompt with HappyHorse models."""
21
+
22
+ ENDPOINT = "/api/v1/happyhorse/edit_video"
23
+
24
+ RESPONSE_CLASS = EditVideoResponse
25
+ COMPLETED_RESPONSE_CLASS = CompletedEditVideoResponse
26
+ REFERENCE_IMAGE_RANGE = range(0, 6)
27
+
28
+ def run(self, **params: Any) -> Any:
29
+ """Create an edit-video task and poll until it completes.
30
+
31
+ Args:
32
+ **params: Edit-video parameters (model, prompt, source_video_url, ...).
33
+
34
+ Returns:
35
+ The completed task with videos.
36
+ """
37
+ task = self.create(**params)
38
+ return self._poll_until_complete(lambda: self.get(task.id))
39
+
40
+ def create(self, **params: Any) -> Any:
41
+ """Create an edit-video task and return immediately with an ``id``.
42
+
43
+ Args:
44
+ **params: Edit-video parameters (model, prompt, source_video_url, ...).
45
+
46
+ Returns:
47
+ The task creation result with an id.
48
+ """
49
+ compacted = self._compact_params(params)
50
+ self._validate_params(compacted)
51
+ return self._request("post", self.ENDPOINT, body=compacted)
52
+
53
+ def get(self, id: str) -> Any:
54
+ """Fetch the current status of an edit-video task.
55
+
56
+ Args:
57
+ id: The task id.
58
+
59
+ Returns:
60
+ The current task status.
61
+ """
62
+ return self._request("get", f"{self.ENDPOINT}/{id}")
63
+
64
+ def _validate_params(self, params: Dict[str, Any]) -> None:
65
+ if params.get("model") != EDIT_VIDEO_MODEL:
66
+ raise ValidationError("model is required")
67
+ if not params.get("prompt"):
68
+ raise ValidationError("prompt is required")
69
+ if not params.get("source_video_url"):
70
+ raise ValidationError("source_video_url is required")
71
+
72
+ reference_image_urls = params.get("reference_image_urls")
73
+ if reference_image_urls and (
74
+ not isinstance(reference_image_urls, list)
75
+ or len(reference_image_urls) not in self.REFERENCE_IMAGE_RANGE
76
+ ):
77
+ raise ValidationError(
78
+ f"reference_image_urls must include at most {self.REFERENCE_IMAGE_RANGE.stop - 1} entries"
79
+ )
80
+
81
+ self._validate_optional(params, "output_resolution", OUTPUT_RESOLUTIONS)
82
+ self._validate_optional(params, "audio_setting", AUDIO_SETTINGS)
83
+ self._validate_integer_range(params, "seed", SEED_RANGE)
84
+
85
+ @staticmethod
86
+ def _validate_integer_range(params: Dict[str, Any], key: str, allowed: range) -> None:
87
+ value = params.get(key)
88
+ if value is None:
89
+ return
90
+ if isinstance(value, int) and not isinstance(value, bool) and value in allowed:
91
+ return
92
+ raise ValidationError(
93
+ f"{key} must be an integer between {allowed.start} and {allowed.stop - 1}"
94
+ )
@@ -0,0 +1,82 @@
1
+ """HappyHorse image-to-video resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+ from runapi.core import Resource, ValidationError
8
+
9
+ from ..types import (
10
+ DURATION_RANGE,
11
+ IMAGE_TO_VIDEO_MODEL,
12
+ OUTPUT_RESOLUTIONS,
13
+ SEED_RANGE,
14
+ CompletedImageToVideoResponse,
15
+ ImageToVideoResponse,
16
+ )
17
+
18
+
19
+ class ImageToVideo(Resource):
20
+ """Generate videos from a first-frame image with HappyHorse models."""
21
+
22
+ ENDPOINT = "/api/v1/happyhorse/image_to_video"
23
+
24
+ RESPONSE_CLASS = ImageToVideoResponse
25
+ COMPLETED_RESPONSE_CLASS = CompletedImageToVideoResponse
26
+
27
+ def run(self, **params: Any) -> Any:
28
+ """Create an image-to-video task and poll until it completes.
29
+
30
+ Args:
31
+ **params: Image-to-video parameters (model, first_frame_image_url, ...).
32
+
33
+ Returns:
34
+ The completed task with videos.
35
+ """
36
+ task = self.create(**params)
37
+ return self._poll_until_complete(lambda: self.get(task.id))
38
+
39
+ def create(self, **params: Any) -> Any:
40
+ """Create an image-to-video task and return immediately with an ``id``.
41
+
42
+ Args:
43
+ **params: Image-to-video parameters (model, first_frame_image_url, ...).
44
+
45
+ Returns:
46
+ The task creation result with an id.
47
+ """
48
+ compacted = self._compact_params(params)
49
+ self._validate_params(compacted)
50
+ return self._request("post", self.ENDPOINT, body=compacted)
51
+
52
+ def get(self, id: str) -> Any:
53
+ """Fetch the current status of an image-to-video task.
54
+
55
+ Args:
56
+ id: The task id.
57
+
58
+ Returns:
59
+ The current task status.
60
+ """
61
+ return self._request("get", f"{self.ENDPOINT}/{id}")
62
+
63
+ def _validate_params(self, params: Dict[str, Any]) -> None:
64
+ if params.get("model") != IMAGE_TO_VIDEO_MODEL:
65
+ raise ValidationError("model is required")
66
+ if not params.get("first_frame_image_url"):
67
+ raise ValidationError("first_frame_image_url is required")
68
+
69
+ self._validate_optional(params, "output_resolution", OUTPUT_RESOLUTIONS)
70
+ self._validate_integer_range(params, "duration_seconds", DURATION_RANGE)
71
+ self._validate_integer_range(params, "seed", SEED_RANGE)
72
+
73
+ @staticmethod
74
+ def _validate_integer_range(params: Dict[str, Any], key: str, allowed: range) -> None:
75
+ value = params.get(key)
76
+ if value is None:
77
+ return
78
+ if isinstance(value, int) and not isinstance(value, bool) and value in allowed:
79
+ return
80
+ raise ValidationError(
81
+ f"{key} must be an integer between {allowed.start} and {allowed.stop - 1}"
82
+ )
@@ -0,0 +1,100 @@
1
+ """HappyHorse text-to-video resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+ from runapi.core import Resource, ValidationError
8
+
9
+ from ..types import (
10
+ ASPECT_RATIOS,
11
+ CHARACTER_MODEL,
12
+ DURATION_RANGE,
13
+ OUTPUT_RESOLUTIONS,
14
+ SEED_RANGE,
15
+ TEXT_TO_VIDEO_MODELS,
16
+ CompletedTextToVideoResponse,
17
+ TextToVideoResponse,
18
+ )
19
+
20
+
21
+ class TextToVideo(Resource):
22
+ """Generate videos from text prompts with HappyHorse models."""
23
+
24
+ ENDPOINT = "/api/v1/happyhorse/text_to_video"
25
+
26
+ RESPONSE_CLASS = TextToVideoResponse
27
+ COMPLETED_RESPONSE_CLASS = CompletedTextToVideoResponse
28
+ REFERENCE_IMAGE_URLS_RANGE = range(1, 10)
29
+
30
+ def run(self, **params: Any) -> Any:
31
+ """Create a text-to-video task and poll until it completes.
32
+
33
+ Args:
34
+ **params: Text-to-video parameters (model, prompt, ...).
35
+
36
+ Returns:
37
+ The completed task with videos.
38
+ """
39
+ task = self.create(**params)
40
+ return self._poll_until_complete(lambda: self.get(task.id))
41
+
42
+ def create(self, **params: Any) -> Any:
43
+ """Create a text-to-video task and return immediately with an ``id``.
44
+
45
+ Args:
46
+ **params: Text-to-video parameters (model, prompt, ...).
47
+
48
+ Returns:
49
+ The task creation result with an id.
50
+ """
51
+ compacted = self._compact_params(params)
52
+ self._validate_params(compacted)
53
+ return self._request("post", self.ENDPOINT, body=compacted)
54
+
55
+ def get(self, id: str) -> Any:
56
+ """Fetch the current status of a text-to-video task.
57
+
58
+ Args:
59
+ id: The task id.
60
+
61
+ Returns:
62
+ The current task status.
63
+ """
64
+ return self._request("get", f"{self.ENDPOINT}/{id}")
65
+
66
+ def _validate_params(self, params: Dict[str, Any]) -> None:
67
+ model = params.get("model")
68
+ if model not in TEXT_TO_VIDEO_MODELS:
69
+ raise ValidationError("model is required")
70
+ if not params.get("prompt"):
71
+ raise ValidationError("prompt is required")
72
+
73
+ reference_image_urls = params.get("reference_image_urls")
74
+ if model == CHARACTER_MODEL:
75
+ if not (
76
+ isinstance(reference_image_urls, list)
77
+ and len(reference_image_urls) in self.REFERENCE_IMAGE_URLS_RANGE
78
+ ):
79
+ raise ValidationError(
80
+ f"reference_image_urls must include between {self.REFERENCE_IMAGE_URLS_RANGE.start} "
81
+ f"and {self.REFERENCE_IMAGE_URLS_RANGE.stop - 1} entries"
82
+ )
83
+ elif reference_image_urls:
84
+ raise ValidationError(f"reference_image_urls is only supported for {CHARACTER_MODEL}")
85
+
86
+ self._validate_optional(params, "output_resolution", OUTPUT_RESOLUTIONS)
87
+ self._validate_optional(params, "aspect_ratio", ASPECT_RATIOS)
88
+ self._validate_integer_range(params, "duration_seconds", DURATION_RANGE)
89
+ self._validate_integer_range(params, "seed", SEED_RANGE)
90
+
91
+ @staticmethod
92
+ def _validate_integer_range(params: Dict[str, Any], key: str, allowed: range) -> None:
93
+ value = params.get(key)
94
+ if value is None:
95
+ return
96
+ if isinstance(value, int) and not isinstance(value, bool) and value in allowed:
97
+ return
98
+ raise ValidationError(
99
+ f"{key} must be an integer between {allowed.start} and {allowed.stop - 1}"
100
+ )
@@ -0,0 +1,41 @@
1
+ """HappyHorse model lists, enums, and response models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from runapi.core import BaseModel, TaskResponse, optional, required
6
+
7
+ TEXT_TO_VIDEO_MODEL = "happyhorse-text-to-video"
8
+ CHARACTER_MODEL = "happyhorse-character"
9
+ TEXT_TO_VIDEO_MODELS = [TEXT_TO_VIDEO_MODEL, CHARACTER_MODEL]
10
+ IMAGE_TO_VIDEO_MODEL = "happyhorse-image-to-video"
11
+ EDIT_VIDEO_MODEL = "happyhorse-edit-video"
12
+ OUTPUT_RESOLUTIONS = ["720p", "1080p"]
13
+ ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4"]
14
+ AUDIO_SETTINGS = ["auto", "original"]
15
+ DURATION_RANGE = range(3, 16)
16
+ SEED_RANGE = range(0, 2_147_483_648)
17
+
18
+
19
+ class MediaUrl(BaseModel):
20
+ url = optional(str)
21
+
22
+
23
+ class TextToVideoResponse(TaskResponse):
24
+ """Response for a video generation task."""
25
+
26
+ id = required(str)
27
+ status = optional(str, enum=lambda: TaskResponse.Status.ALL)
28
+ videos = optional([lambda: MediaUrl])
29
+ error = optional(str)
30
+
31
+
32
+ class CompletedTextToVideoResponse(TextToVideoResponse):
33
+ """Narrowed response from ``run()`` once polling observes completion."""
34
+
35
+ videos = required([lambda: MediaUrl])
36
+
37
+
38
+ ImageToVideoResponse = TextToVideoResponse
39
+ CompletedImageToVideoResponse = CompletedTextToVideoResponse
40
+ EditVideoResponse = TextToVideoResponse
41
+ CompletedEditVideoResponse = CompletedTextToVideoResponse
@@ -0,0 +1,235 @@
1
+ import pytest
2
+
3
+ from runapi.core import config
4
+ from runapi.core.errors import AuthenticationError, ValidationError
5
+ from runapi.happyhorse import HappyHorseClient
6
+ from runapi.happyhorse.resources.edit_video import EditVideo
7
+ from runapi.happyhorse.resources.image_to_video import ImageToVideo
8
+ from runapi.happyhorse.resources.text_to_video import TextToVideo
9
+ from runapi.happyhorse.types import CompletedTextToVideoResponse, TextToVideoResponse
10
+
11
+
12
+ class FakeHttp:
13
+ def __init__(self, *responses):
14
+ self._responses = list(responses)
15
+ self.calls = []
16
+
17
+ def request(self, method, path, body=None, options=None):
18
+ self.calls.append((method, path, body))
19
+ if self._responses:
20
+ return self._responses.pop(0)
21
+ return {"id": "task_1", "status": "pending"}
22
+
23
+
24
+ @pytest.fixture(autouse=True)
25
+ def reset_config(monkeypatch):
26
+ monkeypatch.delenv("RUNAPI_API_KEY", raising=False)
27
+ monkeypatch.setattr(config, "api_key", None)
28
+ yield
29
+
30
+
31
+ # --- auth -----------------------------------------------------------------
32
+
33
+
34
+ def test_accepts_api_key_parameter():
35
+ assert isinstance(HappyHorseClient(api_key="k", http_client=FakeHttp()), HappyHorseClient)
36
+
37
+
38
+ def test_falls_back_to_global(monkeypatch):
39
+ monkeypatch.setattr(config, "api_key", "global-key")
40
+ assert isinstance(HappyHorseClient(http_client=FakeHttp()), HappyHorseClient)
41
+
42
+
43
+ def test_falls_back_to_env(monkeypatch):
44
+ monkeypatch.setenv("RUNAPI_API_KEY", "env-key")
45
+ assert isinstance(HappyHorseClient(http_client=FakeHttp()), HappyHorseClient)
46
+
47
+
48
+ def test_raises_without_api_key():
49
+ with pytest.raises(AuthenticationError, match="API key is required"):
50
+ HappyHorseClient()
51
+
52
+
53
+ # --- injection / accessors ------------------------------------------------
54
+
55
+
56
+ def test_uses_injected_http_client():
57
+ fake = FakeHttp()
58
+ client = HappyHorseClient(api_key="k", http_client=fake)
59
+ assert client.text_to_video._http is fake
60
+ assert client.image_to_video._http is fake
61
+ assert client.edit_video._http is fake
62
+
63
+
64
+ def test_exposes_resource_accessors():
65
+ client = HappyHorseClient(api_key="k", http_client=FakeHttp())
66
+ assert isinstance(client.text_to_video, TextToVideo)
67
+ assert isinstance(client.image_to_video, ImageToVideo)
68
+ assert isinstance(client.edit_video, EditVideo)
69
+
70
+
71
+ # --- request shapes -------------------------------------------------------
72
+
73
+
74
+ def test_text_to_video_create_posts_compacted_body():
75
+ fake = FakeHttp({"id": "t1", "status": "pending"})
76
+ client = HappyHorseClient(api_key="k", http_client=fake)
77
+ result = client.text_to_video.create(
78
+ model="happyhorse-text-to-video",
79
+ prompt="a horse",
80
+ aspect_ratio="16:9",
81
+ seed=None,
82
+ )
83
+ assert fake.calls == [
84
+ (
85
+ "post",
86
+ "/api/v1/happyhorse/text_to_video",
87
+ {"model": "happyhorse-text-to-video", "prompt": "a horse", "aspect_ratio": "16:9"},
88
+ ),
89
+ ]
90
+ assert isinstance(result, TextToVideoResponse)
91
+
92
+
93
+ def test_text_to_video_get_fetches_by_id():
94
+ fake = FakeHttp({"id": "t1", "status": "processing"})
95
+ client = HappyHorseClient(api_key="k", http_client=fake)
96
+ client.text_to_video.get("t1")
97
+ assert fake.calls == [("get", "/api/v1/happyhorse/text_to_video/t1", None)]
98
+
99
+
100
+ def test_image_to_video_create_posts_compacted_body():
101
+ fake = FakeHttp({"id": "t1", "status": "pending"})
102
+ client = HappyHorseClient(api_key="k", http_client=fake)
103
+ client.image_to_video.create(
104
+ model="happyhorse-image-to-video",
105
+ first_frame_image_url="https://example.com/a.jpg",
106
+ output_resolution="720p",
107
+ )
108
+ assert fake.calls == [
109
+ (
110
+ "post",
111
+ "/api/v1/happyhorse/image_to_video",
112
+ {
113
+ "model": "happyhorse-image-to-video",
114
+ "first_frame_image_url": "https://example.com/a.jpg",
115
+ "output_resolution": "720p",
116
+ },
117
+ ),
118
+ ]
119
+
120
+
121
+ def test_edit_video_create_posts_compacted_body():
122
+ fake = FakeHttp({"id": "t1", "status": "pending"})
123
+ client = HappyHorseClient(api_key="k", http_client=fake)
124
+ client.edit_video.create(
125
+ model="happyhorse-edit-video",
126
+ prompt="brighten it",
127
+ source_video_url="https://example.com/v.mp4",
128
+ )
129
+ assert fake.calls == [
130
+ (
131
+ "post",
132
+ "/api/v1/happyhorse/edit_video",
133
+ {
134
+ "model": "happyhorse-edit-video",
135
+ "prompt": "brighten it",
136
+ "source_video_url": "https://example.com/v.mp4",
137
+ },
138
+ ),
139
+ ]
140
+
141
+
142
+ def test_run_narrows_completed_type():
143
+ fake = FakeHttp(
144
+ {"id": "t1", "status": "pending"},
145
+ {"id": "t1", "status": "completed", "videos": [{"url": "https://x/y.mp4"}]},
146
+ )
147
+ client = HappyHorseClient(api_key="k", http_client=fake)
148
+ result = client.text_to_video.run(model="happyhorse-text-to-video", prompt="a serene river")
149
+ assert isinstance(result, CompletedTextToVideoResponse)
150
+ assert result.videos[0].url == "https://x/y.mp4"
151
+
152
+
153
+ # --- validation -----------------------------------------------------------
154
+
155
+
156
+ def test_text_to_video_rejects_unknown_model():
157
+ client = HappyHorseClient(api_key="k", http_client=FakeHttp())
158
+ with pytest.raises(ValidationError, match="model is required"):
159
+ client.text_to_video.create(model="nope", prompt="hi")
160
+
161
+
162
+ def test_text_to_video_requires_prompt():
163
+ client = HappyHorseClient(api_key="k", http_client=FakeHttp())
164
+ with pytest.raises(ValidationError, match="prompt is required"):
165
+ client.text_to_video.create(model="happyhorse-text-to-video")
166
+
167
+
168
+ def test_text_to_video_rejects_reference_images_for_non_character():
169
+ client = HappyHorseClient(api_key="k", http_client=FakeHttp())
170
+ with pytest.raises(ValidationError, match="reference_image_urls is only supported for happyhorse-character"):
171
+ client.text_to_video.create(
172
+ model="happyhorse-text-to-video",
173
+ prompt="hi",
174
+ reference_image_urls=["https://x/a.jpg"],
175
+ )
176
+
177
+
178
+ def test_character_model_requires_reference_images_range():
179
+ client = HappyHorseClient(api_key="k", http_client=FakeHttp())
180
+ with pytest.raises(ValidationError, match="reference_image_urls must include between 1 and 9 entries"):
181
+ client.text_to_video.create(model="happyhorse-character", prompt="hi", reference_image_urls=[])
182
+
183
+
184
+ def test_text_to_video_duration_range():
185
+ client = HappyHorseClient(api_key="k", http_client=FakeHttp())
186
+ with pytest.raises(ValidationError, match="duration_seconds must be an integer between 3 and 15"):
187
+ client.text_to_video.create(model="happyhorse-text-to-video", prompt="hi", duration_seconds=99)
188
+
189
+
190
+ def test_text_to_video_rejects_invalid_output_resolution():
191
+ client = HappyHorseClient(api_key="k", http_client=FakeHttp())
192
+ with pytest.raises(ValidationError, match="Invalid output_resolution"):
193
+ client.text_to_video.create(
194
+ model="happyhorse-text-to-video", prompt="hi", output_resolution="4k"
195
+ )
196
+
197
+
198
+ def test_image_to_video_requires_model():
199
+ client = HappyHorseClient(api_key="k", http_client=FakeHttp())
200
+ with pytest.raises(ValidationError, match="model is required"):
201
+ client.image_to_video.create(model="wrong", first_frame_image_url="https://x/a.jpg")
202
+
203
+
204
+ def test_image_to_video_requires_first_frame():
205
+ client = HappyHorseClient(api_key="k", http_client=FakeHttp())
206
+ with pytest.raises(ValidationError, match="first_frame_image_url is required"):
207
+ client.image_to_video.create(model="happyhorse-image-to-video")
208
+
209
+
210
+ def test_edit_video_requires_source_video_url():
211
+ client = HappyHorseClient(api_key="k", http_client=FakeHttp())
212
+ with pytest.raises(ValidationError, match="source_video_url is required"):
213
+ client.edit_video.create(model="happyhorse-edit-video", prompt="brighten")
214
+
215
+
216
+ def test_edit_video_reference_images_max():
217
+ client = HappyHorseClient(api_key="k", http_client=FakeHttp())
218
+ with pytest.raises(ValidationError, match="reference_image_urls must include at most 5 entries"):
219
+ client.edit_video.create(
220
+ model="happyhorse-edit-video",
221
+ prompt="brighten",
222
+ source_video_url="https://x/v.mp4",
223
+ reference_image_urls=["a", "b", "c", "d", "e", "f"],
224
+ )
225
+
226
+
227
+ def test_edit_video_rejects_invalid_audio_setting():
228
+ client = HappyHorseClient(api_key="k", http_client=FakeHttp())
229
+ with pytest.raises(ValidationError, match="Invalid audio_setting"):
230
+ client.edit_video.create(
231
+ model="happyhorse-edit-video",
232
+ prompt="brighten",
233
+ source_video_url="https://x/v.mp4",
234
+ audio_setting="muted",
235
+ )