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.
@@ -0,0 +1,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(cp -r /Users/dhruvmehra/Desktop/Claude/Life/tools/meme/skill/meme ~/.claude/skills/meme)",
5
+ "Read(//Users/dhruvmehra/.claude/skills/**)",
6
+ "Read(//Users/dhruvmehra/.claude/skills/meme/**)"
7
+ ]
8
+ }
9
+ }
@@ -0,0 +1,18 @@
1
+ # build artifacts
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ *.pyc
7
+
8
+ # default meme output
9
+ meme.png
10
+ *.png
11
+ *.jpg
12
+ *.webp
13
+ *.gif
14
+ !docs/**/*.png
15
+
16
+ # envs
17
+ .venv/
18
+ venv/
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.
@@ -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
@@ -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,6 @@
1
+ """meme - a tiny zero-dependency CLI over the free memegen.link API."""
2
+
3
+ from .cli import build_url, download, escape, get_templates, main
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["main", "build_url", "escape", "download", "get_templates", "__version__"]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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()