pedra 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,19 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: PYTHONPATH=src python -m unittest discover -s tests -v
pedra-0.1.0/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .env
9
+ .DS_Store
10
+ .pytest_cache/
11
+ .mypy_cache/
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release.
6
+ - Full coverage of the Pedra API: `enhance`, `enhance_and_correct_perspective`,
7
+ `empty`, `furnish`, `renovation`, `edit_via_prompt`, `sky`, `remove`, `blur`,
8
+ `create_video`, `credits`, `feedback`.
9
+ - Zero runtime dependencies (standard library only), typed dataclass responses,
10
+ and typed errors (`PedraError`, `PedraAPIError`).
pedra-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pedra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pedra-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: pedra
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Pedra API — AI photo editing for real estate (virtual staging, renovation, enhancement, video).
5
+ Project-URL: Homepage, https://pedra.ai
6
+ Project-URL: Documentation, https://pedra.ai/api-documentation
7
+ Project-URL: Repository, https://github.com/pedra-ai/pedra-python
8
+ Project-URL: Issues, https://github.com/pedra-ai/pedra-python/issues
9
+ Author-email: Pedra <felix@pedra.ai>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai,home-staging,image-editing,interior-design,pedra,proptech,real-estate,real-estate-photography,renovation,sdk,virtual-staging
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Multimedia :: Graphics
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Requires-Python: >=3.8
25
+ Description-Content-Type: text/markdown
26
+
27
+ # Pedra Python SDK
28
+
29
+ Official Python SDK for the [Pedra API](https://pedra.ai/api-documentation) — AI photo editing for real estate: virtual staging, renovation, room emptying, image enhancement, sky replacement, object removal/blur, and property videos.
30
+
31
+ [![PyPI version](https://img.shields.io/pypi/v/pedra.svg)](https://pypi.org/project/pedra/)
32
+
33
+ ```bash
34
+ pip install pedra
35
+ ```
36
+
37
+ Requires Python 3.8+. Zero runtime dependencies (uses the standard library).
38
+
39
+ ## Quick start
40
+
41
+ ```python
42
+ from pedra import Pedra
43
+
44
+ pedra = Pedra("YOUR_API_KEY") # or set PEDRA_API_KEY in the environment
45
+
46
+ result = pedra.furnish(
47
+ image_url="https://example.com/empty-living-room.jpg",
48
+ room_type="Living room",
49
+ style="Minimalist",
50
+ )
51
+
52
+ print(result.url) # → the staged image URL
53
+ print(result.urls) # → all generated URLs
54
+ ```
55
+
56
+ Get your API key from your [Pedra account settings](https://app.pedra.ai). Every method blocks until the asset is ready and returns the final URL(s) — there are no job IDs to poll. The API uses a heartbeat to keep long requests (like `create_video`) alive.
57
+
58
+ ## Authentication
59
+
60
+ ```python
61
+ pedra = Pedra("YOUR_API_KEY")
62
+ # or
63
+ pedra = Pedra() # reads PEDRA_API_KEY from the environment
64
+ ```
65
+
66
+ Options:
67
+
68
+ ```python
69
+ pedra = Pedra(
70
+ "YOUR_API_KEY",
71
+ base_url="https://app.pedra.ai/api", # default
72
+ timeout=600.0, # seconds, default 10 min (covers create_video)
73
+ )
74
+ ```
75
+
76
+ ## Responses
77
+
78
+ Image methods return an `ImageResponse`, which normalizes the underlying
79
+ endpoint's output (some return a list, some a single object):
80
+
81
+ ```python
82
+ @dataclass
83
+ class ImageResponse:
84
+ message: Optional[str]
85
+ urls: List[str] # every generated asset URL
86
+ raw: Any # the untouched API response
87
+ # .url -> Optional[str]: convenience for the first URL
88
+ ```
89
+
90
+ ## Methods
91
+
92
+ | Method | Endpoint | Returns |
93
+ | --- | --- | --- |
94
+ | `enhance(image_url, *, preserve_original_framing=None)` | `/enhance` | `ImageResponse` |
95
+ | `enhance_and_correct_perspective(image_url, *, preserve_original_framing=None)` | `/enhance_and_correct_perspective` | `ImageResponse` |
96
+ | `empty(image_url)` | `/empty_room` | `ImageResponse` |
97
+ | `furnish(image_url, *, room_type=None, style=None, creativity=None)` | `/furnish` | `ImageResponse` |
98
+ | `renovation(image_url, *, style=None, creativity=None, furnish=None, room_type=None)` | `/renovation` | `ImageResponse` |
99
+ | `edit_via_prompt(image_url, prompt)` | `/edit_via_prompt` | `ImageResponse` |
100
+ | `sky(image_url, *, sky_style=None)` | `/sky_blue` | `ImageResponse` |
101
+ | `remove(image_url, mask_url)` | `/remove_object` | `ImageResponse` |
102
+ | `blur(image_url, objects_to_blur)` | `/blur` | `ImageResponse` |
103
+ | `create_video(images, *, music=None, voice=None, branding=None, ending_title=None, ending_subtitle=None, is_vertical=None, property_characteristics=None)` | `/create_video` | `VideoResponse` |
104
+ | `credits()` | `/credits` | `CreditsResponse` |
105
+ | `feedback(*, image_url=None, image_id=None, vote=None, comment=None, credit_back=None)` | `/feedback` | `FeedbackResponse` |
106
+
107
+ ### Examples
108
+
109
+ ```python
110
+ # Enhance — preserve exact framing (verification verticals)
111
+ pedra.enhance(image_url=url, preserve_original_framing=True)
112
+
113
+ # Empty a room
114
+ result = pedra.empty(url)
115
+
116
+ # Renovate, furnished, high creativity
117
+ pedra.renovation(url, style="Scandinavian", creativity="High", furnish=True)
118
+
119
+ # Edit via prompt
120
+ pedra.edit_via_prompt(url, "Add a large green plant in the corner")
121
+
122
+ # Sky replacement
123
+ pedra.sky(url)
124
+
125
+ # Remove an object using a mask
126
+ pedra.remove(url, mask_url)
127
+
128
+ # Blur faces / plates
129
+ pedra.blur(url, ["faces", "license_plates"])
130
+
131
+ # Credits
132
+ info = pedra.credits()
133
+ print(info.plan, info.credits_remaining)
134
+
135
+ # Feedback + credit-back on a bad result
136
+ pedra.feedback(image_url=url, vote="down", comment="Artifacts on the wall", credit_back=True)
137
+ ```
138
+
139
+ ### Creating a video
140
+
141
+ `create_video` blocks server-side (up to ~10 minutes) while the video renders,
142
+ then returns the finished URL inline. Image dicts use snake_case keys — the SDK
143
+ converts them to the API's wire format for you:
144
+
145
+ ```python
146
+ video = pedra.create_video(
147
+ images=[
148
+ {"image_url": "https://example.com/photo1.jpg", "effect": "zoom-in", "title": "Living room"},
149
+ {"image_url": "https://example.com/photo2.jpg", "effect": "zoom-out"},
150
+ {
151
+ "image_url": "https://example.com/before.jpg",
152
+ "effect": "transition",
153
+ "second_image_url": "https://example.com/after.jpg",
154
+ },
155
+ ],
156
+ music={"enabled": True, "track": "calm"},
157
+ branding={"show_watermark": True},
158
+ ending_title="Contact us",
159
+ ending_subtitle="+1 555 0100",
160
+ is_vertical=False,
161
+ property_characteristics=[
162
+ {"label": "Bedrooms", "value": "3"},
163
+ {"label": "Bathrooms", "value": "2"},
164
+ ],
165
+ )
166
+
167
+ print(video.video_url)
168
+ ```
169
+
170
+ Per-image `effect` is one of `zoom-in` (default), `zoom-out`, `transition`
171
+ (requires `second_image_url`), or `static`. Each non-static image costs 5 credits.
172
+
173
+ ## Error handling
174
+
175
+ ```python
176
+ from pedra import PedraAPIError, PedraError
177
+
178
+ try:
179
+ pedra.enhance(url)
180
+ except PedraAPIError as err:
181
+ print(err.status, err, err.body)
182
+ except PedraError as err:
183
+ print("Client/network error:", err)
184
+ ```
185
+
186
+ `PedraAPIError` is also raised when a long request fails *after* the heartbeat
187
+ has started — the API returns HTTP 200 with an `{"error": ...}` body in that
188
+ case, and the SDK surfaces it as an error anyway.
189
+
190
+ ## Links
191
+
192
+ - API documentation: https://pedra.ai/api-documentation
193
+ - Pedra: https://pedra.ai
194
+
195
+ ## License
196
+
197
+ MIT
@@ -0,0 +1,46 @@
1
+ # Publishing `pedra` to PyPI
2
+
3
+ The PyPI name `pedra` is available. The package builds with `hatchling`.
4
+
5
+ ## One-time setup
6
+
7
+ 1. Create a PyPI account and a project API token at https://pypi.org/manage/account/token/
8
+ (scope it to the `pedra` project after the first upload, or use an account
9
+ token for the very first upload).
10
+ 2. Install the build/upload tools (ideally in a virtualenv):
11
+ ```bash
12
+ python -m pip install --upgrade build twine
13
+ ```
14
+
15
+ ## Test before publishing
16
+
17
+ ```bash
18
+ PYTHONPATH=src python -m unittest discover -s tests -v
19
+ ```
20
+
21
+ ## Build + upload
22
+
23
+ ```bash
24
+ python -m build # creates dist/pedra-<version>.tar.gz and .whl
25
+ twine check dist/* # validates metadata + README rendering
26
+ twine upload dist/* # prompts for token (use __token__ as username)
27
+ ```
28
+
29
+ Tip: do a dry run against TestPyPI first:
30
+ ```bash
31
+ twine upload --repository testpypi dist/*
32
+ pip install --index-url https://test.pypi.org/simple/ pedra
33
+ ```
34
+
35
+ ## Releasing a new version
36
+
37
+ 1. Bump `version` in `pyproject.toml` and `src/pedra/__init__.py` (`__version__`).
38
+ 2. Update `CHANGELOG.md`.
39
+ 3. `rm -rf dist && python -m build && twine upload dist/*`.
40
+ 4. Tag the release: `git tag v<version> && git push --tags`.
41
+
42
+ ## CI
43
+
44
+ `.github/workflows/ci.yml` runs the test suite on Python 3.8–3.12 for every push
45
+ and PR. Consider adding a publish job using PyPI Trusted Publishing (OIDC) gated
46
+ on tags.
pedra-0.1.0/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # Pedra Python SDK
2
+
3
+ Official Python SDK for the [Pedra API](https://pedra.ai/api-documentation) — AI photo editing for real estate: virtual staging, renovation, room emptying, image enhancement, sky replacement, object removal/blur, and property videos.
4
+
5
+ [![PyPI version](https://img.shields.io/pypi/v/pedra.svg)](https://pypi.org/project/pedra/)
6
+
7
+ ```bash
8
+ pip install pedra
9
+ ```
10
+
11
+ Requires Python 3.8+. Zero runtime dependencies (uses the standard library).
12
+
13
+ ## Quick start
14
+
15
+ ```python
16
+ from pedra import Pedra
17
+
18
+ pedra = Pedra("YOUR_API_KEY") # or set PEDRA_API_KEY in the environment
19
+
20
+ result = pedra.furnish(
21
+ image_url="https://example.com/empty-living-room.jpg",
22
+ room_type="Living room",
23
+ style="Minimalist",
24
+ )
25
+
26
+ print(result.url) # → the staged image URL
27
+ print(result.urls) # → all generated URLs
28
+ ```
29
+
30
+ Get your API key from your [Pedra account settings](https://app.pedra.ai). Every method blocks until the asset is ready and returns the final URL(s) — there are no job IDs to poll. The API uses a heartbeat to keep long requests (like `create_video`) alive.
31
+
32
+ ## Authentication
33
+
34
+ ```python
35
+ pedra = Pedra("YOUR_API_KEY")
36
+ # or
37
+ pedra = Pedra() # reads PEDRA_API_KEY from the environment
38
+ ```
39
+
40
+ Options:
41
+
42
+ ```python
43
+ pedra = Pedra(
44
+ "YOUR_API_KEY",
45
+ base_url="https://app.pedra.ai/api", # default
46
+ timeout=600.0, # seconds, default 10 min (covers create_video)
47
+ )
48
+ ```
49
+
50
+ ## Responses
51
+
52
+ Image methods return an `ImageResponse`, which normalizes the underlying
53
+ endpoint's output (some return a list, some a single object):
54
+
55
+ ```python
56
+ @dataclass
57
+ class ImageResponse:
58
+ message: Optional[str]
59
+ urls: List[str] # every generated asset URL
60
+ raw: Any # the untouched API response
61
+ # .url -> Optional[str]: convenience for the first URL
62
+ ```
63
+
64
+ ## Methods
65
+
66
+ | Method | Endpoint | Returns |
67
+ | --- | --- | --- |
68
+ | `enhance(image_url, *, preserve_original_framing=None)` | `/enhance` | `ImageResponse` |
69
+ | `enhance_and_correct_perspective(image_url, *, preserve_original_framing=None)` | `/enhance_and_correct_perspective` | `ImageResponse` |
70
+ | `empty(image_url)` | `/empty_room` | `ImageResponse` |
71
+ | `furnish(image_url, *, room_type=None, style=None, creativity=None)` | `/furnish` | `ImageResponse` |
72
+ | `renovation(image_url, *, style=None, creativity=None, furnish=None, room_type=None)` | `/renovation` | `ImageResponse` |
73
+ | `edit_via_prompt(image_url, prompt)` | `/edit_via_prompt` | `ImageResponse` |
74
+ | `sky(image_url, *, sky_style=None)` | `/sky_blue` | `ImageResponse` |
75
+ | `remove(image_url, mask_url)` | `/remove_object` | `ImageResponse` |
76
+ | `blur(image_url, objects_to_blur)` | `/blur` | `ImageResponse` |
77
+ | `create_video(images, *, music=None, voice=None, branding=None, ending_title=None, ending_subtitle=None, is_vertical=None, property_characteristics=None)` | `/create_video` | `VideoResponse` |
78
+ | `credits()` | `/credits` | `CreditsResponse` |
79
+ | `feedback(*, image_url=None, image_id=None, vote=None, comment=None, credit_back=None)` | `/feedback` | `FeedbackResponse` |
80
+
81
+ ### Examples
82
+
83
+ ```python
84
+ # Enhance — preserve exact framing (verification verticals)
85
+ pedra.enhance(image_url=url, preserve_original_framing=True)
86
+
87
+ # Empty a room
88
+ result = pedra.empty(url)
89
+
90
+ # Renovate, furnished, high creativity
91
+ pedra.renovation(url, style="Scandinavian", creativity="High", furnish=True)
92
+
93
+ # Edit via prompt
94
+ pedra.edit_via_prompt(url, "Add a large green plant in the corner")
95
+
96
+ # Sky replacement
97
+ pedra.sky(url)
98
+
99
+ # Remove an object using a mask
100
+ pedra.remove(url, mask_url)
101
+
102
+ # Blur faces / plates
103
+ pedra.blur(url, ["faces", "license_plates"])
104
+
105
+ # Credits
106
+ info = pedra.credits()
107
+ print(info.plan, info.credits_remaining)
108
+
109
+ # Feedback + credit-back on a bad result
110
+ pedra.feedback(image_url=url, vote="down", comment="Artifacts on the wall", credit_back=True)
111
+ ```
112
+
113
+ ### Creating a video
114
+
115
+ `create_video` blocks server-side (up to ~10 minutes) while the video renders,
116
+ then returns the finished URL inline. Image dicts use snake_case keys — the SDK
117
+ converts them to the API's wire format for you:
118
+
119
+ ```python
120
+ video = pedra.create_video(
121
+ images=[
122
+ {"image_url": "https://example.com/photo1.jpg", "effect": "zoom-in", "title": "Living room"},
123
+ {"image_url": "https://example.com/photo2.jpg", "effect": "zoom-out"},
124
+ {
125
+ "image_url": "https://example.com/before.jpg",
126
+ "effect": "transition",
127
+ "second_image_url": "https://example.com/after.jpg",
128
+ },
129
+ ],
130
+ music={"enabled": True, "track": "calm"},
131
+ branding={"show_watermark": True},
132
+ ending_title="Contact us",
133
+ ending_subtitle="+1 555 0100",
134
+ is_vertical=False,
135
+ property_characteristics=[
136
+ {"label": "Bedrooms", "value": "3"},
137
+ {"label": "Bathrooms", "value": "2"},
138
+ ],
139
+ )
140
+
141
+ print(video.video_url)
142
+ ```
143
+
144
+ Per-image `effect` is one of `zoom-in` (default), `zoom-out`, `transition`
145
+ (requires `second_image_url`), or `static`. Each non-static image costs 5 credits.
146
+
147
+ ## Error handling
148
+
149
+ ```python
150
+ from pedra import PedraAPIError, PedraError
151
+
152
+ try:
153
+ pedra.enhance(url)
154
+ except PedraAPIError as err:
155
+ print(err.status, err, err.body)
156
+ except PedraError as err:
157
+ print("Client/network error:", err)
158
+ ```
159
+
160
+ `PedraAPIError` is also raised when a long request fails *after* the heartbeat
161
+ has started — the API returns HTTP 200 with an `{"error": ...}` body in that
162
+ case, and the SDK surfaces it as an error anyway.
163
+
164
+ ## Links
165
+
166
+ - API documentation: https://pedra.ai/api-documentation
167
+ - Pedra: https://pedra.ai
168
+
169
+ ## License
170
+
171
+ MIT
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pedra"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the Pedra API — AI photo editing for real estate (virtual staging, renovation, enhancement, video)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Pedra", email = "felix@pedra.ai" }]
13
+ keywords = [
14
+ "pedra",
15
+ "real-estate",
16
+ "proptech",
17
+ "virtual-staging",
18
+ "home-staging",
19
+ "renovation",
20
+ "interior-design",
21
+ "ai",
22
+ "image-editing",
23
+ "real-estate-photography",
24
+ "sdk",
25
+ ]
26
+ classifiers = [
27
+ "Development Status :: 4 - Beta",
28
+ "Intended Audience :: Developers",
29
+ "License :: OSI Approved :: MIT License",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3.8",
32
+ "Programming Language :: Python :: 3.9",
33
+ "Programming Language :: Python :: 3.10",
34
+ "Programming Language :: Python :: 3.11",
35
+ "Programming Language :: Python :: 3.12",
36
+ "Topic :: Software Development :: Libraries :: Python Modules",
37
+ "Topic :: Multimedia :: Graphics",
38
+ ]
39
+ dependencies = []
40
+
41
+ [project.urls]
42
+ Homepage = "https://pedra.ai"
43
+ Documentation = "https://pedra.ai/api-documentation"
44
+ Repository = "https://github.com/pedra-ai/pedra-python"
45
+ Issues = "https://github.com/pedra-ai/pedra-python/issues"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/pedra"]
@@ -0,0 +1,29 @@
1
+ """Official Python SDK for the Pedra API.
2
+
3
+ >>> from pedra import Pedra
4
+ >>> pedra = Pedra("YOUR_API_KEY")
5
+ >>> result = pedra.furnish(image_url="https://example.com/room.jpg", style="Minimalist")
6
+ >>> print(result.url)
7
+ """
8
+
9
+ from .client import Pedra
10
+ from .errors import PedraAPIError, PedraError
11
+ from .models import (
12
+ CreditsResponse,
13
+ FeedbackResponse,
14
+ ImageResponse,
15
+ VideoResponse,
16
+ )
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "Pedra",
22
+ "PedraError",
23
+ "PedraAPIError",
24
+ "ImageResponse",
25
+ "VideoResponse",
26
+ "CreditsResponse",
27
+ "FeedbackResponse",
28
+ "__version__",
29
+ ]
@@ -0,0 +1,308 @@
1
+ """Synchronous client for the Pedra API."""
2
+
3
+ import json
4
+ import os
5
+ import urllib.error
6
+ import urllib.request
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from .errors import PedraAPIError, PedraError
10
+ from .models import CreditsResponse, FeedbackResponse, ImageResponse, VideoResponse
11
+
12
+ DEFAULT_BASE_URL = "https://app.pedra.ai/api"
13
+ # createVideo blocks server-side until the video is rendered (up to ~10 min).
14
+ DEFAULT_TIMEOUT = 600.0
15
+
16
+
17
+ def _to_camel(key: str) -> str:
18
+ """Convert a snake_case key to camelCase (the API's wire format)."""
19
+ head, *tail = key.split("_")
20
+ return head + "".join(word[:1].upper() + word[1:] for word in tail)
21
+
22
+
23
+ def _camelize(value: Any) -> Any:
24
+ """Recursively convert dict keys from snake_case to camelCase.
25
+
26
+ Only keys are transformed; string values (URLs, prompts, labels) are left
27
+ untouched. This lets callers use Pythonic snake_case everywhere while the
28
+ request body matches the camelCase the API expects.
29
+ """
30
+ if isinstance(value, dict):
31
+ return {_to_camel(k): _camelize(v) for k, v in value.items() if v is not None}
32
+ if isinstance(value, (list, tuple)):
33
+ return [_camelize(v) for v in value]
34
+ return value
35
+
36
+
37
+ class Pedra:
38
+ """Client for the Pedra API.
39
+
40
+ Every method is a synchronous, blocking call that returns the final
41
+ asset URL(s) — there are no client-side job IDs to poll (the API keeps long
42
+ requests alive with a heartbeat).
43
+
44
+ Example::
45
+
46
+ from pedra import Pedra
47
+
48
+ pedra = Pedra("YOUR_API_KEY")
49
+ result = pedra.furnish(
50
+ image_url="https://example.com/empty-room.jpg",
51
+ room_type="Living room",
52
+ style="Minimalist",
53
+ )
54
+ print(result.url)
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ api_key: Optional[str] = None,
60
+ *,
61
+ base_url: str = DEFAULT_BASE_URL,
62
+ timeout: float = DEFAULT_TIMEOUT,
63
+ ) -> None:
64
+ key = api_key or os.environ.get("PEDRA_API_KEY")
65
+ if not key:
66
+ raise PedraError(
67
+ "A Pedra API key is required. Pass it to Pedra(api_key=...) or "
68
+ "set the PEDRA_API_KEY environment variable."
69
+ )
70
+ self.api_key = key
71
+ self.base_url = base_url.rstrip("/")
72
+ self.timeout = timeout
73
+
74
+ # --- endpoints -----------------------------------------------------------
75
+
76
+ def enhance(
77
+ self, image_url: str, *, preserve_original_framing: Optional[bool] = None
78
+ ) -> ImageResponse:
79
+ """Enhance an image (lighting, color, sharpness)."""
80
+ return self._image(
81
+ self._post(
82
+ "/enhance",
83
+ image_url=image_url,
84
+ preserve_original_framing=preserve_original_framing,
85
+ )
86
+ )
87
+
88
+ def enhance_and_correct_perspective(
89
+ self, image_url: str, *, preserve_original_framing: Optional[bool] = None
90
+ ) -> ImageResponse:
91
+ """Enhance an image and correct its perspective."""
92
+ return self._image(
93
+ self._post(
94
+ "/enhance_and_correct_perspective",
95
+ image_url=image_url,
96
+ preserve_original_framing=preserve_original_framing,
97
+ )
98
+ )
99
+
100
+ def empty(self, image_url: str) -> ImageResponse:
101
+ """Empty a room — remove all furniture and objects. (``/empty_room``)"""
102
+ return self._image(self._post("/empty_room", image_url=image_url))
103
+
104
+ def furnish(
105
+ self,
106
+ image_url: str,
107
+ *,
108
+ room_type: Optional[str] = None,
109
+ style: Optional[str] = None,
110
+ creativity: Optional[str] = None,
111
+ ) -> ImageResponse:
112
+ """Virtually stage / furnish a room."""
113
+ return self._image(
114
+ self._post(
115
+ "/furnish",
116
+ image_url=image_url,
117
+ room_type=room_type,
118
+ style=style,
119
+ creativity=creativity,
120
+ )
121
+ )
122
+
123
+ def renovation(
124
+ self,
125
+ image_url: str,
126
+ *,
127
+ style: Optional[str] = None,
128
+ creativity: Optional[str] = None,
129
+ furnish: Any = None,
130
+ room_type: Optional[str] = None,
131
+ ) -> ImageResponse:
132
+ """Renovate a space. ``furnish`` accepts a bool or the explicit string
133
+ ("With furniture" / "Empty" / "Auto")."""
134
+ return self._image(
135
+ self._post(
136
+ "/renovation",
137
+ image_url=image_url,
138
+ style=style,
139
+ creativity=creativity,
140
+ furnish=furnish,
141
+ room_type=room_type,
142
+ )
143
+ )
144
+
145
+ def edit_via_prompt(self, image_url: str, prompt: str) -> ImageResponse:
146
+ """Edit an image from a natural-language prompt. (``/edit_via_prompt``)"""
147
+ return self._image(
148
+ self._post("/edit_via_prompt", image_url=image_url, prompt=prompt)
149
+ )
150
+
151
+ def sky(self, image_url: str, *, sky_style: Optional[str] = None) -> ImageResponse:
152
+ """Replace a dull/overcast sky with a clear blue one. (``/sky_blue``)"""
153
+ return self._image(
154
+ self._post("/sky_blue", image_url=image_url, sky_style=sky_style)
155
+ )
156
+
157
+ def remove(self, image_url: str, mask_url: str) -> ImageResponse:
158
+ """Remove an object using a mask. (``/remove_object``)"""
159
+ return self._image(
160
+ self._post("/remove_object", image_url=image_url, mask_url=mask_url)
161
+ )
162
+
163
+ def blur(self, image_url: str, objects_to_blur: Any) -> ImageResponse:
164
+ """Blur objects (e.g. faces, license plates)."""
165
+ return self._image(
166
+ self._post("/blur", image_url=image_url, objects_to_blur=objects_to_blur)
167
+ )
168
+
169
+ def create_video(
170
+ self,
171
+ images: List[Dict[str, Any]],
172
+ *,
173
+ music: Optional[Dict[str, Any]] = None,
174
+ voice: Optional[Dict[str, Any]] = None,
175
+ branding: Optional[Dict[str, Any]] = None,
176
+ ending_title: Optional[str] = None,
177
+ ending_subtitle: Optional[str] = None,
178
+ is_vertical: Optional[bool] = None,
179
+ property_characteristics: Optional[List[Dict[str, Any]]] = None,
180
+ ) -> VideoResponse:
181
+ """Create a property video from a list of images.
182
+
183
+ Blocks server-side (up to ~10 min) and returns the finished video URL
184
+ inline. Each image dict accepts snake_case keys (``image_url``,
185
+ ``effect``, ``second_image_url``, ``subtitle``, ``title``,
186
+ ``watermark``, ``characteristics``). (``/create_video``)
187
+ """
188
+ data = self._post(
189
+ "/create_video",
190
+ images=images,
191
+ music=music,
192
+ voice=voice,
193
+ branding=branding,
194
+ ending_title=ending_title,
195
+ ending_subtitle=ending_subtitle,
196
+ is_vertical=is_vertical,
197
+ property_characteristics=property_characteristics,
198
+ )
199
+ return VideoResponse(
200
+ message=data.get("message"),
201
+ video_id=data.get("videoId", ""),
202
+ video_url=data.get("videoUrl", ""),
203
+ raw=data,
204
+ )
205
+
206
+ def credits(self) -> CreditsResponse:
207
+ """Read the account's remaining credits and plan. Never deducts credits."""
208
+ data = self._post("/credits")
209
+ return CreditsResponse(
210
+ plan=data.get("plan", "free"),
211
+ credits_remaining=int(data.get("creditsRemaining", 0) or 0),
212
+ raw=data,
213
+ )
214
+
215
+ def feedback(
216
+ self,
217
+ *,
218
+ image_url: Optional[str] = None,
219
+ image_id: Optional[str] = None,
220
+ vote: Optional[str] = None,
221
+ comment: Optional[str] = None,
222
+ credit_back: Optional[bool] = None,
223
+ ) -> FeedbackResponse:
224
+ """Submit thumbs up/down feedback on a generated image, with an optional
225
+ credit-back on a thumbs-down (subject to the API's eligibility rules).
226
+ Provide one of ``image_url`` or ``image_id``."""
227
+ data = self._post(
228
+ "/feedback",
229
+ image_url=image_url,
230
+ image_id=image_id,
231
+ vote=vote,
232
+ comment=comment,
233
+ credit_back=credit_back,
234
+ )
235
+ return FeedbackResponse(
236
+ message=data.get("message"),
237
+ credited_back=data.get("creditedBack", data.get("creditBack")),
238
+ raw=data,
239
+ )
240
+
241
+ # --- internals -----------------------------------------------------------
242
+
243
+ def _post(self, path: str, **params: Any) -> Dict[str, Any]:
244
+ # Drop unset optionals, then convert keys to the camelCase wire format.
245
+ body = _camelize({k: v for k, v in params.items() if v is not None})
246
+ body["apiKey"] = self.api_key
247
+ payload = json.dumps(body).encode("utf-8")
248
+
249
+ request = urllib.request.Request(
250
+ f"{self.base_url}{path}",
251
+ data=payload,
252
+ method="POST",
253
+ headers={
254
+ "Content-Type": "application/json",
255
+ "Accept": "application/json",
256
+ },
257
+ )
258
+
259
+ try:
260
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
261
+ status = response.getcode()
262
+ text = response.read().decode("utf-8")
263
+ except urllib.error.HTTPError as err: # non-2xx
264
+ text = err.read().decode("utf-8", errors="replace")
265
+ data = self._parse(text)
266
+ message = (
267
+ (data.get("error") or data.get("message"))
268
+ if isinstance(data, dict)
269
+ else None
270
+ ) or f"Request to {path} failed with status {err.code}"
271
+ raise PedraAPIError(message, status=err.code, body=data) from None
272
+ except urllib.error.URLError as err:
273
+ raise PedraError(f"Network error calling {path}: {err.reason}") from err
274
+ except TimeoutError as err:
275
+ raise PedraError(
276
+ f"Request to {path} timed out after {self.timeout}s"
277
+ ) from err
278
+
279
+ data = self._parse(text)
280
+ if data is None:
281
+ raise PedraError(f"Could not parse the response from {path}")
282
+
283
+ # Heartbeat caveat: a request that runs long and then fails returns HTTP
284
+ # 200 with an ``{"error": ...}`` body (the 200 header was already sent).
285
+ if isinstance(data, dict) and data.get("error"):
286
+ raise PedraAPIError(str(data["error"]), status=status, body=data)
287
+
288
+ return data
289
+
290
+ @staticmethod
291
+ def _parse(text: str) -> Any:
292
+ # json.loads tolerates the leading whitespace the heartbeat prepends.
293
+ if not text or not text.strip():
294
+ return None
295
+ try:
296
+ return json.loads(text)
297
+ except json.JSONDecodeError:
298
+ return None
299
+
300
+ @staticmethod
301
+ def _image(data: Dict[str, Any]) -> ImageResponse:
302
+ output = data.get("output")
303
+ urls: List[str] = []
304
+ if isinstance(output, list):
305
+ urls = [o["url"] for o in output if isinstance(o, dict) and o.get("url")]
306
+ elif isinstance(output, dict) and output.get("url"):
307
+ urls = [output["url"]]
308
+ return ImageResponse(message=data.get("message"), urls=urls, raw=data)
@@ -0,0 +1,32 @@
1
+ """Exceptions raised by the Pedra SDK."""
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class PedraError(Exception):
7
+ """Base class for every error raised by the SDK.
8
+
9
+ Catch this to handle any Pedra-originated failure (network, timeout, bad
10
+ input, or an API error).
11
+ """
12
+
13
+
14
+ class PedraAPIError(PedraError):
15
+ """Raised when the API returns an error.
16
+
17
+ ``status`` is the HTTP status code. Note: the Pedra API keeps long requests
18
+ alive with a heartbeat, so a generation that runs long and *then* fails
19
+ comes back as HTTP 200 with an ``{"error": ...}`` body — in that case
20
+ ``status`` is 200 but the call still raises this error. ``body`` is the
21
+ parsed JSON response when available.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ message: str,
27
+ status: Optional[int] = None,
28
+ body: Any = None,
29
+ ) -> None:
30
+ super().__init__(message)
31
+ self.status = status
32
+ self.body = body
@@ -0,0 +1,45 @@
1
+ """Typed response models returned by the SDK."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, List, Optional
5
+
6
+
7
+ @dataclass
8
+ class ImageResponse:
9
+ """Response from any image-generation endpoint.
10
+
11
+ The raw API returns ``output`` as either a list or a single object
12
+ depending on the endpoint; the SDK normalizes that into ``urls`` (and the
13
+ convenience ``url`` for the first result).
14
+ """
15
+
16
+ message: Optional[str]
17
+ urls: List[str]
18
+ raw: Any = field(repr=False, default=None)
19
+
20
+ @property
21
+ def url(self) -> Optional[str]:
22
+ """The first generated asset URL, or ``None`` if there were none."""
23
+ return self.urls[0] if self.urls else None
24
+
25
+
26
+ @dataclass
27
+ class VideoResponse:
28
+ message: Optional[str]
29
+ video_id: str
30
+ video_url: str
31
+ raw: Any = field(repr=False, default=None)
32
+
33
+
34
+ @dataclass
35
+ class CreditsResponse:
36
+ plan: str
37
+ credits_remaining: int
38
+ raw: Any = field(repr=False, default=None)
39
+
40
+
41
+ @dataclass
42
+ class FeedbackResponse:
43
+ message: Optional[str]
44
+ credited_back: Optional[bool]
45
+ raw: Any = field(repr=False, default=None)
File without changes
@@ -0,0 +1,127 @@
1
+ """Tests for the Pedra Python client.
2
+
3
+ Run with: python -m unittest discover -s tests
4
+ """
5
+
6
+ import json
7
+ import unittest
8
+ from unittest import mock
9
+
10
+ from pedra import Pedra, PedraAPIError, PedraError
11
+ from pedra.client import _camelize
12
+
13
+
14
+ class FakeResponse:
15
+ def __init__(self, text, status=200):
16
+ self._text = text
17
+ self._status = status
18
+
19
+ def read(self):
20
+ return self._text.encode("utf-8")
21
+
22
+ def getcode(self):
23
+ return self._status
24
+
25
+ def __enter__(self):
26
+ return self
27
+
28
+ def __exit__(self, *args):
29
+ return False
30
+
31
+
32
+ def patch_urlopen(text, status=200):
33
+ captured = {}
34
+
35
+ def fake_urlopen(request, timeout=None):
36
+ captured["url"] = request.full_url
37
+ captured["body"] = json.loads(request.data.decode("utf-8"))
38
+ captured["timeout"] = timeout
39
+ return FakeResponse(text, status)
40
+
41
+ return mock.patch("urllib.request.urlopen", fake_urlopen), captured
42
+
43
+
44
+ class ClientTests(unittest.TestCase):
45
+ def test_requires_api_key(self):
46
+ with mock.patch.dict("os.environ", {}, clear=True):
47
+ with self.assertRaises(PedraError):
48
+ Pedra()
49
+
50
+ def test_sends_apikey_and_camelcased_params(self):
51
+ patcher, captured = patch_urlopen(
52
+ json.dumps({"message": "ok", "output": [{"url": "https://x/1"}]})
53
+ )
54
+ with patcher:
55
+ pedra = Pedra("k")
56
+ res = pedra.furnish(image_url="https://img", room_type="Living room")
57
+
58
+ self.assertEqual(captured["url"], "https://app.pedra.ai/api/furnish")
59
+ self.assertEqual(
60
+ captured["body"],
61
+ {"apiKey": "k", "imageUrl": "https://img", "roomType": "Living room"},
62
+ )
63
+ self.assertEqual(res.url, "https://x/1")
64
+ self.assertEqual(res.urls, ["https://x/1"])
65
+
66
+ def test_drops_unset_optionals(self):
67
+ patcher, captured = patch_urlopen(json.dumps({"output": {"url": "u"}}))
68
+ with patcher:
69
+ Pedra("k").enhance(image_url="https://img")
70
+ self.assertNotIn("preserveOriginalFraming", captured["body"])
71
+
72
+ def test_normalizes_single_object_output(self):
73
+ patcher, _ = patch_urlopen(json.dumps({"output": {"url": "https://x/2"}}))
74
+ with patcher:
75
+ res = Pedra("k").edit_via_prompt(image_url="https://img", prompt="cozy")
76
+ self.assertEqual(res.url, "https://x/2")
77
+ self.assertEqual(res.urls, ["https://x/2"])
78
+
79
+ def test_tolerates_heartbeat_whitespace(self):
80
+ patcher, _ = patch_urlopen(" " + json.dumps({"output": [{"url": "https://x/3"}]}))
81
+ with patcher:
82
+ res = Pedra("k").enhance(image_url="https://img")
83
+ self.assertEqual(res.url, "https://x/3")
84
+
85
+ def test_raises_on_200_with_error_body(self):
86
+ patcher, _ = patch_urlopen(json.dumps({"error": "Video processing failed"}))
87
+ with patcher:
88
+ with self.assertRaises(PedraAPIError):
89
+ Pedra("k").create_video(images=[{"image_url": "https://img"}])
90
+
91
+ def test_credits(self):
92
+ patcher, _ = patch_urlopen(json.dumps({"plan": "pro", "creditsRemaining": 42}))
93
+ with patcher:
94
+ res = Pedra("k").credits()
95
+ self.assertEqual(res.plan, "pro")
96
+ self.assertEqual(res.credits_remaining, 42)
97
+
98
+ def test_create_video_deep_camelizes(self):
99
+ patcher, captured = patch_urlopen(
100
+ json.dumps({"videoId": "v1", "videoUrl": "https://v/1"})
101
+ )
102
+ with patcher:
103
+ res = Pedra("k").create_video(
104
+ images=[{"image_url": "https://a", "second_image_url": "https://b"}],
105
+ voice={"enabled": True, "audio_url": "https://aud"},
106
+ is_vertical=True,
107
+ )
108
+ self.assertEqual(captured["body"]["images"][0]["imageUrl"], "https://a")
109
+ self.assertEqual(captured["body"]["images"][0]["secondImageUrl"], "https://b")
110
+ self.assertEqual(captured["body"]["voice"]["audioUrl"], "https://aud")
111
+ self.assertTrue(captured["body"]["isVertical"])
112
+ self.assertEqual(res.video_url, "https://v/1")
113
+
114
+
115
+ class CamelizeTests(unittest.TestCase):
116
+ def test_to_camel_keys_only(self):
117
+ self.assertEqual(
118
+ _camelize({"image_url": "x", "preserve_original_framing": True}),
119
+ {"imageUrl": "x", "preserveOriginalFraming": True},
120
+ )
121
+
122
+ def test_drops_none_values(self):
123
+ self.assertEqual(_camelize({"a_b": None, "c_d": 1}), {"cD": 1})
124
+
125
+
126
+ if __name__ == "__main__":
127
+ unittest.main()