tteg 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.
- tteg-0.1.0/PKG-INFO +74 -0
- tteg-0.1.0/README.md +55 -0
- tteg-0.1.0/pyproject.toml +37 -0
- tteg-0.1.0/setup.cfg +4 -0
- tteg-0.1.0/tests/test_preview.py +68 -0
- tteg-0.1.0/tests/test_unsplash.py +53 -0
- tteg-0.1.0/tteg/__init__.py +4 -0
- tteg-0.1.0/tteg/__main__.py +6 -0
- tteg-0.1.0/tteg/cli.py +109 -0
- tteg-0.1.0/tteg/models.py +33 -0
- tteg-0.1.0/tteg/preview.py +134 -0
- tteg-0.1.0/tteg/sources/__init__.py +4 -0
- tteg-0.1.0/tteg/sources/unsplash.py +89 -0
- tteg-0.1.0/tteg.egg-info/PKG-INFO +74 -0
- tteg-0.1.0/tteg.egg-info/SOURCES.txt +17 -0
- tteg-0.1.0/tteg.egg-info/dependency_links.txt +1 -0
- tteg-0.1.0/tteg.egg-info/entry_points.txt +2 -0
- tteg-0.1.0/tteg.egg-info/requires.txt +3 -0
- tteg-0.1.0/tteg.egg-info/top_level.txt +3 -0
tteg-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tteg
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent-friendly stock image search CLI. No rate-limit BS.
|
|
5
|
+
Author: Kushal
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/kushal/tteg
|
|
8
|
+
Keywords: unsplash,stock-images,cli,ai-agents,image-search
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: click>=8.1.7
|
|
17
|
+
Requires-Dist: requests>=2.32.0
|
|
18
|
+
Requires-Dist: Pillow>=10.0.0
|
|
19
|
+
|
|
20
|
+
# tteg
|
|
21
|
+
|
|
22
|
+
`tteg` is a Python CLI for agent-friendly stock-image search.
|
|
23
|
+
|
|
24
|
+
Current shape:
|
|
25
|
+
|
|
26
|
+
- one command
|
|
27
|
+
- Unsplash-backed search
|
|
28
|
+
- JSON output by default
|
|
29
|
+
- inline stitched preview as base64 JPEG
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
`tteg` reads environment variables from the shell and also loads a local `.env` file from the current working directory if present.
|
|
34
|
+
|
|
35
|
+
Supported keys:
|
|
36
|
+
|
|
37
|
+
- `UNSPLASH_ACCESS_KEY`
|
|
38
|
+
- `ACCESS_KEY` as a fallback alias
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
python3 -m tteg "mountain sunset"
|
|
44
|
+
python3 -m tteg "hero banner" --count 8 --orientation landscape --width 1920 --height 1080
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Example response shape:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"query": "mountain sunset",
|
|
52
|
+
"results": [
|
|
53
|
+
{
|
|
54
|
+
"id": "abc123",
|
|
55
|
+
"source": "unsplash",
|
|
56
|
+
"image_url": "https://images.unsplash.com/...",
|
|
57
|
+
"thumb_url": "https://images.unsplash.com/...",
|
|
58
|
+
"photographer": "Jane Doe",
|
|
59
|
+
"width": 4000,
|
|
60
|
+
"height": 3000,
|
|
61
|
+
"html_url": "https://unsplash.com/photos/...",
|
|
62
|
+
"download_location": "https://api.unsplash.com/photos/.../download"
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"preview": {
|
|
66
|
+
"mime_type": "image/jpeg",
|
|
67
|
+
"width": 320,
|
|
68
|
+
"height": 1108,
|
|
69
|
+
"data_base64": "..."
|
|
70
|
+
},
|
|
71
|
+
"warnings": []
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
tteg-0.1.0/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# tteg
|
|
2
|
+
|
|
3
|
+
`tteg` is a Python CLI for agent-friendly stock-image search.
|
|
4
|
+
|
|
5
|
+
Current shape:
|
|
6
|
+
|
|
7
|
+
- one command
|
|
8
|
+
- Unsplash-backed search
|
|
9
|
+
- JSON output by default
|
|
10
|
+
- inline stitched preview as base64 JPEG
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
`tteg` reads environment variables from the shell and also loads a local `.env` file from the current working directory if present.
|
|
15
|
+
|
|
16
|
+
Supported keys:
|
|
17
|
+
|
|
18
|
+
- `UNSPLASH_ACCESS_KEY`
|
|
19
|
+
- `ACCESS_KEY` as a fallback alias
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
python3 -m tteg "mountain sunset"
|
|
25
|
+
python3 -m tteg "hero banner" --count 8 --orientation landscape --width 1920 --height 1080
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Example response shape:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"query": "mountain sunset",
|
|
33
|
+
"results": [
|
|
34
|
+
{
|
|
35
|
+
"id": "abc123",
|
|
36
|
+
"source": "unsplash",
|
|
37
|
+
"image_url": "https://images.unsplash.com/...",
|
|
38
|
+
"thumb_url": "https://images.unsplash.com/...",
|
|
39
|
+
"photographer": "Jane Doe",
|
|
40
|
+
"width": 4000,
|
|
41
|
+
"height": 3000,
|
|
42
|
+
"html_url": "https://unsplash.com/photos/...",
|
|
43
|
+
"download_location": "https://api.unsplash.com/photos/.../download"
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"preview": {
|
|
47
|
+
"mime_type": "image/jpeg",
|
|
48
|
+
"width": 320,
|
|
49
|
+
"height": 1108,
|
|
50
|
+
"data_base64": "..."
|
|
51
|
+
},
|
|
52
|
+
"warnings": []
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tteg"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Agent-friendly stock image search CLI. No rate-limit BS."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "Kushal"}]
|
|
13
|
+
keywords = ["unsplash", "stock-images", "cli", "ai-agents", "image-search"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Multimedia :: Graphics",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"click>=8.1.7",
|
|
23
|
+
"requests>=2.32.0",
|
|
24
|
+
"Pillow>=10.0.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/kushal/tteg"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
tteg = "tteg.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools]
|
|
34
|
+
include-package-data = true
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["."]
|
tteg-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import io
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
from tteg.models import ImageResult
|
|
10
|
+
from tteg.preview import build_preview
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _make_image_bytes(color: str, size: tuple[int, int] = (120, 80)) -> bytes:
|
|
14
|
+
image = Image.new("RGB", size, color)
|
|
15
|
+
output = io.BytesIO()
|
|
16
|
+
image.save(output, format="JPEG")
|
|
17
|
+
return output.getvalue()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PreviewTests(unittest.TestCase):
|
|
21
|
+
def test_build_preview_returns_base64_jpeg(self) -> None:
|
|
22
|
+
results = [
|
|
23
|
+
ImageResult(
|
|
24
|
+
id="1",
|
|
25
|
+
source="unsplash",
|
|
26
|
+
image_url="https://example.com/full-1.jpg",
|
|
27
|
+
thumb_url="https://example.com/thumb-1.jpg",
|
|
28
|
+
photographer="Jane",
|
|
29
|
+
width=1200,
|
|
30
|
+
height=800,
|
|
31
|
+
title="First image",
|
|
32
|
+
html_url="https://example.com/page-1",
|
|
33
|
+
download_location="https://example.com/download-1",
|
|
34
|
+
),
|
|
35
|
+
ImageResult(
|
|
36
|
+
id="2",
|
|
37
|
+
source="unsplash",
|
|
38
|
+
image_url="https://example.com/full-2.jpg",
|
|
39
|
+
thumb_url="https://example.com/thumb-2.jpg",
|
|
40
|
+
photographer="John",
|
|
41
|
+
width=1400,
|
|
42
|
+
height=900,
|
|
43
|
+
title="Second image",
|
|
44
|
+
html_url="https://example.com/page-2",
|
|
45
|
+
download_location="https://example.com/download-2",
|
|
46
|
+
),
|
|
47
|
+
]
|
|
48
|
+
image_map = {
|
|
49
|
+
"https://example.com/thumb-1.jpg": _make_image_bytes("#cc5500"),
|
|
50
|
+
"https://example.com/thumb-2.jpg": _make_image_bytes("#0055cc"),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
built = build_preview(results, downloader=image_map.__getitem__)
|
|
54
|
+
|
|
55
|
+
self.assertEqual(built.warnings, [])
|
|
56
|
+
self.assertIsNotNone(built.preview)
|
|
57
|
+
assert built.preview is not None
|
|
58
|
+
self.assertEqual(built.preview.mime_type, "image/jpeg")
|
|
59
|
+
decoded = base64.b64decode(built.preview.data_base64)
|
|
60
|
+
image = Image.open(io.BytesIO(decoded))
|
|
61
|
+
self.assertEqual(image.format, "JPEG")
|
|
62
|
+
self.assertGreater(image.width, 0)
|
|
63
|
+
self.assertGreater(image.height, 0)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
unittest.main()
|
|
68
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from tteg.sources.unsplash import _build_image_url, _normalize_result
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UnsplashTests(unittest.TestCase):
|
|
9
|
+
def test_build_image_url_applies_size_params(self) -> None:
|
|
10
|
+
actual = _build_image_url(
|
|
11
|
+
"https://images.unsplash.com/photo-123",
|
|
12
|
+
"https://images.unsplash.com/fallback",
|
|
13
|
+
1920,
|
|
14
|
+
1080,
|
|
15
|
+
)
|
|
16
|
+
self.assertEqual(
|
|
17
|
+
actual,
|
|
18
|
+
"https://images.unsplash.com/photo-123?w=1920&h=1080&fit=crop&q=80",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def test_normalize_result_keeps_compliance_fields(self) -> None:
|
|
22
|
+
photo = {
|
|
23
|
+
"id": "abc",
|
|
24
|
+
"description": "Golden hour",
|
|
25
|
+
"width": 4000,
|
|
26
|
+
"height": 3000,
|
|
27
|
+
"urls": {
|
|
28
|
+
"raw": "https://images.unsplash.com/raw",
|
|
29
|
+
"regular": "https://images.unsplash.com/regular",
|
|
30
|
+
"thumb": "https://images.unsplash.com/thumb",
|
|
31
|
+
},
|
|
32
|
+
"links": {
|
|
33
|
+
"html": "https://unsplash.com/photos/abc",
|
|
34
|
+
"download_location": "https://api.unsplash.com/photos/abc/download",
|
|
35
|
+
},
|
|
36
|
+
"user": {"name": "Jane Doe"},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
normalized = _normalize_result(photo, 1600, 900)
|
|
40
|
+
|
|
41
|
+
self.assertEqual(normalized.id, "abc")
|
|
42
|
+
self.assertEqual(normalized.photographer, "Jane Doe")
|
|
43
|
+
self.assertEqual(normalized.html_url, "https://unsplash.com/photos/abc")
|
|
44
|
+
self.assertEqual(
|
|
45
|
+
normalized.download_location,
|
|
46
|
+
"https://api.unsplash.com/photos/abc/download",
|
|
47
|
+
)
|
|
48
|
+
self.assertIn("w=1600", normalized.image_url)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
unittest.main()
|
|
53
|
+
|
tteg-0.1.0/tteg/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from tteg.preview import build_preview
|
|
11
|
+
from tteg.sources import search_unsplash
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _load_env_file() -> None:
|
|
15
|
+
candidates = [
|
|
16
|
+
Path.cwd() / ".env",
|
|
17
|
+
Path.home() / ".config" / "tteg" / ".env",
|
|
18
|
+
Path.home() / ".tteg.env",
|
|
19
|
+
]
|
|
20
|
+
for env_path in candidates:
|
|
21
|
+
if not env_path.exists():
|
|
22
|
+
continue
|
|
23
|
+
for line in env_path.read_text(encoding="utf-8").splitlines():
|
|
24
|
+
stripped = line.strip()
|
|
25
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
26
|
+
continue
|
|
27
|
+
key, value = stripped.split("=", 1)
|
|
28
|
+
key = key.strip()
|
|
29
|
+
value = value.strip().strip('"').strip("'")
|
|
30
|
+
if key and key not in os.environ:
|
|
31
|
+
os.environ[key] = value
|
|
32
|
+
break # use first env file found
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_access_key() -> str:
|
|
36
|
+
return os.environ.get("UNSPLASH_ACCESS_KEY") or os.environ.get("ACCESS_KEY") or ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _response_payload(
|
|
40
|
+
*,
|
|
41
|
+
query: str,
|
|
42
|
+
results: list[Any],
|
|
43
|
+
preview_enabled: bool,
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
payload: dict[str, Any] = {
|
|
46
|
+
"query": query,
|
|
47
|
+
"results": [item.to_dict() for item in results],
|
|
48
|
+
"preview": None,
|
|
49
|
+
"warnings": [],
|
|
50
|
+
}
|
|
51
|
+
if not preview_enabled:
|
|
52
|
+
return payload
|
|
53
|
+
|
|
54
|
+
preview_result = build_preview(results)
|
|
55
|
+
if preview_result.preview is not None:
|
|
56
|
+
payload["preview"] = preview_result.preview.to_dict()
|
|
57
|
+
if preview_result.warnings:
|
|
58
|
+
payload["warnings"].extend(preview_result.warnings)
|
|
59
|
+
return payload
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
63
|
+
@click.argument("query")
|
|
64
|
+
@click.option("-n", "--count", default=5, show_default=True, type=click.IntRange(1, 10))
|
|
65
|
+
@click.option(
|
|
66
|
+
"--orientation",
|
|
67
|
+
type=click.Choice(["any", "landscape", "portrait", "square"], case_sensitive=False),
|
|
68
|
+
default="any",
|
|
69
|
+
show_default=True,
|
|
70
|
+
)
|
|
71
|
+
@click.option("--width", type=click.IntRange(1, 10000), default=None)
|
|
72
|
+
@click.option("--height", type=click.IntRange(1, 10000), default=None)
|
|
73
|
+
@click.option("--preview/--no-preview", default=False, show_default=True)
|
|
74
|
+
def main(
|
|
75
|
+
query: str,
|
|
76
|
+
count: int,
|
|
77
|
+
orientation: str,
|
|
78
|
+
width: int | None,
|
|
79
|
+
height: int | None,
|
|
80
|
+
preview: bool,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Search stock images and return URLs plus an inline preview artifact."""
|
|
83
|
+
_load_env_file()
|
|
84
|
+
access_key = _resolve_access_key()
|
|
85
|
+
if not access_key:
|
|
86
|
+
click.echo("Error: No Unsplash API key found.", err=True)
|
|
87
|
+
click.echo("Set UNSPLASH_ACCESS_KEY env var or put it in .env / ~/.config/tteg/.env", err=True)
|
|
88
|
+
raise SystemExit(1)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
results = search_unsplash(
|
|
92
|
+
access_key=access_key,
|
|
93
|
+
query=query,
|
|
94
|
+
count=count,
|
|
95
|
+
orientation=orientation,
|
|
96
|
+
width=width,
|
|
97
|
+
height=height,
|
|
98
|
+
)
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
click.echo(f"Error: {exc}", err=True)
|
|
101
|
+
raise SystemExit(1)
|
|
102
|
+
|
|
103
|
+
payload = _response_payload(query=query, results=results, preview_enabled=preview)
|
|
104
|
+
click.echo(json.dumps(payload, indent=2))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
main()
|
|
109
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class ImageResult:
|
|
9
|
+
id: str
|
|
10
|
+
source: str
|
|
11
|
+
image_url: str
|
|
12
|
+
thumb_url: str
|
|
13
|
+
photographer: str | None
|
|
14
|
+
width: int | None
|
|
15
|
+
height: int | None
|
|
16
|
+
title: str | None
|
|
17
|
+
html_url: str | None
|
|
18
|
+
download_location: str | None
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict[str, Any]:
|
|
21
|
+
return asdict(self)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class PreviewPayload:
|
|
26
|
+
mime_type: str
|
|
27
|
+
width: int
|
|
28
|
+
height: int
|
|
29
|
+
data_base64: str
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict[str, Any]:
|
|
32
|
+
return asdict(self)
|
|
33
|
+
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import io
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
10
|
+
|
|
11
|
+
from tteg.models import ImageResult, PreviewPayload
|
|
12
|
+
|
|
13
|
+
Downloader = Callable[[str], bytes]
|
|
14
|
+
|
|
15
|
+
CARD_WIDTH = 320
|
|
16
|
+
CARD_PADDING = 12
|
|
17
|
+
LABEL_SIZE = 30
|
|
18
|
+
LABEL_MARGIN = 10
|
|
19
|
+
BACKGROUND_COLOR = "#f4f1eb"
|
|
20
|
+
CARD_BACKGROUND = "#ffffff"
|
|
21
|
+
LABEL_FILL = "#111111"
|
|
22
|
+
LABEL_TEXT = "#ffffff"
|
|
23
|
+
TEXT_COLOR = "#1b1b1b"
|
|
24
|
+
JPEG_QUALITY = 78
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class PreviewBuildResult:
|
|
29
|
+
preview: PreviewPayload | None
|
|
30
|
+
warnings: list[str]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _default_downloader(url: str) -> bytes:
|
|
34
|
+
response = requests.get(url, timeout=15)
|
|
35
|
+
response.raise_for_status()
|
|
36
|
+
return response.content
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _open_and_resize(image_bytes: bytes) -> Image.Image:
|
|
40
|
+
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
|
41
|
+
scale = CARD_WIDTH / image.width
|
|
42
|
+
target_height = max(1, int(image.height * scale))
|
|
43
|
+
return image.resize((CARD_WIDTH, target_height))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _measure_text(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> tuple[int, int]:
|
|
47
|
+
left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
|
|
48
|
+
return right - left, bottom - top
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_preview(
|
|
52
|
+
results: list[ImageResult],
|
|
53
|
+
*,
|
|
54
|
+
downloader: Downloader | None = None,
|
|
55
|
+
) -> PreviewBuildResult:
|
|
56
|
+
if not results:
|
|
57
|
+
return PreviewBuildResult(preview=None, warnings=["No results to preview."])
|
|
58
|
+
|
|
59
|
+
get_bytes = downloader or _default_downloader
|
|
60
|
+
font = ImageFont.load_default()
|
|
61
|
+
prepared: list[tuple[int, Image.Image, str | None]] = []
|
|
62
|
+
warnings: list[str] = []
|
|
63
|
+
|
|
64
|
+
for index, result in enumerate(results, start=1):
|
|
65
|
+
if not result.thumb_url:
|
|
66
|
+
warnings.append(f"Missing thumbnail URL for result {index}.")
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
prepared.append((index, _open_and_resize(get_bytes(result.thumb_url)), result.title))
|
|
70
|
+
except Exception as exc: # pragma: no cover - exercised through warning behavior
|
|
71
|
+
warnings.append(f"Failed to build preview tile {index}: {exc}")
|
|
72
|
+
|
|
73
|
+
if not prepared:
|
|
74
|
+
return PreviewBuildResult(preview=None, warnings=warnings or ["No preview tiles could be downloaded."])
|
|
75
|
+
|
|
76
|
+
title_height = 18 # approximate height for title text line
|
|
77
|
+
heights = [
|
|
78
|
+
image.height + (CARD_PADDING * 2) + (title_height if title else 0)
|
|
79
|
+
for _, image, title in prepared
|
|
80
|
+
]
|
|
81
|
+
canvas_width = CARD_WIDTH + (CARD_PADDING * 2)
|
|
82
|
+
canvas_height = sum(heights) + (CARD_PADDING * (len(prepared) + 1))
|
|
83
|
+
canvas = Image.new("RGB", (canvas_width, canvas_height), BACKGROUND_COLOR)
|
|
84
|
+
draw = ImageDraw.Draw(canvas)
|
|
85
|
+
|
|
86
|
+
cursor_y = CARD_PADDING
|
|
87
|
+
for index, image, title in prepared:
|
|
88
|
+
card_left = CARD_PADDING
|
|
89
|
+
card_top = cursor_y
|
|
90
|
+
card_bottom = card_top + image.height + (CARD_PADDING * 2)
|
|
91
|
+
draw.rounded_rectangle(
|
|
92
|
+
[(card_left, card_top), (card_left + CARD_WIDTH + (CARD_PADDING * 2), card_bottom)],
|
|
93
|
+
radius=16,
|
|
94
|
+
fill=CARD_BACKGROUND,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
image_x = card_left + CARD_PADDING
|
|
98
|
+
image_y = card_top + CARD_PADDING
|
|
99
|
+
canvas.paste(image, (image_x, image_y))
|
|
100
|
+
|
|
101
|
+
label_left = image_x + LABEL_MARGIN
|
|
102
|
+
label_top = image_y + LABEL_MARGIN
|
|
103
|
+
label_box = (label_left, label_top, label_left + LABEL_SIZE, label_top + LABEL_SIZE)
|
|
104
|
+
draw.rounded_rectangle(label_box, radius=12, fill=LABEL_FILL)
|
|
105
|
+
label_text = str(index)
|
|
106
|
+
text_width, text_height = _measure_text(draw, label_text, font)
|
|
107
|
+
draw.text(
|
|
108
|
+
(
|
|
109
|
+
label_left + ((LABEL_SIZE - text_width) / 2),
|
|
110
|
+
label_top + ((LABEL_SIZE - text_height) / 2) - 1,
|
|
111
|
+
),
|
|
112
|
+
label_text,
|
|
113
|
+
font=font,
|
|
114
|
+
fill=LABEL_TEXT,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if title:
|
|
118
|
+
trimmed = title[:60]
|
|
119
|
+
text_y = image_y + image.height + 4
|
|
120
|
+
draw.text((image_x, text_y), trimmed, font=font, fill=TEXT_COLOR)
|
|
121
|
+
|
|
122
|
+
cursor_y = card_bottom + CARD_PADDING
|
|
123
|
+
|
|
124
|
+
output = io.BytesIO()
|
|
125
|
+
canvas.save(output, format="JPEG", quality=JPEG_QUALITY, optimize=True)
|
|
126
|
+
encoded = base64.b64encode(output.getvalue()).decode("ascii")
|
|
127
|
+
preview = PreviewPayload(
|
|
128
|
+
mime_type="image/jpeg",
|
|
129
|
+
width=canvas.width,
|
|
130
|
+
height=canvas.height,
|
|
131
|
+
data_base64=encoded,
|
|
132
|
+
)
|
|
133
|
+
return PreviewBuildResult(preview=preview, warnings=warnings)
|
|
134
|
+
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from tteg.models import ImageResult
|
|
8
|
+
|
|
9
|
+
UNSPLASH_SEARCH_URL = "https://api.unsplash.com/search/photos"
|
|
10
|
+
DEFAULT_TIMEOUT_SECONDS = 15
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _build_image_url(raw_url: str | None, fallback_url: str | None, width: int | None, height: int | None) -> str:
|
|
14
|
+
if width is None and height is None:
|
|
15
|
+
return fallback_url or raw_url or ""
|
|
16
|
+
|
|
17
|
+
if not raw_url:
|
|
18
|
+
return fallback_url or ""
|
|
19
|
+
|
|
20
|
+
params: list[str] = []
|
|
21
|
+
if width is not None:
|
|
22
|
+
params.append(f"w={width}")
|
|
23
|
+
if height is not None:
|
|
24
|
+
params.append(f"h={height}")
|
|
25
|
+
if width is not None and height is not None:
|
|
26
|
+
params.append("fit=crop")
|
|
27
|
+
else:
|
|
28
|
+
params.append("fit=max")
|
|
29
|
+
params.append("q=80")
|
|
30
|
+
separator = "&" if "?" in raw_url else "?"
|
|
31
|
+
return f"{raw_url}{separator}{'&'.join(params)}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _normalize_result(photo: dict[str, Any], width: int | None, height: int | None) -> ImageResult:
|
|
35
|
+
urls = photo.get("urls") or {}
|
|
36
|
+
links = photo.get("links") or {}
|
|
37
|
+
user = photo.get("user") or {}
|
|
38
|
+
title = photo.get("description") or photo.get("alt_description")
|
|
39
|
+
|
|
40
|
+
return ImageResult(
|
|
41
|
+
id=str(photo.get("id", "")),
|
|
42
|
+
source="unsplash",
|
|
43
|
+
image_url=_build_image_url(urls.get("raw"), urls.get("regular"), width, height),
|
|
44
|
+
thumb_url=urls.get("thumb") or urls.get("small") or urls.get("regular") or "",
|
|
45
|
+
photographer=user.get("name"),
|
|
46
|
+
width=photo.get("width"),
|
|
47
|
+
height=photo.get("height"),
|
|
48
|
+
title=title,
|
|
49
|
+
html_url=links.get("html"),
|
|
50
|
+
download_location=links.get("download_location"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def search_unsplash(
|
|
55
|
+
*,
|
|
56
|
+
access_key: str,
|
|
57
|
+
query: str,
|
|
58
|
+
count: int,
|
|
59
|
+
orientation: str | None = None,
|
|
60
|
+
width: int | None = None,
|
|
61
|
+
height: int | None = None,
|
|
62
|
+
session: requests.Session | None = None,
|
|
63
|
+
) -> list[ImageResult]:
|
|
64
|
+
if not access_key:
|
|
65
|
+
raise ValueError("Missing Unsplash access key. Set UNSPLASH_ACCESS_KEY or ACCESS_KEY.")
|
|
66
|
+
|
|
67
|
+
client = session or requests.Session()
|
|
68
|
+
params: dict[str, Any] = {
|
|
69
|
+
"query": query,
|
|
70
|
+
"per_page": max(1, min(count, 30)),
|
|
71
|
+
}
|
|
72
|
+
if orientation and orientation != "any":
|
|
73
|
+
params["orientation"] = "squarish" if orientation == "square" else orientation
|
|
74
|
+
|
|
75
|
+
response = client.get(
|
|
76
|
+
UNSPLASH_SEARCH_URL,
|
|
77
|
+
params=params,
|
|
78
|
+
headers={
|
|
79
|
+
"Authorization": f"Client-ID {access_key}",
|
|
80
|
+
"Accept-Version": "v1",
|
|
81
|
+
"User-Agent": "tteg/0.1.0",
|
|
82
|
+
},
|
|
83
|
+
timeout=DEFAULT_TIMEOUT_SECONDS,
|
|
84
|
+
)
|
|
85
|
+
response.raise_for_status()
|
|
86
|
+
payload = response.json()
|
|
87
|
+
raw_results = payload.get("results") or []
|
|
88
|
+
return [_normalize_result(item, width, height) for item in raw_results if item.get("urls")]
|
|
89
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tteg
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent-friendly stock image search CLI. No rate-limit BS.
|
|
5
|
+
Author: Kushal
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/kushal/tteg
|
|
8
|
+
Keywords: unsplash,stock-images,cli,ai-agents,image-search
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: click>=8.1.7
|
|
17
|
+
Requires-Dist: requests>=2.32.0
|
|
18
|
+
Requires-Dist: Pillow>=10.0.0
|
|
19
|
+
|
|
20
|
+
# tteg
|
|
21
|
+
|
|
22
|
+
`tteg` is a Python CLI for agent-friendly stock-image search.
|
|
23
|
+
|
|
24
|
+
Current shape:
|
|
25
|
+
|
|
26
|
+
- one command
|
|
27
|
+
- Unsplash-backed search
|
|
28
|
+
- JSON output by default
|
|
29
|
+
- inline stitched preview as base64 JPEG
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
`tteg` reads environment variables from the shell and also loads a local `.env` file from the current working directory if present.
|
|
34
|
+
|
|
35
|
+
Supported keys:
|
|
36
|
+
|
|
37
|
+
- `UNSPLASH_ACCESS_KEY`
|
|
38
|
+
- `ACCESS_KEY` as a fallback alias
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
python3 -m tteg "mountain sunset"
|
|
44
|
+
python3 -m tteg "hero banner" --count 8 --orientation landscape --width 1920 --height 1080
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Example response shape:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"query": "mountain sunset",
|
|
52
|
+
"results": [
|
|
53
|
+
{
|
|
54
|
+
"id": "abc123",
|
|
55
|
+
"source": "unsplash",
|
|
56
|
+
"image_url": "https://images.unsplash.com/...",
|
|
57
|
+
"thumb_url": "https://images.unsplash.com/...",
|
|
58
|
+
"photographer": "Jane Doe",
|
|
59
|
+
"width": 4000,
|
|
60
|
+
"height": 3000,
|
|
61
|
+
"html_url": "https://unsplash.com/photos/...",
|
|
62
|
+
"download_location": "https://api.unsplash.com/photos/.../download"
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"preview": {
|
|
66
|
+
"mime_type": "image/jpeg",
|
|
67
|
+
"width": 320,
|
|
68
|
+
"height": 1108,
|
|
69
|
+
"data_base64": "..."
|
|
70
|
+
},
|
|
71
|
+
"warnings": []
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tests/test_preview.py
|
|
4
|
+
tests/test_unsplash.py
|
|
5
|
+
tteg/__init__.py
|
|
6
|
+
tteg/__main__.py
|
|
7
|
+
tteg/cli.py
|
|
8
|
+
tteg/models.py
|
|
9
|
+
tteg/preview.py
|
|
10
|
+
tteg.egg-info/PKG-INFO
|
|
11
|
+
tteg.egg-info/SOURCES.txt
|
|
12
|
+
tteg.egg-info/dependency_links.txt
|
|
13
|
+
tteg.egg-info/entry_points.txt
|
|
14
|
+
tteg.egg-info/requires.txt
|
|
15
|
+
tteg.egg-info/top_level.txt
|
|
16
|
+
tteg/sources/__init__.py
|
|
17
|
+
tteg/sources/unsplash.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|