makememe 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.
- makememe-0.1.0/.claude/settings.local.json +9 -0
- makememe-0.1.0/.gitignore +18 -0
- makememe-0.1.0/LICENSE +21 -0
- makememe-0.1.0/PKG-INFO +154 -0
- makememe-0.1.0/README.md +135 -0
- makememe-0.1.0/pyproject.toml +32 -0
- makememe-0.1.0/skill/meme/SKILL.md +78 -0
- makememe-0.1.0/src/memecli/__init__.py +6 -0
- makememe-0.1.0/src/memecli/__main__.py +4 -0
- makememe-0.1.0/src/memecli/cli.py +180 -0
- makememe-0.1.0/tests/test_cli.py +106 -0
makememe-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dhruv Mehra
|
|
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.
|
makememe-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: makememe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A tiny zero-dependency CLI for generating memes via the free memegen.link API. Agent-friendly.
|
|
5
|
+
Project-URL: Homepage, https://pypi.org/project/makememe/
|
|
6
|
+
Project-URL: memegen.link API, https://api.memegen.link
|
|
7
|
+
Author: Dhruv Mehra
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agent,claude,cli,codex,meme,memegen
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# meme
|
|
21
|
+
|
|
22
|
+
A tiny, zero-dependency CLI for generating memes via the free
|
|
23
|
+
[memegen.link](https://memegen.link) API. No API key, no signup, stdlib-only.
|
|
24
|
+
|
|
25
|
+
Built to be **agent-friendly**: predictable stdout, a `--json` mode, and a
|
|
26
|
+
bundled Claude Code skill so coding agents (Claude Code, Codex, etc.) can drive
|
|
27
|
+
it directly.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
meme drake "not reading docs" "reading docs" -o out.png
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
Requires Python 3.8+.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install makememe
|
|
39
|
+
# or, as an isolated tool:
|
|
40
|
+
uv tool install makememe # or: pipx install makememe
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Run once without installing:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uvx --from makememe meme drake "a" "b"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
meme <template> "top line" "bottom line" [-o out.png]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
| Flag | Meaning |
|
|
56
|
+
|------|---------|
|
|
57
|
+
| `-o, --out` | output file (default `meme.png`) |
|
|
58
|
+
| `--bg URL` | use a custom background image instead of a template |
|
|
59
|
+
| `--ext` | `png` (default), `jpg`, `webp`, or `gif` |
|
|
60
|
+
| `--style` / `--font` | template style variant / font override |
|
|
61
|
+
| `--print-url` | print the image URL, don't download |
|
|
62
|
+
| `--json` | machine-readable output (for scripts/agents) |
|
|
63
|
+
| `--list` | list available template ids |
|
|
64
|
+
|
|
65
|
+
### Examples
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
meme drake "manual deploys" "ci/cd"
|
|
69
|
+
meme same "after I sold" "if I held" "same picture"
|
|
70
|
+
meme --bg https://example.com/pic.png "_" "DODGED"
|
|
71
|
+
meme regret "SOLD @ 620" "NOW 780 (+26%)" --print-url
|
|
72
|
+
meme --list
|
|
73
|
+
meme --list --json
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Text that starts with `-`
|
|
77
|
+
|
|
78
|
+
If a caption line begins with `-` (e.g. `"-26%"`), put `--` before your lines so
|
|
79
|
+
it isn't parsed as a flag:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
meme regret --json -- "-26%" "WHY"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
(Put flags like `--json`/`-o` *before* the `--`.)
|
|
86
|
+
|
|
87
|
+
## For agents (Claude Code / Codex / scripts)
|
|
88
|
+
|
|
89
|
+
The tool is designed to be parsed:
|
|
90
|
+
|
|
91
|
+
- **Plain mode** prints *only the output path* to stdout (status goes to stderr),
|
|
92
|
+
so `path=$(meme drake "a" "b")` just works.
|
|
93
|
+
- **`--json` mode** prints a single JSON object:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{ "path": "meme.png", "bytes": 12345, "url": "https://api.memegen.link/..." }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`--list --json` returns the template catalog as JSON; `--print-url --json`
|
|
100
|
+
returns `{"url": "..."}`; failures return `{"error": "...", "url": "..."}`
|
|
101
|
+
with a non-zero exit code.
|
|
102
|
+
|
|
103
|
+
Typical agent flow:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
meme --list --json # discover template ids
|
|
107
|
+
meme drake "old way" "new way" --json # generate, capture the path
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Claude Code skill
|
|
111
|
+
|
|
112
|
+
A ready-made skill lives in [`skill/meme/`](skill/meme/SKILL.md). Install it so
|
|
113
|
+
Claude Code auto-discovers the tool:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
# user-level (all projects)
|
|
117
|
+
mkdir -p ~/.claude/skills
|
|
118
|
+
cp -r skill/meme ~/.claude/skills/meme
|
|
119
|
+
|
|
120
|
+
# or project-level
|
|
121
|
+
mkdir -p .claude/skills
|
|
122
|
+
cp -r skill/meme .claude/skills/meme
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Then just ask Claude Code things like *"make a drake meme about writing tests"*
|
|
126
|
+
and it will call `meme` for you.
|
|
127
|
+
|
|
128
|
+
## Robustness
|
|
129
|
+
|
|
130
|
+
The CLI is built to fail gracefully, never with a raw traceback:
|
|
131
|
+
|
|
132
|
+
- Network errors, dead hosts, bad template ids (404), oversized text (414), and
|
|
133
|
+
non-image backgrounds (415) all exit non-zero with a one-line message — and
|
|
134
|
+
**no partial/garbage file is written**.
|
|
135
|
+
- `Ctrl-C` exits cleanly (code 130); piping into `head` etc. won't spew a
|
|
136
|
+
`BrokenPipeError`.
|
|
137
|
+
- Arbitrary text — emoji, CJK, `% # & / ? " \`, tabs, control chars, 10k-char
|
|
138
|
+
lines — is escaped safely.
|
|
139
|
+
|
|
140
|
+
Run the test suite (stdlib only, no network needed):
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
python -m unittest discover -s tests
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## How it works
|
|
147
|
+
|
|
148
|
+
It builds a memegen.link URL from your template + text (handling all the fiddly
|
|
149
|
+
path-segment escaping — spaces, `_`, `-`, `?`, `/`, `%`, etc.), downloads the
|
|
150
|
+
image, and saves it. That's the whole trick.
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT
|
makememe-0.1.0/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# meme
|
|
2
|
+
|
|
3
|
+
A tiny, zero-dependency CLI for generating memes via the free
|
|
4
|
+
[memegen.link](https://memegen.link) API. No API key, no signup, stdlib-only.
|
|
5
|
+
|
|
6
|
+
Built to be **agent-friendly**: predictable stdout, a `--json` mode, and a
|
|
7
|
+
bundled Claude Code skill so coding agents (Claude Code, Codex, etc.) can drive
|
|
8
|
+
it directly.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
meme drake "not reading docs" "reading docs" -o out.png
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
Requires Python 3.8+.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install makememe
|
|
20
|
+
# or, as an isolated tool:
|
|
21
|
+
uv tool install makememe # or: pipx install makememe
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Run once without installing:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uvx --from makememe meme drake "a" "b"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
meme <template> "top line" "bottom line" [-o out.png]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| Flag | Meaning |
|
|
37
|
+
|------|---------|
|
|
38
|
+
| `-o, --out` | output file (default `meme.png`) |
|
|
39
|
+
| `--bg URL` | use a custom background image instead of a template |
|
|
40
|
+
| `--ext` | `png` (default), `jpg`, `webp`, or `gif` |
|
|
41
|
+
| `--style` / `--font` | template style variant / font override |
|
|
42
|
+
| `--print-url` | print the image URL, don't download |
|
|
43
|
+
| `--json` | machine-readable output (for scripts/agents) |
|
|
44
|
+
| `--list` | list available template ids |
|
|
45
|
+
|
|
46
|
+
### Examples
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
meme drake "manual deploys" "ci/cd"
|
|
50
|
+
meme same "after I sold" "if I held" "same picture"
|
|
51
|
+
meme --bg https://example.com/pic.png "_" "DODGED"
|
|
52
|
+
meme regret "SOLD @ 620" "NOW 780 (+26%)" --print-url
|
|
53
|
+
meme --list
|
|
54
|
+
meme --list --json
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Text that starts with `-`
|
|
58
|
+
|
|
59
|
+
If a caption line begins with `-` (e.g. `"-26%"`), put `--` before your lines so
|
|
60
|
+
it isn't parsed as a flag:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
meme regret --json -- "-26%" "WHY"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
(Put flags like `--json`/`-o` *before* the `--`.)
|
|
67
|
+
|
|
68
|
+
## For agents (Claude Code / Codex / scripts)
|
|
69
|
+
|
|
70
|
+
The tool is designed to be parsed:
|
|
71
|
+
|
|
72
|
+
- **Plain mode** prints *only the output path* to stdout (status goes to stderr),
|
|
73
|
+
so `path=$(meme drake "a" "b")` just works.
|
|
74
|
+
- **`--json` mode** prints a single JSON object:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{ "path": "meme.png", "bytes": 12345, "url": "https://api.memegen.link/..." }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`--list --json` returns the template catalog as JSON; `--print-url --json`
|
|
81
|
+
returns `{"url": "..."}`; failures return `{"error": "...", "url": "..."}`
|
|
82
|
+
with a non-zero exit code.
|
|
83
|
+
|
|
84
|
+
Typical agent flow:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
meme --list --json # discover template ids
|
|
88
|
+
meme drake "old way" "new way" --json # generate, capture the path
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Claude Code skill
|
|
92
|
+
|
|
93
|
+
A ready-made skill lives in [`skill/meme/`](skill/meme/SKILL.md). Install it so
|
|
94
|
+
Claude Code auto-discovers the tool:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# user-level (all projects)
|
|
98
|
+
mkdir -p ~/.claude/skills
|
|
99
|
+
cp -r skill/meme ~/.claude/skills/meme
|
|
100
|
+
|
|
101
|
+
# or project-level
|
|
102
|
+
mkdir -p .claude/skills
|
|
103
|
+
cp -r skill/meme .claude/skills/meme
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Then just ask Claude Code things like *"make a drake meme about writing tests"*
|
|
107
|
+
and it will call `meme` for you.
|
|
108
|
+
|
|
109
|
+
## Robustness
|
|
110
|
+
|
|
111
|
+
The CLI is built to fail gracefully, never with a raw traceback:
|
|
112
|
+
|
|
113
|
+
- Network errors, dead hosts, bad template ids (404), oversized text (414), and
|
|
114
|
+
non-image backgrounds (415) all exit non-zero with a one-line message — and
|
|
115
|
+
**no partial/garbage file is written**.
|
|
116
|
+
- `Ctrl-C` exits cleanly (code 130); piping into `head` etc. won't spew a
|
|
117
|
+
`BrokenPipeError`.
|
|
118
|
+
- Arbitrary text — emoji, CJK, `% # & / ? " \`, tabs, control chars, 10k-char
|
|
119
|
+
lines — is escaped safely.
|
|
120
|
+
|
|
121
|
+
Run the test suite (stdlib only, no network needed):
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
python -m unittest discover -s tests
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## How it works
|
|
128
|
+
|
|
129
|
+
It builds a memegen.link URL from your template + text (handling all the fiddly
|
|
130
|
+
path-segment escaping — spaces, `_`, `-`, `?`, `/`, `%`, etc.), downloads the
|
|
131
|
+
image, and saves it. That's the whole trick.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "makememe"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A tiny zero-dependency CLI for generating memes via the free memegen.link API. Agent-friendly."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Dhruv Mehra" }]
|
|
13
|
+
keywords = ["meme", "cli", "memegen", "agent", "claude", "codex"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Multimedia :: Graphics",
|
|
20
|
+
"Topic :: Utilities",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://pypi.org/project/makememe/"
|
|
26
|
+
"memegen.link API" = "https://api.memegen.link"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
meme = "memecli.cli:main"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/memecli"]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: meme
|
|
3
|
+
description: Generate meme images from a template and caption text using the `meme` CLI (a wrapper over the free memegen.link API). Use whenever the user asks to make/create/generate a meme, add a caption to a meme template, or wants a funny image with top/bottom text. Handles popular templates (drake, distracted boyfriend, two buttons, etc.) and custom background images.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# meme
|
|
7
|
+
|
|
8
|
+
Generate memes from the command line. The `meme` command wraps the free
|
|
9
|
+
[memegen.link](https://api.memegen.link) API — no API key needed.
|
|
10
|
+
|
|
11
|
+
## Prerequisite
|
|
12
|
+
|
|
13
|
+
The `meme` command must be installed. Check with `meme --list`. If it's missing,
|
|
14
|
+
install it:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install makememe
|
|
18
|
+
# or: uv tool install makememe
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Workflow
|
|
22
|
+
|
|
23
|
+
1. **Pick a template.** If you don't already know a valid template id, list them:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
meme --list --json
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Common ids: `drake`, `db` (distracted boyfriend), `buttons` (two buttons),
|
|
30
|
+
`gru` (gru's plan), `cmm` (change my mind), `fine` (this is fine),
|
|
31
|
+
`success` (success kid), `rollsafe`, `same` (same picture), `regret`.
|
|
32
|
+
|
|
33
|
+
2. **Generate.** Pass the template id then the caption lines in order. Use
|
|
34
|
+
`--json` so you can capture the output path reliably:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
meme drake "writing code by hand" "asking the meme cli" --json
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Output:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{ "path": "meme.png", "bytes": 12345, "url": "https://api.memegen.link/..." }
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
3. **Tell the user the path** (and show the image if the surface supports it).
|
|
47
|
+
|
|
48
|
+
## Key flags
|
|
49
|
+
|
|
50
|
+
- `-o out.png` — choose the output filename (default `meme.png`). Pick a
|
|
51
|
+
descriptive name when generating several.
|
|
52
|
+
- `--bg <image-url>` — use a custom background image instead of a template;
|
|
53
|
+
pass caption lines as usual.
|
|
54
|
+
- `--ext png|jpg|webp|gif` — output format.
|
|
55
|
+
- `--print-url` — get the image URL without downloading.
|
|
56
|
+
- `--json` — machine-readable output (always prefer this when scripting).
|
|
57
|
+
|
|
58
|
+
## Tips
|
|
59
|
+
|
|
60
|
+
- Number of caption lines depends on the template (`--list` shows each
|
|
61
|
+
template's `lines` count). Most use 2 (top/bottom).
|
|
62
|
+
- For an empty line, pass `"_"` (memegen renders it blank).
|
|
63
|
+
- Text escaping (spaces, `?`, `/`, `%`, emoji, etc.) is handled automatically —
|
|
64
|
+
just pass natural text in quotes.
|
|
65
|
+
- If a caption line starts with `-` (e.g. `"-26%"`), insert `--` before the
|
|
66
|
+
lines so it isn't read as a flag: `meme regret --json -- "-26%" "WHY"`.
|
|
67
|
+
(Flags like `--json`/`-o` go before the `--`.)
|
|
68
|
+
- If a download fails, the JSON output includes the `url` and `error` — inspect
|
|
69
|
+
the URL to debug the template id or line count.
|
|
70
|
+
|
|
71
|
+
## Examples
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
meme drake "old way" "new way" -o drake.png --json
|
|
75
|
+
meme same "after I sold" "if I held" "same picture" --json
|
|
76
|
+
meme cmm "tabs are better than spaces" --json
|
|
77
|
+
meme --bg https://example.com/cat.png "_" "DEPLOY ON FRIDAY" --json
|
|
78
|
+
```
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
meme - a tiny CLI over the free memegen.link API.
|
|
4
|
+
|
|
5
|
+
Builds a meme image URL from a template + text lines, downloads it, saves an
|
|
6
|
+
image. No API key, no third-party dependencies (Python stdlib only). Handles URL
|
|
7
|
+
escaping so you never hit the bare-% problem.
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
meme drake "not reading docs" "reading docs"
|
|
11
|
+
meme regret "SOLD IRCTC @ Rs620" "NOW Rs780 (+26%) WHY"
|
|
12
|
+
meme same "after I sold" "if I held" "same picture"
|
|
13
|
+
meme --bg https://example.com/pic.png "_" "DODGED"
|
|
14
|
+
meme drake "a" "b" --print-url # just print the URL, don't download
|
|
15
|
+
meme drake "a" "b" --json # machine-readable output for agents
|
|
16
|
+
meme --list # list available template ids
|
|
17
|
+
meme --list --json # template ids as JSON
|
|
18
|
+
|
|
19
|
+
Find templates: https://api.memegen.link/templates/ (or `meme --list`)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import urllib.parse
|
|
27
|
+
import urllib.request
|
|
28
|
+
|
|
29
|
+
API = "https://api.memegen.link"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# memegen path-segment escaping. Order matters: escape the escape chars first.
|
|
33
|
+
# Confirmed from the API: space->_, _->__, -->--, ?->~q, newline->~n, "->''
|
|
34
|
+
# Others use memegen's documented tilde codes; verify with --print-url if unsure.
|
|
35
|
+
def escape(text):
|
|
36
|
+
if text == "":
|
|
37
|
+
return "_" # memegen renders an empty line as a single underscore
|
|
38
|
+
text = text.replace("_", "__").replace("-", "--")
|
|
39
|
+
text = text.replace(" ", "_").replace("\n", "~n")
|
|
40
|
+
text = text.replace("?", "~q").replace('"', "''").replace("/", "~s")
|
|
41
|
+
# let quote percent-encode the rest (%, #, etc) - memegen decodes %25 reliably.
|
|
42
|
+
# keep the memegen tokens we just produced intact.
|
|
43
|
+
return urllib.parse.quote(text, safe="_~'.!*()")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_url(template, lines, ext="png", bg=None, style=None, font=None):
|
|
47
|
+
parts = [escape(l) for l in lines] if lines else ["_"]
|
|
48
|
+
base = "custom" if bg else template
|
|
49
|
+
path = "/".join(parts)
|
|
50
|
+
url = f"{API}/images/{base}/{path}.{ext}"
|
|
51
|
+
q = {}
|
|
52
|
+
if bg:
|
|
53
|
+
q["background"] = bg
|
|
54
|
+
if style:
|
|
55
|
+
q["style"] = style
|
|
56
|
+
if font:
|
|
57
|
+
q["font"] = font
|
|
58
|
+
if q:
|
|
59
|
+
url += "?" + urllib.parse.urlencode(q)
|
|
60
|
+
return url
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_templates(timeout=20):
|
|
64
|
+
req = urllib.request.Request(
|
|
65
|
+
f"{API}/templates/", headers={"User-Agent": "meme-cli"})
|
|
66
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
67
|
+
return json.load(r)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def list_templates(as_json=False):
|
|
71
|
+
data = get_templates()
|
|
72
|
+
if as_json:
|
|
73
|
+
slim = [
|
|
74
|
+
{"id": t["id"], "lines": t.get("lines"), "name": t["name"]}
|
|
75
|
+
for t in data
|
|
76
|
+
]
|
|
77
|
+
print(json.dumps(slim, indent=2))
|
|
78
|
+
return
|
|
79
|
+
for t in data:
|
|
80
|
+
print(f"{t['id']:<18} {t.get('lines', '?')} lines {t['name']}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def download(url, out, timeout=30):
|
|
84
|
+
req = urllib.request.Request(url, headers={"User-Agent": "meme-cli"})
|
|
85
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
86
|
+
data = r.read()
|
|
87
|
+
with open(out, "wb") as f:
|
|
88
|
+
f.write(data)
|
|
89
|
+
return len(data)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_parser():
|
|
93
|
+
ap = argparse.ArgumentParser(
|
|
94
|
+
prog="meme", description="Generate a meme via memegen.link.",
|
|
95
|
+
epilog="Tip: if a text line starts with '-' (e.g. \"-26%\"), put '--' "
|
|
96
|
+
"before your lines so it isn't read as a flag:\n"
|
|
97
|
+
" meme regret --json -- \"-26%\" \"WHY\"",
|
|
98
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
99
|
+
ap.add_argument("template", nargs="?",
|
|
100
|
+
help="template id (see --list), or any id when using --bg")
|
|
101
|
+
ap.add_argument("lines", nargs="*", help="text lines, in order")
|
|
102
|
+
ap.add_argument("-o", "--out", default="meme.png",
|
|
103
|
+
help="output file (default meme.png)")
|
|
104
|
+
ap.add_argument("--bg",
|
|
105
|
+
help="custom background image URL (uses the 'custom' template)")
|
|
106
|
+
ap.add_argument("--ext", default="png", choices=["png", "jpg", "webp", "gif"])
|
|
107
|
+
ap.add_argument("--style", help="template style variant, if any")
|
|
108
|
+
ap.add_argument("--font", help="font name (see /fonts/)")
|
|
109
|
+
ap.add_argument("--print-url", action="store_true",
|
|
110
|
+
help="print the URL and exit, no download")
|
|
111
|
+
ap.add_argument("--json", action="store_true",
|
|
112
|
+
help="emit machine-readable JSON (good for agents/scripts)")
|
|
113
|
+
ap.add_argument("--list", action="store_true",
|
|
114
|
+
help="list template ids and exit")
|
|
115
|
+
return ap
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _run(argv=None):
|
|
119
|
+
ap = build_parser()
|
|
120
|
+
args = ap.parse_args(argv)
|
|
121
|
+
|
|
122
|
+
if args.list:
|
|
123
|
+
try:
|
|
124
|
+
list_templates(as_json=args.json)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
sys.exit(f"could not fetch templates: {e}")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
if not args.template and not args.bg:
|
|
130
|
+
ap.error("a template id is required (or use --bg for a custom background)")
|
|
131
|
+
|
|
132
|
+
# with --bg there is no template positional, so fold it back into the lines
|
|
133
|
+
if args.bg:
|
|
134
|
+
lines = ([args.template] if args.template else []) + args.lines
|
|
135
|
+
template = "custom"
|
|
136
|
+
else:
|
|
137
|
+
lines = args.lines
|
|
138
|
+
template = args.template
|
|
139
|
+
|
|
140
|
+
url = build_url(template, lines, args.ext, args.bg, args.style, args.font)
|
|
141
|
+
|
|
142
|
+
if args.print_url:
|
|
143
|
+
if args.json:
|
|
144
|
+
print(json.dumps({"url": url}))
|
|
145
|
+
else:
|
|
146
|
+
print(url)
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
n = download(url, args.out)
|
|
151
|
+
except Exception as e:
|
|
152
|
+
if args.json:
|
|
153
|
+
print(json.dumps({"error": str(e), "url": url}))
|
|
154
|
+
sys.exit(1)
|
|
155
|
+
sys.exit(f"download failed: {e}\nurl was: {url}")
|
|
156
|
+
|
|
157
|
+
if args.json:
|
|
158
|
+
print(json.dumps({"path": args.out, "bytes": n, "url": url}))
|
|
159
|
+
else:
|
|
160
|
+
print(args.out) # stdout: just the path, easy to capture
|
|
161
|
+
print(f"saved {n} bytes from {url}", file=sys.stderr)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main(argv=None):
|
|
165
|
+
"""Entry point. Wraps _run so the CLI never dies with a raw traceback."""
|
|
166
|
+
try:
|
|
167
|
+
return _run(argv)
|
|
168
|
+
except KeyboardInterrupt:
|
|
169
|
+
print("interrupted", file=sys.stderr)
|
|
170
|
+
sys.exit(130)
|
|
171
|
+
except BrokenPipeError:
|
|
172
|
+
# downstream closed the pipe (e.g. `meme --list | head`). Silence the
|
|
173
|
+
# traceback Python would otherwise emit when flushing stdout at exit.
|
|
174
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
175
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
176
|
+
sys.exit(0)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
main()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Offline tests for meme's URL building / escaping and crash-safety.
|
|
2
|
+
|
|
3
|
+
Run with: python -m unittest discover -s tests
|
|
4
|
+
No network access required (network paths are covered by --print-url style asserts).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
import unittest
|
|
9
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
10
|
+
|
|
11
|
+
from memecli import cli
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestEscape(unittest.TestCase):
|
|
15
|
+
def test_empty_is_underscore(self):
|
|
16
|
+
self.assertEqual(cli.escape(""), "_")
|
|
17
|
+
|
|
18
|
+
def test_space(self):
|
|
19
|
+
self.assertEqual(cli.escape("a b"), "a_b")
|
|
20
|
+
|
|
21
|
+
def test_literal_underscore_and_dash(self):
|
|
22
|
+
self.assertEqual(cli.escape("a_b"), "a__b")
|
|
23
|
+
self.assertEqual(cli.escape("a-b"), "a--b")
|
|
24
|
+
|
|
25
|
+
def test_special_tokens(self):
|
|
26
|
+
self.assertEqual(cli.escape("really?"), "really~q")
|
|
27
|
+
self.assertEqual(cli.escape("a/b"), "a~sb")
|
|
28
|
+
self.assertEqual(cli.escape('say "hi"'), "say_''hi''")
|
|
29
|
+
|
|
30
|
+
def test_percent_and_hash_are_encoded_not_dropped(self):
|
|
31
|
+
# the bare-% problem: must be percent-encoded, never left raw
|
|
32
|
+
self.assertEqual(cli.escape("50%"), "50%25")
|
|
33
|
+
self.assertIn("%23", cli.escape("#tag"))
|
|
34
|
+
|
|
35
|
+
def test_unicode_roundtrips_to_ascii_url(self):
|
|
36
|
+
out = cli.escape("café 🚀")
|
|
37
|
+
self.assertTrue(out.isascii(), "URL segment must be ascii-safe")
|
|
38
|
+
|
|
39
|
+
def test_no_input_ever_raises(self):
|
|
40
|
+
for s in ["", " ", "\n", "\t", "\x07", "%%%", "----", "____",
|
|
41
|
+
"a" * 10000, "🚀" * 100, "?/\"\\#&=+"]:
|
|
42
|
+
cli.escape(s) # should not raise
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestBuildUrl(unittest.TestCase):
|
|
46
|
+
def test_basic(self):
|
|
47
|
+
u = cli.build_url("drake", ["a b", "c"])
|
|
48
|
+
self.assertEqual(u, "https://api.memegen.link/images/drake/a_b/c.png")
|
|
49
|
+
|
|
50
|
+
def test_no_lines_gives_single_underscore(self):
|
|
51
|
+
u = cli.build_url("drake", [])
|
|
52
|
+
self.assertTrue(u.endswith("/drake/_.png"))
|
|
53
|
+
|
|
54
|
+
def test_custom_background(self):
|
|
55
|
+
u = cli.build_url("x", ["t"], bg="https://e.com/p.png")
|
|
56
|
+
self.assertIn("/images/custom/", u)
|
|
57
|
+
self.assertIn("background=https%3A%2F%2Fe.com%2Fp.png", u)
|
|
58
|
+
|
|
59
|
+
def test_ext_and_style_and_font(self):
|
|
60
|
+
u = cli.build_url("drake", ["a"], ext="jpg", style="s", font="impact")
|
|
61
|
+
self.assertIn(".jpg?", u)
|
|
62
|
+
self.assertIn("style=s", u)
|
|
63
|
+
self.assertIn("font=impact", u)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestCrashSafety(unittest.TestCase):
|
|
67
|
+
"""The CLI must exit cleanly, never propagate a raw exception."""
|
|
68
|
+
|
|
69
|
+
def _exit_code(self, argv):
|
|
70
|
+
try:
|
|
71
|
+
cli.main(argv)
|
|
72
|
+
return 0
|
|
73
|
+
except SystemExit as e:
|
|
74
|
+
return e.code if isinstance(e.code, int) else 1
|
|
75
|
+
|
|
76
|
+
def test_missing_template_is_clean_exit(self):
|
|
77
|
+
with redirect_stderr(io.StringIO()):
|
|
78
|
+
self.assertEqual(self._exit_code([]), 2)
|
|
79
|
+
|
|
80
|
+
def test_print_url_no_network(self):
|
|
81
|
+
buf = io.StringIO()
|
|
82
|
+
with redirect_stdout(buf):
|
|
83
|
+
cli.main(["drake", "a", "b", "--print-url"])
|
|
84
|
+
self.assertIn("api.memegen.link/images/drake/a/b.png", buf.getvalue())
|
|
85
|
+
|
|
86
|
+
def test_keyboard_interrupt_becomes_130(self):
|
|
87
|
+
orig = cli.download
|
|
88
|
+
cli.download = lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt())
|
|
89
|
+
try:
|
|
90
|
+
with redirect_stderr(io.StringIO()):
|
|
91
|
+
self.assertEqual(self._exit_code(["drake", "a", "b"]), 130)
|
|
92
|
+
finally:
|
|
93
|
+
cli.download = orig
|
|
94
|
+
|
|
95
|
+
def test_download_failure_is_clean_exit_in_json(self):
|
|
96
|
+
orig = cli.download
|
|
97
|
+
cli.download = lambda *a, **k: (_ for _ in ()).throw(OSError("boom"))
|
|
98
|
+
try:
|
|
99
|
+
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
|
|
100
|
+
self.assertEqual(self._exit_code(["drake", "a", "--json"]), 1)
|
|
101
|
+
finally:
|
|
102
|
+
cli.download = orig
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
unittest.main()
|