sepia-cli 0.1.0__py3-none-any.whl

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.
sepia/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """sepia: prompts become colored symbol paintings in your terminal."""
2
+
3
+ __version__ = "0.1.0"
sepia/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from sepia.cli import main
2
+
3
+ raise SystemExit(main())
sepia/ascii_art.py ADDED
@@ -0,0 +1,368 @@
1
+ """Turn RGB images into colored symbol art."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ import numpy as np
7
+ import numpy.typing as npt
8
+ from rich.style import Style
9
+ from rich.text import Text
10
+
11
+ from sepia.raster import RGBImage
12
+
13
+ CELL_WIDTH = 8
14
+ CELL_HEIGHT = 16
15
+ """Pixels rendered per terminal cell. Terminal cells are about twice as tall as wide."""
16
+
17
+ ArtStyle = Literal["ink", "fill"]
18
+ """`ink` colors the symbols on the terminal's own background (made for dark themes).
19
+ `fill` also paints each cell's background, which shows the picture on any theme."""
20
+
21
+ FloatArray = npt.NDArray[np.float32]
22
+
23
+ Charset = Literal["classic", "symbols"]
24
+ """`classic` is the full ASCII-art alphabet (letters, digits, symbols), which gives the
25
+ finest shading for photographs. `symbols` uses punctuation only."""
26
+
27
+ # Characters with the share of a cell their ink covers, averaged over Cascadia Mono
28
+ # (the Windows Terminal default) and Consolas, each spaced at least 0.015 apart so that
29
+ # neighbors in a ramp look different.
30
+ _CLASSIC: tuple[tuple[str, float], ...] = (
31
+ (" ", 0.000),
32
+ ("`", 0.076),
33
+ ("'", 0.133),
34
+ ("-", 0.157),
35
+ (",", 0.177),
36
+ ("_", 0.200),
37
+ ("^", 0.246),
38
+ ('"', 0.262),
39
+ ("!", 0.290),
40
+ ("<", 0.347),
41
+ ("+", 0.365),
42
+ ("?", 0.410),
43
+ ("|", 0.427),
44
+ ("c", 0.443),
45
+ ("(", 0.475),
46
+ ("i", 0.491),
47
+ ("}", 0.507),
48
+ ("n", 0.529),
49
+ ("f", 0.545),
50
+ ("o", 0.575),
51
+ ("a", 0.600),
52
+ ("X", 0.622),
53
+ ("w", 0.651),
54
+ ("b", 0.695),
55
+ ("O", 0.734),
56
+ ("%", 0.763),
57
+ ("8", 0.792),
58
+ ("0", 0.812),
59
+ ("W", 0.839),
60
+ ("Q", 0.875),
61
+ ("@", 1.000),
62
+ )
63
+ # Punctuation only, measured the same way. Directional glyphs stay out so that a
64
+ # texture never reads as an outline.
65
+ _SYMBOLS: tuple[tuple[str, float], ...] = (
66
+ (" ", 0.00),
67
+ (".", 0.08),
68
+ ("'", 0.15),
69
+ (":", 0.16),
70
+ (";", 0.24),
71
+ ("^", 0.27),
72
+ ("!", 0.29),
73
+ ("~", 0.30),
74
+ ("+", 0.39),
75
+ ("*", 0.45),
76
+ ("(", 0.55),
77
+ ("%", 0.80),
78
+ ("#", 0.81),
79
+ ("&", 0.87),
80
+ ("@", 1.00),
81
+ )
82
+ RAMPS: dict[Charset, tuple[tuple[str, float], ...]] = {
83
+ "classic": _CLASSIC,
84
+ "symbols": _SYMBOLS,
85
+ }
86
+ _RAMP_ARRAYS = {
87
+ name: (np.array([c for c, _ in ramp]), np.array([d for _, d in ramp]))
88
+ for name, ramp in RAMPS.items()
89
+ }
90
+
91
+ EDGE_STRENGTH = 0.35
92
+ """Sobel magnitude (blurred luma) above which a pixel counts as part of an edge."""
93
+ EDGE_MIN_PIXELS = 5
94
+ """Thinned edge pixels a cell needs before it is drawn as an outline."""
95
+ CONTRAST_STRETCH = 0.6
96
+ """How far luminance is stretched toward the full ramp (0 keeps, 1 stretches fully)."""
97
+ SATURATION_BOOST = 1.2
98
+ """Thin glyphs show less color than a filled cell, so ink colors are pushed a little."""
99
+ INK_FLOOR = 0.55
100
+ """Lowest brightness of an ink color (its strongest channel, 0..1). Symbol density
101
+ already shows how bright a cell is, so the color only has to carry its hue; without a
102
+ floor, dark surfaces print as nearly invisible symbols."""
103
+ DENSITY_GAMMA = 0.5
104
+ """Curve from luminance to symbol density. Below 1 it lifts dark surfaces off the
105
+ background so they read as texture instead of empty space."""
106
+ FILL_BACKGROUND = 0.55
107
+ """Brightness of the painted background in `fill` style, relative to the cell color."""
108
+ FILL_HIGHLIGHT = 0.35
109
+ """How far `fill` symbols are mixed toward white so they stand out on their own cell."""
110
+ DITHER = 0.05
111
+ """Ordered-dither amplitude added to mid-tone luminance before a symbol is picked. It
112
+ turns smooth gradients into mixed symbols instead of one-symbol bands."""
113
+
114
+ # 4x4 Bayer matrix, centered on zero so dithering does not shift overall brightness.
115
+ _BAYER = (
116
+ np.array([[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]], np.float32)
117
+ + 0.5
118
+ ) / 16 - 0.5
119
+
120
+
121
+ @dataclass(frozen=True, eq=False)
122
+ class SymbolArt:
123
+ """A grid of symbols with a foreground (and optional background) color per cell."""
124
+
125
+ chars: npt.NDArray[np.str_]
126
+ fg: RGBImage
127
+ bg: RGBImage | None
128
+
129
+ @property
130
+ def rows(self) -> int:
131
+ return int(self.chars.shape[0])
132
+
133
+ @property
134
+ def cols(self) -> int:
135
+ return int(self.chars.shape[1])
136
+
137
+ def lines(self) -> list[str]:
138
+ return ["".join(row) for row in self.chars]
139
+
140
+
141
+ def image_to_art(
142
+ pixels: RGBImage,
143
+ cols: int,
144
+ rows: int,
145
+ style: ArtStyle = "ink",
146
+ charset: Charset = "classic",
147
+ edges: bool = False,
148
+ ) -> SymbolArt:
149
+ """Convert `pixels` into `cols` x `rows` cells of colored symbols.
150
+
151
+ Each cell averages the pixels under it, so `pixels` should be rendered larger than
152
+ the grid (see `CELL_WIDTH` and `CELL_HEIGHT`). Leftover pixels at the right and
153
+ bottom edges are dropped.
154
+ """
155
+ if cols <= 0 or rows <= 0:
156
+ raise ValueError(f"grid must be positive, got {cols}x{rows}")
157
+ height, width = pixels.shape[:2]
158
+ cell_h, cell_w = height // rows, width // cols
159
+ if cell_h == 0 or cell_w == 0:
160
+ raise ValueError(
161
+ f"image {width}x{height} is smaller than the {cols}x{rows} grid"
162
+ )
163
+ srgb = pixels[: rows * cell_h, : cols * cell_w].astype(np.float32) / 255.0
164
+
165
+ linear = _srgb_to_linear(srgb)
166
+ cell_color = _linear_to_srgb(_cell_mean(linear, rows, cell_h, cols, cell_w))
167
+ glow = _linear_to_srgb(_bright_mean(linear, rows, cell_h, cols, cell_w))
168
+ density = _stretch(_luma(cell_color)) ** DENSITY_GAMMA
169
+ ramp_chars, ramp_density = _RAMP_ARRAYS[charset]
170
+ chars = ramp_chars[_ramp_index(_dither(density), ramp_density)]
171
+ if edges:
172
+ edge_chars = _edge_chars(_luma(srgb), rows, cell_h, cols, cell_w)
173
+ chars = np.where(edge_chars != "", edge_chars, chars)
174
+
175
+ if style == "fill":
176
+ fg = glow + (1.0 - glow) * FILL_HIGHLIGHT
177
+ return SymbolArt(chars, _to_bytes(fg), _to_bytes(cell_color * FILL_BACKGROUND))
178
+ return SymbolArt(chars, _to_bytes(_ink(glow)), None)
179
+
180
+
181
+ def to_text(art: SymbolArt) -> Text:
182
+ """Build a rich `Text` that prints the art, one terminal line per row."""
183
+ text = Text(no_wrap=True, overflow="crop", end="")
184
+ styles: dict[tuple[int, ...], Style] = {}
185
+ for row in range(art.rows):
186
+ for col in range(art.cols):
187
+ fg = tuple(int(v) for v in art.fg[row, col])
188
+ bg = () if art.bg is None else tuple(int(v) for v in art.bg[row, col])
189
+ key = fg + bg
190
+ style = styles.get(key)
191
+ if style is None:
192
+ style = Style(color=_hex(fg), bgcolor=_hex(bg) if bg else None)
193
+ styles[key] = style
194
+ text.append(str(art.chars[row, col]), style)
195
+ if row < art.rows - 1:
196
+ text.append("\n")
197
+ return text
198
+
199
+
200
+ def _srgb_to_linear(c: FloatArray) -> FloatArray:
201
+ return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
202
+
203
+
204
+ def _linear_to_srgb(c: FloatArray) -> FloatArray:
205
+ c = np.clip(c, 0.0, 1.0)
206
+ return np.where(c <= 0.0031308, c * 12.92, 1.055 * c ** (1 / 2.4) - 0.055)
207
+
208
+
209
+ def _luma(srgb: FloatArray) -> FloatArray:
210
+ weights = np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
211
+ result: FloatArray = srgb @ weights
212
+ return result
213
+
214
+
215
+ def _cell_mean(
216
+ values: FloatArray, rows: int, cell_h: int, cols: int, cell_w: int
217
+ ) -> FloatArray:
218
+ shaped = values.reshape(rows, cell_h, cols, cell_w, *values.shape[2:])
219
+ result: FloatArray = shaped.mean(axis=(1, 3))
220
+ return result
221
+
222
+
223
+ def _bright_mean(
224
+ linear: FloatArray, rows: int, cell_h: int, cols: int, cell_w: int
225
+ ) -> FloatArray:
226
+ """Cell color weighted toward its brightest pixels.
227
+
228
+ A symbol's ink stands for the light in its cell, so a small lit window or star
229
+ should give the symbol its color instead of being averaged into the dark around
230
+ it. Weighting by squared luminance does that and leaves flat cells unchanged.
231
+ """
232
+ weight = (_luma(linear) ** 2 + 1e-6)[..., None]
233
+ total = _cell_mean(linear * weight, rows, cell_h, cols, cell_w)
234
+ result: FloatArray = total / _cell_mean(weight, rows, cell_h, cols, cell_w)
235
+ return result
236
+
237
+
238
+ def _stretch(luma: FloatArray) -> FloatArray:
239
+ low, high = (float(v) for v in np.percentile(luma, [2, 98]))
240
+ if high - low < 1e-3:
241
+ return luma
242
+ stretched = np.clip((luma - low) / (high - low), 0.0, 1.0)
243
+ result: FloatArray = luma + (stretched - luma) * CONTRAST_STRETCH
244
+ return result
245
+
246
+
247
+ def _dither(luma: FloatArray) -> FloatArray:
248
+ """Add the Bayer pattern, fading it out toward pure black and pure white."""
249
+ rows, cols = luma.shape
250
+ pattern = np.tile(_BAYER, (rows // 4 + 1, cols // 4 + 1))[:rows, :cols]
251
+ midtone = 4.0 * luma * (1.0 - luma)
252
+ result: FloatArray = (luma + pattern * 2.0 * DITHER * midtone).astype(np.float32)
253
+ return result
254
+
255
+
256
+ def _ramp_index(
257
+ target: FloatArray, density: npt.NDArray[np.float64]
258
+ ) -> npt.NDArray[np.intp]:
259
+ """Index of the ramp character whose ink density is closest to each target."""
260
+ index: npt.NDArray[np.intp] = np.abs(target[..., None] - density).argmin(axis=-1)
261
+ return index
262
+
263
+
264
+ def _edge_chars(
265
+ luma: FloatArray, rows: int, cell_h: int, cols: int, cell_w: int
266
+ ) -> npt.NDArray[np.str_]:
267
+ """Outline glyph per cell (`| / - _ \\`), or "" for cells without a strong edge."""
268
+ blurred = _blur(luma)
269
+ gx, gy = _sobel(blurred)
270
+ magnitude = np.hypot(gx, gy)
271
+ # Gradient angle folded into [0, 180): 0 means brightness changes left to right, so
272
+ # the edge itself runs vertically. Image rows grow downward.
273
+ angle = np.degrees(np.arctan2(gy, gx)) % 180.0
274
+ bins = ((angle + 22.5) // 45.0).astype(np.intp) % 4 # 0: | 1: / 2: - 3: \
275
+ strong = (magnitude > EDGE_STRENGTH) & _ridge(magnitude, bins)
276
+
277
+ def per_cell(mask: npt.NDArray[np.bool_]) -> FloatArray:
278
+ return _cell_mean(mask.astype(np.float64), rows, cell_h, cols, cell_w)
279
+
280
+ coverage = np.stack([per_cell(strong & (bins == b)) for b in range(4)], axis=-1)
281
+ best = coverage.argmax(axis=-1)
282
+ is_edge = coverage.sum(axis=-1) * cell_h * cell_w >= EDGE_MIN_PIXELS
283
+
284
+ # A horizontal edge in the lower part of its cell reads better as "_" than "-".
285
+ row_pos = np.broadcast_to(
286
+ np.arange(luma.shape[0], dtype=np.float64)[:, None], luma.shape
287
+ )
288
+ row_in_cell = (row_pos % cell_h) / max(cell_h - 1, 1)
289
+ horizontal = strong & (bins == 2)
290
+ count = per_cell(horizontal)
291
+ weighted = _cell_mean(row_in_cell * horizontal, rows, cell_h, cols, cell_w)
292
+ mean_pos = np.divide(weighted, count, out=np.zeros_like(count), where=count > 0)
293
+ glyphs = np.array(["|", "/", "-", "\\"])[best]
294
+ glyphs = np.where((best == 2) & (mean_pos > 0.7), "_", glyphs)
295
+ return np.where(is_edge, glyphs, "")
296
+
297
+
298
+ def _ridge(magnitude: FloatArray, bins: npt.NDArray[np.intp]) -> npt.NDArray[np.bool_]:
299
+ """Keep only pixels that peak across their edge (non-maximum suppression).
300
+
301
+ Blurring smears an edge over several pixels, which would mark the cells on both
302
+ sides of it. Thinning to a one-pixel ridge puts each edge in exactly one cell; the
303
+ `>=` / `>` pair breaks ties the same way every time.
304
+ """
305
+ height, width = magnitude.shape
306
+ padded = np.pad(magnitude, 1)
307
+
308
+ def neighbor(dy: int, dx: int) -> FloatArray:
309
+ result: FloatArray = padded[1 + dy : 1 + dy + height, 1 + dx : 1 + dx + width]
310
+ return result
311
+
312
+ # Neighbors along the gradient, i.e. across the edge, for each direction bin.
313
+ across = {
314
+ 0: ((0, -1), (0, 1)),
315
+ 1: ((-1, -1), (1, 1)),
316
+ 2: ((-1, 0), (1, 0)),
317
+ 3: ((-1, 1), (1, -1)),
318
+ }
319
+ keep = np.zeros(magnitude.shape, dtype=bool)
320
+ for direction, (before, after) in across.items():
321
+ peak = (magnitude >= neighbor(*before)) & (magnitude > neighbor(*after))
322
+ keep |= (bins == direction) & peak
323
+ return keep
324
+
325
+
326
+ def _blur(image: FloatArray, sigma: float = 1.5) -> FloatArray:
327
+ radius = int(3 * sigma)
328
+ offsets = np.arange(-radius, radius + 1)
329
+ kernel = np.exp(-(offsets**2) / (2 * sigma**2)).astype(np.float32)
330
+ kernel /= kernel.sum()
331
+ for axis in (0, 1):
332
+ pad = [(radius, radius) if a == axis else (0, 0) for a in (0, 1)]
333
+ padded = np.pad(image, pad, "edge")
334
+ length = image.shape[axis]
335
+ blurred = np.zeros_like(image)
336
+ for i, weight in enumerate(kernel):
337
+ blurred += weight * np.take(padded, np.arange(i, i + length), axis=axis)
338
+ image = blurred
339
+ return image
340
+
341
+
342
+ def _sobel(image: FloatArray) -> tuple[FloatArray, FloatArray]:
343
+ p = np.pad(image, 1, "edge")
344
+ top_left, top, top_right = p[:-2, :-2], p[:-2, 1:-1], p[:-2, 2:]
345
+ left, right = p[1:-1, :-2], p[1:-1, 2:]
346
+ bottom_left, bottom, bottom_right = p[2:, :-2], p[2:, 1:-1], p[2:, 2:]
347
+ gx = (top_right + 2 * right + bottom_right) - (top_left + 2 * left + bottom_left)
348
+ gy = (bottom_left + 2 * bottom + bottom_right) - (top_left + 2 * top + top_right)
349
+ return gx, gy
350
+
351
+
352
+ def _ink(color: FloatArray) -> FloatArray:
353
+ gray = _luma(color)[..., None]
354
+ saturated = np.clip(gray + (color - gray) * SATURATION_BOOST, 0.0, 1.0)
355
+ value = saturated.max(axis=-1, keepdims=True)
356
+ lifted = INK_FLOOR + (1.0 - INK_FLOOR) * value
357
+ scale = np.divide(lifted, value, out=np.ones_like(value), where=value > 0.02)
358
+ result: FloatArray = np.clip(saturated * scale, 0.0, 1.0).astype(np.float32)
359
+ return result
360
+
361
+
362
+ def _to_bytes(color: FloatArray) -> RGBImage:
363
+ return np.clip(np.rint(color * 255.0), 0, 255).astype(np.uint8)
364
+
365
+
366
+ def _hex(rgb: tuple[int, ...]) -> str:
367
+ red, green, blue = rgb
368
+ return f"#{red:02x}{green:02x}{blue:02x}"
sepia/cli.py ADDED
@@ -0,0 +1,58 @@
1
+ """Command-line entry point for `sepia`."""
2
+
3
+ import argparse
4
+ import contextlib
5
+ import sys
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+
9
+ from sepia import __version__
10
+
11
+
12
+ def build_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(
14
+ prog="sepia",
15
+ description=(
16
+ "Describe a picture and watch it appear as colored text in a second "
17
+ "terminal window."
18
+ ),
19
+ )
20
+ parser.add_argument("--version", action="version", version=f"sepia {__version__}")
21
+ parser.add_argument(
22
+ "--download",
23
+ action="store_true",
24
+ help="download the local image model (about 16 GB) used when the API fails",
25
+ )
26
+ # Internal: the picture window runs `sepia --viewer <session file>`.
27
+ parser.add_argument("--viewer", type=Path, help=argparse.SUPPRESS)
28
+ return parser
29
+
30
+
31
+ def main(argv: Sequence[str] | None = None) -> int:
32
+ _use_utf8()
33
+ args = build_parser().parse_args(argv)
34
+ if args.viewer is not None:
35
+ from sepia.viewer import run_viewer
36
+
37
+ return run_viewer(args.viewer)
38
+ if args.download:
39
+ from sepia.painter import PainterError, download_local_model
40
+
41
+ try:
42
+ print(f"downloaded to {download_local_model()}")
43
+ except PainterError as exc:
44
+ print(exc, file=sys.stderr)
45
+ return 1
46
+ return 0
47
+ from sepia.repl import run_repl
48
+
49
+ return run_repl()
50
+
51
+
52
+ def _use_utf8() -> None:
53
+ """Block and box glyphs need UTF-8 even where the console code page is not."""
54
+ for stream in (sys.stdout, sys.stderr):
55
+ reconfigure = getattr(stream, "reconfigure", None)
56
+ if reconfigure is not None:
57
+ with contextlib.suppress(OSError, ValueError):
58
+ reconfigure(encoding="utf-8")
sepia/config.py ADDED
@@ -0,0 +1,105 @@
1
+ """Settings and API key lookup."""
2
+
3
+ import contextlib
4
+ import os
5
+ import stat
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Literal, cast, get_args
10
+
11
+ from dotenv import load_dotenv
12
+ from platformdirs import user_config_path
13
+
14
+ from sepia.ascii_art import ArtStyle, Charset
15
+ from sepia.painter import DEFAULT_IMAGE_MODEL
16
+
17
+ Effort = Literal["low", "medium", "high", "xhigh", "max"]
18
+
19
+ DEFAULT_MODEL = "claude-haiku-5-5"
20
+ DEFAULT_EFFORT: Effort = "low"
21
+ DEFAULT_STYLE: ArtStyle = "ink"
22
+ DEFAULT_CHARSET: Charset = "classic"
23
+ API_KEY_VAR = "ANTHROPIC_API_KEY"
24
+
25
+
26
+ class ConfigError(ValueError):
27
+ """A setting has a value sepia cannot use."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Settings:
32
+ model: str = DEFAULT_MODEL
33
+ effort: Effort = DEFAULT_EFFORT
34
+ style: ArtStyle = DEFAULT_STYLE
35
+ charset: Charset = DEFAULT_CHARSET
36
+ image_model: str = DEFAULT_IMAGE_MODEL
37
+
38
+
39
+ def config_dir() -> Path:
40
+ """Per-user directory for sepia's key file, history and session files."""
41
+ return user_config_path("sepia", appauthor=False)
42
+
43
+
44
+ def user_env_file() -> Path:
45
+ return config_dir() / ".env"
46
+
47
+
48
+ def load_environment(cwd: Path | None = None) -> None:
49
+ """Fill `os.environ` from `.env` files without overriding what is already set.
50
+
51
+ Lookup order: the real environment, then `.env` in the working directory (handy
52
+ while developing), then the `.env` in the user config directory.
53
+ """
54
+ load_dotenv((cwd or Path.cwd()) / ".env", override=False)
55
+ load_dotenv(user_env_file(), override=False)
56
+
57
+
58
+ def load_settings(environ: Mapping[str, str] = os.environ) -> Settings:
59
+ model = environ.get("SEPIA_MODEL", "").strip() or DEFAULT_MODEL
60
+ image_model = environ.get("SEPIA_IMAGE_MODEL", "").strip() or DEFAULT_IMAGE_MODEL
61
+ effort = _choice(environ, "SEPIA_EFFORT", get_args(Effort), DEFAULT_EFFORT)
62
+ style = _choice(environ, "SEPIA_STYLE", get_args(ArtStyle), DEFAULT_STYLE)
63
+ charset = _choice(environ, "SEPIA_CHARSET", get_args(Charset), DEFAULT_CHARSET)
64
+ return Settings(
65
+ model=model,
66
+ effort=cast(Effort, effort),
67
+ style=cast(ArtStyle, style),
68
+ charset=cast(Charset, charset),
69
+ image_model=image_model,
70
+ )
71
+
72
+
73
+ def api_key(environ: Mapping[str, str] = os.environ) -> str | None:
74
+ return environ.get(API_KEY_VAR, "").strip() or None
75
+
76
+
77
+ def pollinations_key(environ: Mapping[str, str] = os.environ) -> str | None:
78
+ """Optional key from enter.pollinations.ai: no logo and better models."""
79
+ return environ.get("POLLINATIONS_KEY", "").strip() or None
80
+
81
+
82
+ def save_api_key(key: str, path: Path | None = None) -> Path:
83
+ """Store `key` in the user config `.env`, readable only by its owner."""
84
+ target = path or user_env_file()
85
+ target.parent.mkdir(parents=True, exist_ok=True)
86
+ kept: list[str] = []
87
+ if target.exists():
88
+ kept = [
89
+ line
90
+ for line in target.read_text(encoding="utf-8").splitlines()
91
+ if not line.startswith(f"{API_KEY_VAR}=")
92
+ ]
93
+ target.write_text("\n".join([*kept, f"{API_KEY_VAR}={key.strip()}"]) + "\n")
94
+ with contextlib.suppress(OSError): # Windows ignores POSIX modes
95
+ target.chmod(stat.S_IRUSR | stat.S_IWUSR)
96
+ return target
97
+
98
+
99
+ def _choice(
100
+ environ: Mapping[str, str], name: str, allowed: tuple[str, ...], default: str
101
+ ) -> str:
102
+ value = environ.get(name, "").strip().lower() or default
103
+ if value not in allowed:
104
+ raise ConfigError(f"{name} must be one of {', '.join(allowed)}; got {value!r}")
105
+ return value