cli-web-pexels 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.
Files changed (33) hide show
  1. cli_web_pexels-0.1.0/PKG-INFO +11 -0
  2. cli_web_pexels-0.1.0/cli_web/pexels/README.md +83 -0
  3. cli_web_pexels-0.1.0/cli_web/pexels/__init__.py +3 -0
  4. cli_web_pexels-0.1.0/cli_web/pexels/__main__.py +6 -0
  5. cli_web_pexels-0.1.0/cli_web/pexels/commands/__init__.py +0 -0
  6. cli_web_pexels-0.1.0/cli_web/pexels/commands/collections.py +63 -0
  7. cli_web_pexels-0.1.0/cli_web/pexels/commands/photos.py +116 -0
  8. cli_web_pexels-0.1.0/cli_web/pexels/commands/users.py +62 -0
  9. cli_web_pexels-0.1.0/cli_web/pexels/commands/videos.py +162 -0
  10. cli_web_pexels-0.1.0/cli_web/pexels/core/__init__.py +0 -0
  11. cli_web_pexels-0.1.0/cli_web/pexels/core/client.py +299 -0
  12. cli_web_pexels-0.1.0/cli_web/pexels/core/exceptions.py +54 -0
  13. cli_web_pexels-0.1.0/cli_web/pexels/core/models.py +213 -0
  14. cli_web_pexels-0.1.0/cli_web/pexels/pexels_cli.py +139 -0
  15. cli_web_pexels-0.1.0/cli_web/pexels/skills/SKILL.md +105 -0
  16. cli_web_pexels-0.1.0/cli_web/pexels/tests/TEST.md +130 -0
  17. cli_web_pexels-0.1.0/cli_web/pexels/tests/__init__.py +0 -0
  18. cli_web_pexels-0.1.0/cli_web/pexels/tests/test_core.py +326 -0
  19. cli_web_pexels-0.1.0/cli_web/pexels/tests/test_e2e.py +168 -0
  20. cli_web_pexels-0.1.0/cli_web/pexels/utils/__init__.py +0 -0
  21. cli_web_pexels-0.1.0/cli_web/pexels/utils/doctor.py +188 -0
  22. cli_web_pexels-0.1.0/cli_web/pexels/utils/helpers.py +42 -0
  23. cli_web_pexels-0.1.0/cli_web/pexels/utils/mcp_server.py +290 -0
  24. cli_web_pexels-0.1.0/cli_web/pexels/utils/output.py +139 -0
  25. cli_web_pexels-0.1.0/cli_web/pexels/utils/repl_skin.py +486 -0
  26. cli_web_pexels-0.1.0/cli_web_pexels.egg-info/PKG-INFO +11 -0
  27. cli_web_pexels-0.1.0/cli_web_pexels.egg-info/SOURCES.txt +31 -0
  28. cli_web_pexels-0.1.0/cli_web_pexels.egg-info/dependency_links.txt +1 -0
  29. cli_web_pexels-0.1.0/cli_web_pexels.egg-info/entry_points.txt +2 -0
  30. cli_web_pexels-0.1.0/cli_web_pexels.egg-info/requires.txt +3 -0
  31. cli_web_pexels-0.1.0/cli_web_pexels.egg-info/top_level.txt +1 -0
  32. cli_web_pexels-0.1.0/setup.cfg +4 -0
  33. cli_web_pexels-0.1.0/setup.py +24 -0
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: cli-web-pexels
3
+ Version: 0.1.0
4
+ Summary: CLI for Pexels free stock photos and videos
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: click>=8.0
7
+ Requires-Dist: curl_cffi>=0.5
8
+ Requires-Dist: prompt_toolkit>=3.0
9
+ Dynamic: requires-dist
10
+ Dynamic: requires-python
11
+ Dynamic: summary
@@ -0,0 +1,83 @@
1
+ # cli-web-pexels
2
+
3
+ CLI for [Pexels](https://www.pexels.com/) — free stock photos and videos from the command line.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ cd pexels/agent-harness
9
+ pip install -e .
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ### Search Photos
15
+
16
+ ```bash
17
+ cli-web-pexels photos search "nature"
18
+ cli-web-pexels photos search "sunset" --orientation landscape --size large --json
19
+ ```
20
+
21
+ ### Get Photo Details
22
+
23
+ ```bash
24
+ cli-web-pexels photos get green-leaves-1072179
25
+ cli-web-pexels photos get green-leaves-1072179 --json
26
+ ```
27
+
28
+ ### Download Photos
29
+
30
+ ```bash
31
+ cli-web-pexels photos download green-leaves-1072179
32
+ cli-web-pexels photos download green-leaves-1072179 --size large --output photo.jpg
33
+ ```
34
+
35
+ ### Search Videos
36
+
37
+ ```bash
38
+ cli-web-pexels videos search "ocean"
39
+ cli-web-pexels videos search "nature" --orientation landscape --json
40
+ ```
41
+
42
+ ### Download Videos
43
+
44
+ ```bash
45
+ cli-web-pexels videos download long-narrow-road-856479 --quality hd
46
+ ```
47
+
48
+ ### User Profiles
49
+
50
+ ```bash
51
+ cli-web-pexels users get pixabay
52
+ cli-web-pexels users media pixabay --json
53
+ ```
54
+
55
+ ### Collections
56
+
57
+ ```bash
58
+ cli-web-pexels collections discover
59
+ cli-web-pexels collections get spring-aesthetic-fvku5ng --json
60
+ ```
61
+
62
+ ### REPL Mode
63
+
64
+ ```bash
65
+ cli-web-pexels # enters interactive REPL
66
+ cli-web-pexels --json # REPL with JSON output
67
+ ```
68
+
69
+ ## Search Filters
70
+
71
+ | Filter | Options | Example |
72
+ |--------|---------|---------|
73
+ | `--orientation` | landscape, portrait, square | `--orientation landscape` |
74
+ | `--size` | large, medium, small | `--size large` |
75
+ | `--color` | hex or named color | `--color orange` |
76
+ | `--page` | page number (1-based) | `--page 2` |
77
+
78
+ ## Running Tests
79
+
80
+ ```bash
81
+ cd pexels/agent-harness
82
+ python -m pytest cli_web/pexels/tests/ -v
83
+ ```
@@ -0,0 +1,3 @@
1
+ """cli-web-pexels — CLI for Pexels free stock photos and videos."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow running as python -m cli_web.pexels."""
2
+
3
+ from cli_web.pexels.pexels_cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,63 @@
1
+ """Collections commands for cli-web-pexels."""
2
+
3
+ import click
4
+
5
+ from ..core.client import PexelsClient
6
+ from ..utils.helpers import handle_errors
7
+ from ..utils.output import (
8
+ print_collection_detail,
9
+ print_collections_table,
10
+ print_json,
11
+ print_pagination,
12
+ print_photos_table,
13
+ )
14
+
15
+
16
+ @click.group("collections")
17
+ @click.pass_context
18
+ def collections(ctx):
19
+ """Browse and explore Pexels collections."""
20
+ pass
21
+
22
+
23
+ @collections.command()
24
+ @click.argument("slug")
25
+ @click.option("--page", type=int, default=1, help="Page number.")
26
+ @click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
27
+ @click.pass_context
28
+ def get(ctx, slug, page, json_mode):
29
+ """Get collection detail and media by slug."""
30
+ json_mode = json_mode or ctx.obj.get("json", False)
31
+ with handle_errors(json_mode):
32
+ client = PexelsClient()
33
+ result = client.get_collection(slug, page=page)
34
+ if json_mode:
35
+ print_json(result)
36
+ else:
37
+ print_collection_detail(result["collection"])
38
+ media = result.get("media", {})
39
+ if media.get("data"):
40
+ print_photos_table(media["data"])
41
+ print_pagination(media.get("pagination", {}))
42
+
43
+
44
+ @collections.command()
45
+ @click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
46
+ @click.pass_context
47
+ def discover(ctx, json_mode):
48
+ """Show popular and curated collections from the discover page."""
49
+ json_mode = json_mode or ctx.obj.get("json", False)
50
+ with handle_errors(json_mode):
51
+ client = PexelsClient()
52
+ result = client.discover()
53
+ if json_mode:
54
+ print_json(result)
55
+ else:
56
+ if result.get("popular"):
57
+ click.echo("\n Popular Collections")
58
+ click.echo(f" {'─' * 40}")
59
+ print_collections_table(result["popular"])
60
+ if result.get("collections"):
61
+ click.echo("\n Curated Collections")
62
+ click.echo(f" {'─' * 40}")
63
+ print_collections_table(result["collections"])
@@ -0,0 +1,116 @@
1
+ """Photos commands for cli-web-pexels."""
2
+
3
+ import os
4
+
5
+ import click
6
+
7
+ from ..core.client import PexelsClient
8
+ from ..core.exceptions import NotFoundError
9
+ from ..utils.helpers import handle_errors, sanitize_filename
10
+ from ..utils.output import (
11
+ print_json,
12
+ print_pagination,
13
+ print_photo_detail,
14
+ print_photos_table,
15
+ )
16
+
17
+
18
+ @click.group("photos")
19
+ @click.pass_context
20
+ def photos(ctx):
21
+ """Browse and download Pexels photos."""
22
+ pass
23
+
24
+
25
+ @photos.command()
26
+ @click.argument("query")
27
+ @click.option(
28
+ "--orientation",
29
+ type=click.Choice(["landscape", "portrait", "square"]),
30
+ default=None,
31
+ help="Filter by orientation.",
32
+ )
33
+ @click.option(
34
+ "--size",
35
+ type=click.Choice(["large", "medium", "small"]),
36
+ default=None,
37
+ help="Filter by minimum size.",
38
+ )
39
+ @click.option("--color", default=None, help="Filter by color (hex or named color).")
40
+ @click.option("--page", type=int, default=1, help="Page number.")
41
+ @click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
42
+ @click.pass_context
43
+ def search(ctx, query, orientation, size, color, page, json_mode):
44
+ """Search photos by keyword."""
45
+ json_mode = json_mode or ctx.obj.get("json", False)
46
+ with handle_errors(json_mode):
47
+ client = PexelsClient()
48
+ result = client.search_photos(
49
+ query=query,
50
+ orientation=orientation,
51
+ size=size,
52
+ color=color,
53
+ page=page,
54
+ )
55
+ if json_mode:
56
+ print_json(result)
57
+ else:
58
+ print_photos_table(result["data"])
59
+ print_pagination(result.get("pagination", {}))
60
+
61
+
62
+ @photos.command()
63
+ @click.argument("slug")
64
+ @click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
65
+ @click.pass_context
66
+ def get(ctx, slug, json_mode):
67
+ """Get photo details by slug or ID."""
68
+ json_mode = json_mode or ctx.obj.get("json", False)
69
+ with handle_errors(json_mode):
70
+ client = PexelsClient()
71
+ photo = client.get_photo(slug)
72
+ if json_mode:
73
+ print_json(photo)
74
+ else:
75
+ print_photo_detail(photo)
76
+
77
+
78
+ @photos.command()
79
+ @click.argument("slug")
80
+ @click.option("--output", "-o", default=None, help="Output file path.")
81
+ @click.option(
82
+ "--size",
83
+ type=click.Choice(["small", "medium", "large", "original"]),
84
+ default="original",
85
+ help="Download size.",
86
+ )
87
+ @click.pass_context
88
+ def download(ctx, slug, output, size):
89
+ """Download a photo by slug or ID."""
90
+ json_mode = ctx.obj.get("json", False)
91
+ with handle_errors(json_mode):
92
+ client = PexelsClient()
93
+ photo = client.get_photo(slug)
94
+
95
+ image = photo.get("image", {})
96
+ size_map = {
97
+ "original": "download",
98
+ "large": "large",
99
+ "medium": "medium",
100
+ "small": "small",
101
+ }
102
+ url = image.get(size_map[size]) or image.get("download") or image.get("large")
103
+ if not url:
104
+ raise NotFoundError("No download URL available")
105
+
106
+ if output is None:
107
+ title = photo.get("title") or photo.get("alt") or "photo"
108
+ ext = os.path.splitext(url.split("?")[0])[1] or ".jpeg"
109
+ output = sanitize_filename(title) + ext
110
+
111
+ client.download_file(url, output)
112
+
113
+ if json_mode:
114
+ print_json({"downloaded": True, "path": output, "size": size})
115
+ else:
116
+ click.echo(f"Downloaded: {output}")
@@ -0,0 +1,62 @@
1
+ """Users commands for cli-web-pexels."""
2
+
3
+ import click
4
+
5
+ from ..core.client import PexelsClient
6
+ from ..utils.helpers import handle_errors
7
+ from ..utils.output import print_json, print_pagination, print_user_detail
8
+
9
+
10
+ @click.group("users")
11
+ @click.pass_context
12
+ def users(ctx):
13
+ """Browse user profiles and their media."""
14
+ pass
15
+
16
+
17
+ @users.command("get")
18
+ @click.argument("username")
19
+ @click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
20
+ @click.pass_context
21
+ def get(ctx, username, json_mode):
22
+ """Get a user's profile information."""
23
+ json_mode = json_mode or ctx.obj.get("json", False)
24
+ with handle_errors(json_mode):
25
+ client = PexelsClient()
26
+ result = client.get_user(username)
27
+ if json_mode:
28
+ print_json(result)
29
+ else:
30
+ print_user_detail(result["user"])
31
+
32
+
33
+ @users.command("media")
34
+ @click.argument("username")
35
+ @click.option("--page", type=int, default=1, help="Page number.")
36
+ @click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
37
+ @click.pass_context
38
+ def media(ctx, username, page, json_mode):
39
+ """List a user's uploaded photos and videos."""
40
+ json_mode = json_mode or ctx.obj.get("json", False)
41
+ with handle_errors(json_mode):
42
+ client = PexelsClient()
43
+ result = client.get_user_media(username, page=page)
44
+ if json_mode:
45
+ print_json(result)
46
+ else:
47
+ items = result.get("data", [])
48
+ if not items:
49
+ click.echo(f" No media found for @{username}.")
50
+ return
51
+
52
+ click.echo(f"\n Media by @{username}")
53
+ click.echo(f" {'ID':<12} {'Type':<8} {'Title':<35} {'Size':<12}")
54
+ click.echo(f" {'─' * 12} {'─' * 8} {'─' * 35} {'─' * 12}")
55
+ for item in items:
56
+ title = (item.get("title") or "Untitled")[:34]
57
+ size = f"{item.get('width', '?')}x{item.get('height', '?')}"
58
+ click.echo(
59
+ f" {item.get('id', ''):<12} {item.get('type', 'photo'):<8} "
60
+ f"{title:<35} {size:<12}"
61
+ )
62
+ print_pagination(result.get("pagination", {}))
@@ -0,0 +1,162 @@
1
+ """Video commands for cli-web-pexels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from ..core.client import PexelsClient
10
+ from ..core.exceptions import NotFoundError
11
+ from ..utils.helpers import handle_errors, sanitize_filename
12
+ from ..utils.output import print_json, print_pagination, print_video_detail, print_videos_table
13
+
14
+ QUALITY_ORDER = {"sd": 0, "hd": 1, "uhd": 2}
15
+
16
+
17
+ @click.group("videos")
18
+ def videos():
19
+ """Search, view, and download videos."""
20
+
21
+
22
+ @videos.command("search")
23
+ @click.argument("query")
24
+ @click.option(
25
+ "--orientation",
26
+ type=click.Choice(["landscape", "portrait", "square"]),
27
+ help="Filter by orientation.",
28
+ )
29
+ @click.option("--page", type=int, default=1, help="Page number.")
30
+ @click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
31
+ @click.pass_context
32
+ def search(ctx, query, orientation, page, json_mode):
33
+ """Search videos by keyword."""
34
+ json_mode = json_mode or ctx.obj.get("json", False)
35
+ with handle_errors(json_mode):
36
+ client = PexelsClient()
37
+ result = client.search_videos(query, page=page, orientation=orientation)
38
+ videos_list = result.get("data", [])
39
+ pagination = result.get("pagination", {})
40
+
41
+ if json_mode:
42
+ print_json(
43
+ {
44
+ "query": query,
45
+ "page": page,
46
+ "total_results": pagination.get("total_results", 0),
47
+ "total_pages": pagination.get("total_pages", 0),
48
+ "results": videos_list,
49
+ }
50
+ )
51
+ else:
52
+ total = pagination.get("total_results", 0)
53
+ click.echo(f"\n Found {total:,} videos for '{query}'")
54
+ print_videos_table(videos_list)
55
+ print_pagination(pagination)
56
+
57
+
58
+ @videos.command("get")
59
+ @click.argument("slug")
60
+ @click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
61
+ @click.pass_context
62
+ def get(ctx, slug, json_mode):
63
+ """Get video details by slug or ID (e.g., 'long-narrow-road-856479' or '856479')."""
64
+ json_mode = json_mode or ctx.obj.get("json", False)
65
+ with handle_errors(json_mode):
66
+ client = PexelsClient()
67
+ video = client.get_video(slug)
68
+
69
+ if json_mode:
70
+ print_json(video)
71
+ else:
72
+ print_video_detail(video)
73
+
74
+
75
+ @videos.command("download")
76
+ @click.argument("slug")
77
+ @click.option(
78
+ "--quality",
79
+ type=click.Choice(["sd", "hd", "uhd"]),
80
+ default="hd",
81
+ help="Video quality (default: hd).",
82
+ )
83
+ @click.option("--output", "-o", type=click.Path(), help="Output file path.")
84
+ @click.pass_context
85
+ def download(ctx, slug, quality, output):
86
+ """Download a video by slug or ID."""
87
+ json_mode = ctx.obj.get("json", False)
88
+ with handle_errors(json_mode):
89
+ client = PexelsClient()
90
+ video = client.get_video(slug)
91
+
92
+ video_files = video.get("video_files", [])
93
+ if not video_files:
94
+ raise NotFoundError(f"No video files available for '{slug}'")
95
+
96
+ # Find best matching file by quality
97
+ best = _select_video_file(video_files, quality)
98
+ if not best:
99
+ raise NotFoundError(f"No downloadable video file found for '{slug}'")
100
+
101
+ download_url = best.get("link")
102
+ if not download_url:
103
+ raise NotFoundError(f"No download link for selected quality ({best.get('quality')})")
104
+
105
+ # Determine output filename
106
+ if not output:
107
+ title = video.get("title") or slug
108
+ safe_name = sanitize_filename(title)
109
+ output = f"{safe_name}.mp4"
110
+
111
+ out_path = Path(output)
112
+ client.download_file(download_url, str(out_path))
113
+
114
+ file_size = out_path.stat().st_size
115
+ if json_mode:
116
+ print_json(
117
+ {
118
+ "video_id": video.get("id"),
119
+ "slug": slug,
120
+ "quality": best.get("quality"),
121
+ "resolution": f"{best.get('width')}x{best.get('height')}",
122
+ "file": str(out_path),
123
+ "bytes": file_size,
124
+ }
125
+ )
126
+ else:
127
+ click.echo(f" Downloaded: {out_path} ({file_size:,} bytes)")
128
+ click.echo(
129
+ f" Quality: {best.get('quality')} "
130
+ f"({best.get('width')}x{best.get('height')}, "
131
+ f"{best.get('fps', '?')}fps)"
132
+ )
133
+ click.echo(f" Video: {video.get('title') or slug}")
134
+
135
+
136
+ def _select_video_file(video_files: list[dict], target_quality: str) -> dict | None:
137
+ """Select the best video file matching the requested quality.
138
+
139
+ Picks the highest-resolution file at the target quality level.
140
+ Falls back to the closest available quality if no exact match.
141
+ """
142
+ target_rank = QUALITY_ORDER.get(target_quality, 1)
143
+
144
+ # Filter exact matches and pick highest resolution
145
+ exact = [f for f in video_files if f.get("quality") == target_quality]
146
+ if exact:
147
+ return max(exact, key=lambda f: f.get("width", 0) * f.get("height", 0))
148
+
149
+ # No exact match — find closest quality
150
+ # Sort candidates by distance to target rank, then by resolution descending
151
+ candidates = [f for f in video_files if f.get("link")]
152
+ if not candidates:
153
+ return None
154
+
155
+ def sort_key(f):
156
+ f_rank = QUALITY_ORDER.get(f.get("quality", ""), 1)
157
+ distance = abs(f_rank - target_rank)
158
+ resolution = f.get("width", 0) * f.get("height", 0)
159
+ return (distance, -resolution)
160
+
161
+ candidates.sort(key=sort_key)
162
+ return candidates[0]
File without changes