markserv 1.0.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.
markserv/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ __all__ = ["main"]
markserv/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
markserv/app.py ADDED
@@ -0,0 +1,4 @@
1
+ from .site import WatchPathFilter, build_config
2
+ from .web import create_app
3
+
4
+ __all__ = ["WatchPathFilter", "build_config", "create_app"]
markserv/cli.py ADDED
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import logging
5
+ import os
6
+ import select
7
+ import sys
8
+ import threading
9
+ import webbrowser
10
+ from collections.abc import Mapping
11
+ from pathlib import Path
12
+ from typing import Annotated, Any, Protocol
13
+
14
+ import uvicorn
15
+ from cyclopts import App, Parameter
16
+ from cyclopts.help import PlainFormatter
17
+ from cyclopts.token import Token
18
+ from rich.console import Console
19
+ from rich.logging import RichHandler
20
+ from uvicorn import Config, Server
21
+
22
+ from .app import create_app
23
+ from .site import build_config, build_file_site
24
+
25
+
26
+ class StoppableServer(Protocol):
27
+ should_exit: bool
28
+
29
+ def run(self) -> None: ...
30
+
31
+
32
+ console = Console(stderr=True)
33
+ QUIT_KEYS = {"q", "Q", "\x1b"}
34
+ DEFAULT_HOST = "localhost"
35
+ DEFAULT_PORT = 4422
36
+ PYTHON_RELOAD_ENV_VAR = "MARKSERV_PYTHON_RELOAD"
37
+ TARGET_ENV_VAR = "MARKSERV_TARGET"
38
+ PYTHON_RELOAD_DIR = Path(__file__).resolve().parent
39
+
40
+ app = App(
41
+ name="markserv",
42
+ help="Render a folder of GitHub-flavored markdown with live reload.",
43
+ help_formatter=PlainFormatter(),
44
+ result_action="return_value",
45
+ )
46
+
47
+
48
+ def _validate_target(_type_: Any, tokens: tuple[Token, ...]) -> Path:
49
+ raw_path = Path(tokens[0].value)
50
+ build_config(raw_path)
51
+ return raw_path
52
+
53
+
54
+ def configure_logging() -> None:
55
+ logging.basicConfig(
56
+ level=logging.INFO,
57
+ format="%(message)s",
58
+ handlers=[
59
+ RichHandler(
60
+ show_time=False,
61
+ show_level=False,
62
+ show_path=False,
63
+ markup=False,
64
+ rich_tracebacks=True,
65
+ console=console,
66
+ )
67
+ ],
68
+ force=True,
69
+ )
70
+
71
+
72
+ def browser_url(host: str, port: int) -> str:
73
+ public_host = "localhost" if host in {"127.0.0.1", "0.0.0.0", "localhost"} else host
74
+ return f"http://{public_host}:{port}"
75
+
76
+
77
+ def python_reload_enabled() -> bool:
78
+ value = os.environ.get(PYTHON_RELOAD_ENV_VAR, "")
79
+ return value.lower() in {"1", "true", "yes", "on"}
80
+
81
+
82
+ @contextlib.contextmanager
83
+ def temporary_env(updates: Mapping[str, str]) -> Any:
84
+ previous = {key: os.environ.get(key) for key in updates}
85
+ os.environ.update(updates)
86
+ try:
87
+ yield
88
+ finally:
89
+ for key, value in previous.items():
90
+ if value is None:
91
+ os.environ.pop(key, None)
92
+ else:
93
+ os.environ[key] = value
94
+
95
+
96
+ def print_startup_banner(*, source: str, root_dir: str, url: str, open_browser: bool, python_reload: bool) -> None:
97
+ quit_hint = (
98
+ "Press Ctrl+C to quit."
99
+ if python_reload
100
+ else "Press Q or Esc to quit."
101
+ if _supports_quit_prompt()
102
+ else "Press Ctrl+C to quit."
103
+ )
104
+ browser_hint = "Browser opens automatically." if open_browser else "Browser auto-open disabled."
105
+ reload_hint = "Python reload enabled via MARKSERV_PYTHON_RELOAD." if python_reload else None
106
+ display_url = url.removeprefix("http://")
107
+
108
+ console.print(f"[bold cyan]markserv[/] serving {source}")
109
+ console.print(f"[cyan]root[/] {root_dir}")
110
+ console.print(f"[cyan]url[/] [link={url}][underline]{display_url}[/underline][/link]")
111
+ console.print(f"[dim]{browser_hint}[/]")
112
+ if reload_hint is not None:
113
+ console.print(f"[dim]{reload_hint}[/]")
114
+ console.print(f"[dim]{quit_hint}[/]")
115
+
116
+
117
+ def create_server(app: Any, *, host: str, port: int) -> Server:
118
+ return Server(
119
+ Config(
120
+ app,
121
+ host=host,
122
+ port=port,
123
+ log_level="warning",
124
+ access_log=False,
125
+ log_config=None,
126
+ )
127
+ )
128
+
129
+
130
+ def run_python_reloading_server(app_factory_import: str, *, host: str, port: int) -> None:
131
+ uvicorn.run(
132
+ app_factory_import,
133
+ factory=True,
134
+ host=host,
135
+ port=port,
136
+ reload=True,
137
+ reload_dirs=[str(PYTHON_RELOAD_DIR)],
138
+ log_level="warning",
139
+ access_log=False,
140
+ log_config=None,
141
+ )
142
+
143
+
144
+ def _supports_quit_prompt() -> bool:
145
+ return sys.stdin.isatty() and os.name != "nt"
146
+
147
+
148
+ def _request_server_shutdown(server: StoppableServer, stop_event: threading.Event) -> None:
149
+ if stop_event.is_set():
150
+ return
151
+ console.print("[dim]Stopping server...[/dim]")
152
+ server.should_exit = True
153
+ stop_event.set()
154
+
155
+
156
+ def _listen_for_quit_keys(server: StoppableServer, stop_event: threading.Event) -> None:
157
+ import termios
158
+ import tty
159
+
160
+ with contextlib.suppress(termios.error, ValueError, OSError):
161
+ fd = sys.stdin.fileno()
162
+ original_attrs = termios.tcgetattr(fd)
163
+ try:
164
+ tty.setcbreak(fd)
165
+ while not stop_event.is_set():
166
+ readable, _writable, _errors = select.select([sys.stdin], [], [], 0.1)
167
+ if not readable:
168
+ continue
169
+ key = sys.stdin.read(1)
170
+ if key in QUIT_KEYS:
171
+ _request_server_shutdown(server, stop_event)
172
+ return
173
+ finally:
174
+ termios.tcsetattr(fd, termios.TCSADRAIN, original_attrs)
175
+
176
+
177
+ def run_server(server: StoppableServer) -> None:
178
+ stop_event = threading.Event()
179
+ listener: threading.Thread | None = None
180
+
181
+ if _supports_quit_prompt():
182
+ listener = threading.Thread(
183
+ target=_listen_for_quit_keys,
184
+ args=(server, stop_event),
185
+ daemon=True,
186
+ name="markserv-quit-listener",
187
+ )
188
+ listener.start()
189
+
190
+ try:
191
+ server.run()
192
+ finally:
193
+ stop_event.set()
194
+ if listener is not None:
195
+ listener.join(timeout=0.2)
196
+
197
+
198
+ def serve_application(
199
+ application: Any | None,
200
+ *,
201
+ source: str,
202
+ root_dir: str,
203
+ host: str,
204
+ port: int,
205
+ open_browser: bool,
206
+ app_factory_import: str | None = None,
207
+ env_updates: Mapping[str, str] | None = None,
208
+ ) -> None:
209
+ configure_logging()
210
+ url = browser_url(host, port)
211
+ python_reload = python_reload_enabled()
212
+ print_startup_banner(
213
+ source=source,
214
+ root_dir=root_dir,
215
+ url=url,
216
+ open_browser=open_browser,
217
+ python_reload=python_reload,
218
+ )
219
+
220
+ if open_browser:
221
+ threading.Timer(0.8, lambda: webbrowser.open(url)).start()
222
+
223
+ if python_reload:
224
+ if app_factory_import is None:
225
+ raise ValueError("app_factory_import is required when Python reload is enabled")
226
+ with temporary_env(dict(env_updates or {})):
227
+ run_python_reloading_server(app_factory_import, host=host, port=port)
228
+ return
229
+
230
+ if application is None:
231
+ raise ValueError("application is required when Python reload is disabled")
232
+
233
+ server = create_server(application, host=host, port=port)
234
+ run_server(server)
235
+
236
+
237
+ @app.default
238
+ def serve(
239
+ path: Annotated[
240
+ Path,
241
+ Parameter(
242
+ converter=_validate_target,
243
+ help="Markdown file or directory to serve.",
244
+ ),
245
+ ] = Path("."),
246
+ /,
247
+ *,
248
+ host: Annotated[str, Parameter(help="Host interface to bind.")] = DEFAULT_HOST,
249
+ port: Annotated[int, Parameter(help="Port to listen on.")] = DEFAULT_PORT,
250
+ open_browser: Annotated[
251
+ bool,
252
+ Parameter(name="--open", help="Open the app in your default browser after the server starts."),
253
+ ] = True,
254
+ ) -> None:
255
+ """Serve GitHub-flavored markdown from a file or directory."""
256
+ config = build_config(path)
257
+ site = build_file_site(config)
258
+ serve_application(
259
+ None if python_reload_enabled() else create_app(site),
260
+ source=str(config.source),
261
+ root_dir=str(config.root_dir),
262
+ host=host,
263
+ port=port,
264
+ open_browser=open_browser,
265
+ app_factory_import="markserv.cli:create_app_from_env",
266
+ env_updates={TARGET_ENV_VAR: str(config.source)},
267
+ )
268
+
269
+
270
+ def create_app_from_env() -> Any:
271
+ target = os.environ.get(TARGET_ENV_VAR)
272
+ if not target:
273
+ raise RuntimeError(f"{TARGET_ENV_VAR} must be set when Python reload is enabled")
274
+ config = build_config(Path(target))
275
+ return create_app(build_file_site(config))
276
+
277
+
278
+ def main(argv: list[str] | None = None) -> None:
279
+ app(tokens=argv)
markserv/demo.py ADDED
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ from cyclopts import App, Parameter
6
+ from cyclopts.help import PlainFormatter
7
+
8
+ from .app import create_app
9
+ from .cli import DEFAULT_HOST, DEFAULT_PORT, python_reload_enabled, serve_application
10
+ from .site import SyntheticSite
11
+
12
+ DEMO_DOCUMENTS = {
13
+ "README.md": """# markserv demo
14
+
15
+ Welcome to the built-in demo site.
16
+
17
+ This sample tree exists so you can quickly try the renderer, sidebar navigation, live reload, and theme control.
18
+
19
+ ## Try these pages
20
+
21
+ - [Quickstart](guides/quickstart.md)
22
+ - [GitHub-flavored markdown examples](guides/features/gfm.md)
23
+ - [Nested navigation](guides/nested/deep-dive.md)
24
+ - [Reference notes](reference/notes.md)
25
+
26
+ ## What to try
27
+
28
+ - Toggle between **system**, **light**, and **dark** themes.
29
+ - Follow links between nested folders.
30
+ - Resize the window to see the responsive layout.
31
+
32
+ > markserv is meant to feel nice for local docs, READMEs, and note collections.
33
+ """,
34
+ "guides/quickstart.md": """# Quickstart
35
+
36
+ This page gives you a quick way to verify the basics.
37
+
38
+ ## Checklist
39
+
40
+ - [x] Markdown is rendered with GitHub-style formatting
41
+ - [x] Sidebar navigation is generated from nested folders
42
+ - [x] Theme choice is stored in browser storage
43
+ - [x] Synthetic demo content renders without touching the filesystem
44
+
45
+ ## Code block
46
+
47
+ ```python
48
+ from pathlib import Path
49
+
50
+ root = Path("docs")
51
+ print(root.resolve())
52
+ ```
53
+
54
+ ## Table
55
+
56
+ | Feature | Status |
57
+ | --- | --- |
58
+ | Live preview | Ready |
59
+ | Sidebar nav | Ready |
60
+ | Theme picker | Ready |
61
+
62
+ Continue to the [feature examples](features/gfm.md).
63
+ """,
64
+ "guides/features/gfm.md": """# GitHub-flavored markdown examples
65
+
66
+ This page exercises a few GFM features.
67
+
68
+ ## Formatting
69
+
70
+ You can render **bold text**, *italic text*, ~~strikethrough~~, and `inline code`.
71
+
72
+ ## Blockquote
73
+
74
+ > Markdown previews should be quick to open and pleasant to read.
75
+ >
76
+ > — local docs enjoyer
77
+
78
+ ## Task list
79
+
80
+ - [x] Tables
81
+ - [x] Fenced code blocks
82
+ - [x] Blockquotes
83
+ - [x] Nested navigation
84
+
85
+ ## Ordered list
86
+
87
+ 1. Open the demo.
88
+ 2. Change the selected page.
89
+ 3. Explore the nested tree.
90
+
91
+ Back to the [demo home](../../README.md).
92
+ """,
93
+ "guides/nested/deep-dive.md": """# Deep dive
94
+
95
+ This file lives in a nested folder so you can inspect sidebar behavior.
96
+
97
+ ## Notes
98
+
99
+ Nested folders are shown as expandable sections in the sidebar.
100
+
101
+ ### Another level
102
+
103
+ The current page should stay highlighted while its parent folders remain open.
104
+
105
+ See the [reference notes](../../reference/notes.md) for a simple cross-link.
106
+ """,
107
+ "reference/notes.md": """# Reference notes
108
+
109
+ A small page for cross-link testing.
110
+
111
+ ## Relative links
112
+
113
+ - [Back home](../README.md)
114
+ - [Quickstart](../guides/quickstart.md)
115
+ - [Deep dive](../guides/nested/deep-dive.md)
116
+
117
+ ## Inline HTML
118
+
119
+ GitHub-flavored markdown rendering should also tolerate small inline HTML snippets like <kbd>Ctrl</kbd> + <kbd>C</kbd>.
120
+ """,
121
+ }
122
+
123
+ __all__ = ["DEFAULT_HOST", "DEFAULT_PORT", "build_demo_site", "create_demo_app", "main", "serve_demo"]
124
+
125
+ app = App(
126
+ name="markserv.demo",
127
+ help="Serve the built-in synthetic markdown demo.",
128
+ help_formatter=PlainFormatter(),
129
+ result_action="return_value",
130
+ )
131
+
132
+
133
+ def build_demo_site() -> SyntheticSite:
134
+ return SyntheticSite(
135
+ name="markserv demo",
136
+ root_label="built-in demo",
137
+ documents=DEMO_DOCUMENTS,
138
+ default_doc="README.md",
139
+ )
140
+
141
+
142
+ def create_demo_app() -> object:
143
+ return create_app(build_demo_site())
144
+
145
+
146
+ def serve_demo(*, host: str, port: int, open_browser: bool) -> None:
147
+ site = build_demo_site()
148
+ serve_application(
149
+ None if python_reload_enabled() else create_app(site),
150
+ source="markserv demo",
151
+ root_dir=site.root_label,
152
+ host=host,
153
+ port=port,
154
+ open_browser=open_browser,
155
+ app_factory_import="markserv.demo:create_demo_app",
156
+ )
157
+
158
+
159
+ @app.default
160
+ def serve(
161
+ *,
162
+ host: Annotated[str, Parameter(help="Host interface to bind.")] = DEFAULT_HOST,
163
+ port: Annotated[int, Parameter(help="Port to listen on.")] = DEFAULT_PORT,
164
+ open_browser: Annotated[
165
+ bool,
166
+ Parameter(name="--open", help="Open the app in your default browser after the server starts."),
167
+ ] = True,
168
+ ) -> None:
169
+ """Serve the built-in synthetic markdown demo."""
170
+ serve_demo(host=host, port=port, open_browser=open_browser)
171
+
172
+
173
+ def main(argv: list[str] | None = None) -> None:
174
+ app(tokens=argv)
175
+
176
+
177
+ if __name__ == "__main__":
178
+ main()
markserv/icons.py ADDED
@@ -0,0 +1,182 @@
1
+ """Generate per-page favicon PNGs using a Clifford strange attractor.
2
+
3
+ Zero external dependencies -- uses only stdlib (hashlib, math, struct, zlib).
4
+ Each page's content hash maps to a unique attractor that produces an
5
+ organic, visually distinct icon.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import math
12
+ import struct
13
+ import zlib
14
+
15
+ # Curated Clifford attractor parameters known to produce rich forms.
16
+ _GOOD_PARAMS: list[tuple[float, float, float, float]] = [
17
+ (1.5, -1.8, 1.6, 0.9),
18
+ (-1.7, 1.8, -0.9, -1.4),
19
+ (-1.7, 1.3, -0.1, -1.21),
20
+ (-1.4, 1.6, 1.0, 0.7),
21
+ (1.7, 1.7, 0.6, 1.2),
22
+ (-1.9, -1.9, -1.9, -1.0),
23
+ (1.8, -1.5, 1.4, -0.8),
24
+ (-1.2, 1.9, 0.3, -1.5),
25
+ (1.1, -1.3, 1.7, -1.8),
26
+ (-1.8, -1.0, -1.6, 0.6),
27
+ (1.6, -0.6, -1.2, 1.6),
28
+ (-1.5, 1.4, 1.1, -1.3),
29
+ (1.3, 1.7, -0.5, -1.6),
30
+ (-0.8, 1.9, -1.7, 1.1),
31
+ (1.9, -1.1, 0.8, -1.7),
32
+ (-1.6, -1.4, 1.8, 0.4),
33
+ ]
34
+
35
+ _COLOR_STOPS_T = (0.00, 0.05, 0.15, 0.30, 0.50, 0.65, 0.80, 0.92, 1.00)
36
+ _COLOR_STOPS_C = (
37
+ (13, 17, 23),
38
+ (20, 30, 60),
39
+ (40, 70, 140),
40
+ (70, 130, 230),
41
+ (100, 110, 255),
42
+ (150, 100, 255),
43
+ (200, 150, 255),
44
+ (240, 220, 255),
45
+ (255, 245, 250),
46
+ )
47
+
48
+
49
+ def _params_from_hash(digest: bytes, attempt: int = 0) -> tuple[float, float, float, float]:
50
+ idx = (digest[0] + attempt) % len(_GOOD_PARAMS)
51
+ base = _GOOD_PARAMS[idx]
52
+ return tuple(base[i] + (digest[i + 1] / 255.0 - 0.5) * 0.16 for i in range(4)) # type: ignore[return-value]
53
+
54
+
55
+ def _hue_shift_from_hash(digest: bytes) -> float:
56
+ return (digest[8] / 255.0) * 0.4 - 0.2
57
+
58
+
59
+ def _clifford_density(
60
+ a: float,
61
+ b: float,
62
+ c: float,
63
+ d: float,
64
+ res: int,
65
+ n_points: int,
66
+ ) -> list[list[int]]:
67
+ sin, cos = math.sin, math.cos
68
+ x, y = 0.1, 0.1
69
+
70
+ # Warmup + bounds
71
+ xs, ys = [], []
72
+ for _ in range(500):
73
+ x, y = sin(a * y) + c * cos(a * x), sin(b * x) + d * cos(b * y)
74
+ xs.append(x)
75
+ ys.append(y)
76
+
77
+ x_min, x_max = min(xs), max(xs)
78
+ y_min, y_max = min(ys), max(ys)
79
+ span = max(x_max - x_min, y_max - y_min)
80
+ if span < 0.01:
81
+ span = 4.0
82
+ pad = span * 0.12
83
+ span += 2 * pad
84
+ cx, cy = (x_min + x_max) / 2, (y_min + y_max) / 2
85
+ x_lo, y_lo = cx - span / 2, cy - span / 2
86
+ scale = (res - 1) / span
87
+
88
+ grid = [[0] * res for _ in range(res)]
89
+ for _ in range(n_points):
90
+ x, y = sin(a * y) + c * cos(a * x), sin(b * x) + d * cos(b * y)
91
+ bx = int((x - x_lo) * scale)
92
+ by = int((y - y_lo) * scale)
93
+ if 0 <= bx < res and 0 <= by < res:
94
+ grid[by][bx] += 1
95
+
96
+ return grid
97
+
98
+
99
+ def _grid_is_interesting(grid: list[list[int]], res: int) -> bool:
100
+ filled = sum(1 for row in grid for v in row if v > 0)
101
+ return filled > res * res * 0.05 # at least 5% of pixels hit
102
+
103
+
104
+ def _colorize_rgba(grid: list[list[int]], hue_shift: float) -> list[list[tuple[int, int, int, int]]]:
105
+ """Colorize with alpha derived from density. Background is fully transparent."""
106
+ max_raw = max(max(row) for row in grid)
107
+ if max_raw == 0:
108
+ return [[(0, 0, 0, 0)] * len(grid[0]) for _ in grid]
109
+
110
+ log_max = math.log1p(max_raw)
111
+ stops_t = _COLOR_STOPS_T
112
+ stops_c = _COLOR_STOPS_C
113
+
114
+ def lerp_color(t: float) -> tuple[int, int, int]:
115
+ # Skip the first two dark stops -- start from visible blue
116
+ t = max(0.0, min(1.0, t + hue_shift * t))
117
+ for i in range(len(stops_t) - 1):
118
+ if t <= stops_t[i + 1]:
119
+ t0, t1 = stops_t[i], stops_t[i + 1]
120
+ c0, c1 = stops_c[i], stops_c[i + 1]
121
+ f = (t - t0) / (t1 - t0) if t1 > t0 else 0.0
122
+ return (
123
+ min(255, max(0, int(c0[0] + (c1[0] - c0[0]) * f))),
124
+ min(255, max(0, int(c0[1] + (c1[1] - c0[1]) * f))),
125
+ min(255, max(0, int(c0[2] + (c1[2] - c0[2]) * f))),
126
+ )
127
+ return stops_c[-1]
128
+
129
+ result: list[list[tuple[int, int, int, int]]] = []
130
+ for row in grid:
131
+ rgba_row: list[tuple[int, int, int, int]] = []
132
+ for v in row:
133
+ if v == 0:
134
+ rgba_row.append((0, 0, 0, 0))
135
+ else:
136
+ t = math.log1p(v) / log_max
137
+ r, g, b = lerp_color(t)
138
+ # Alpha proportional to density -- faint wisps are translucent, hot spots are opaque
139
+ a = min(255, int(t * 320))
140
+ rgba_row.append((r, g, b, a))
141
+ result.append(rgba_row)
142
+ return result
143
+
144
+
145
+ def _encode_png_rgba(pixels: list[list[tuple[int, int, int, int]]], width: int, height: int) -> bytes:
146
+ """Encode RGBA PNG using only stdlib."""
147
+
148
+ def chunk(ctype: bytes, data: bytes) -> bytes:
149
+ c = ctype + data
150
+ return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
151
+
152
+ raw = bytearray()
153
+ for row in pixels:
154
+ raw.append(0) # filter: none
155
+ for r, g, b, a in row:
156
+ raw.extend((r, g, b, a))
157
+
158
+ return (
159
+ b"\x89PNG\r\n\x1a\n"
160
+ + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
161
+ + chunk(b"IDAT", zlib.compress(bytes(raw), 9))
162
+ + chunk(b"IEND", b"")
163
+ )
164
+
165
+
166
+ def generate_favicon(content: str, res: int = 48, n_points: int = 150_000) -> bytes:
167
+ """Generate a unique favicon PNG from content. Pure stdlib, ~30ms at 48px."""
168
+ digest = hashlib.sha256(content.encode()).digest()
169
+ hue_shift = _hue_shift_from_hash(digest)
170
+
171
+ grid: list[list[int]] | None = None
172
+ for attempt in range(len(_GOOD_PARAMS)):
173
+ a, b, c, d = _params_from_hash(digest, attempt)
174
+ grid = _clifford_density(a, b, c, d, res, n_points)
175
+ if _grid_is_interesting(grid, res):
176
+ break
177
+
178
+ if grid is None:
179
+ grid = [[0] * res for _ in range(res)]
180
+
181
+ pixels = _colorize_rgba(grid, hue_shift)
182
+ return _encode_png_rgba(pixels, res, res)