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.
- pedra-0.1.0/.github/workflows/ci.yml +19 -0
- pedra-0.1.0/.gitignore +11 -0
- pedra-0.1.0/CHANGELOG.md +10 -0
- pedra-0.1.0/LICENSE +21 -0
- pedra-0.1.0/PKG-INFO +197 -0
- pedra-0.1.0/PUBLISHING.md +46 -0
- pedra-0.1.0/README.md +171 -0
- pedra-0.1.0/pyproject.toml +48 -0
- pedra-0.1.0/src/pedra/__init__.py +29 -0
- pedra-0.1.0/src/pedra/client.py +308 -0
- pedra-0.1.0/src/pedra/errors.py +32 -0
- pedra-0.1.0/src/pedra/models.py +45 -0
- pedra-0.1.0/src/pedra/py.typed +0 -0
- pedra-0.1.0/tests/test_client.py +127 -0
|
@@ -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
pedra-0.1.0/CHANGELOG.md
ADDED
|
@@ -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
|
+
[](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
|
+
[](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()
|