scrollkit 0.8.3__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.
Files changed (111) hide show
  1. scrollkit/__init__.py +16 -0
  2. scrollkit/app/__init__.py +6 -0
  3. scrollkit/app/base.py +918 -0
  4. scrollkit/app/memory.py +70 -0
  5. scrollkit/config/__init__.py +1 -0
  6. scrollkit/config/settings_manager.py +215 -0
  7. scrollkit/config/transition_names.py +31 -0
  8. scrollkit/dev/__init__.py +41 -0
  9. scrollkit/dev/capabilities.py +374 -0
  10. scrollkit/dev/harness.py +383 -0
  11. scrollkit/dev/metrics.py +91 -0
  12. scrollkit/dev/performance.py +174 -0
  13. scrollkit/dev/validation.py +245 -0
  14. scrollkit/display/__init__.py +14 -0
  15. scrollkit/display/_graphics.py +299 -0
  16. scrollkit/display/_recording.py +165 -0
  17. scrollkit/display/_sim_backend.py +99 -0
  18. scrollkit/display/bitmap_text.py +408 -0
  19. scrollkit/display/boards.py +186 -0
  20. scrollkit/display/colors.py +176 -0
  21. scrollkit/display/content.py +604 -0
  22. scrollkit/display/gradient_text.py +167 -0
  23. scrollkit/display/interface.py +126 -0
  24. scrollkit/display/simulator.py +80 -0
  25. scrollkit/display/text_fill.py +59 -0
  26. scrollkit/display/text_pixels.py +250 -0
  27. scrollkit/display/unified.py +595 -0
  28. scrollkit/effects/__init__.py +28 -0
  29. scrollkit/effects/drip_splash.py +253 -0
  30. scrollkit/effects/easing.py +131 -0
  31. scrollkit/effects/overlay.py +92 -0
  32. scrollkit/effects/particles.py +355 -0
  33. scrollkit/effects/reveal_splash.py +132 -0
  34. scrollkit/effects/scrolling.py +363 -0
  35. scrollkit/effects/swarm_reveal.py +512 -0
  36. scrollkit/effects/text_render.py +25 -0
  37. scrollkit/effects/transitions.py +873 -0
  38. scrollkit/exceptions.py +55 -0
  39. scrollkit/network/__init__.py +1 -0
  40. scrollkit/network/http_client.py +505 -0
  41. scrollkit/network/mdns.py +44 -0
  42. scrollkit/network/wifi_manager.py +382 -0
  43. scrollkit/ota/__init__.py +13 -0
  44. scrollkit/ota/client.py +528 -0
  45. scrollkit/ota/display_progress.py +125 -0
  46. scrollkit/ota/manifest.py +206 -0
  47. scrollkit/ota/publish.py +379 -0
  48. scrollkit/simulator/ATTRIBUTION.md +20 -0
  49. scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +364 -0
  50. scrollkit/simulator/LICENSE +176 -0
  51. scrollkit/simulator/README.md +87 -0
  52. scrollkit/simulator/__init__.py +11 -0
  53. scrollkit/simulator/adafruit_bitmap_font/__init__.py +5 -0
  54. scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +273 -0
  55. scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +70 -0
  56. scrollkit/simulator/adafruit_display_text/__init__.py +5 -0
  57. scrollkit/simulator/adafruit_display_text/label.py +336 -0
  58. scrollkit/simulator/bitmaptools.py +50 -0
  59. scrollkit/simulator/core/__init__.py +8 -0
  60. scrollkit/simulator/core/color_utils.py +62 -0
  61. scrollkit/simulator/core/device_benchmarks.json +254 -0
  62. scrollkit/simulator/core/feasibility.py +160 -0
  63. scrollkit/simulator/core/hardware_profile.py +191 -0
  64. scrollkit/simulator/core/led_matrix.py +307 -0
  65. scrollkit/simulator/core/matrixportal_s3_baseline.json +12 -0
  66. scrollkit/simulator/core/performance_manager.py +253 -0
  67. scrollkit/simulator/core/pixel_buffer.py +188 -0
  68. scrollkit/simulator/devices/__init__.py +7 -0
  69. scrollkit/simulator/devices/base_device.py +79 -0
  70. scrollkit/simulator/devices/matrixportal_s3.py +87 -0
  71. scrollkit/simulator/displayio/__init__.py +12 -0
  72. scrollkit/simulator/displayio/bitmap.py +158 -0
  73. scrollkit/simulator/displayio/display.py +195 -0
  74. scrollkit/simulator/displayio/fourwire.py +54 -0
  75. scrollkit/simulator/displayio/group.py +125 -0
  76. scrollkit/simulator/displayio/ondiskbitmap.py +109 -0
  77. scrollkit/simulator/displayio/palette.py +115 -0
  78. scrollkit/simulator/displayio/tilegrid.py +155 -0
  79. scrollkit/simulator/fonts/3x5.bdf +2474 -0
  80. scrollkit/simulator/fonts/Arial_16.bdf +7366 -0
  81. scrollkit/simulator/fonts/Arial_16.bdf.license +3 -0
  82. scrollkit/simulator/fonts/Arial_Bold_12.bdf +6131 -0
  83. scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +2 -0
  84. scrollkit/simulator/fonts/Arial_Bold_18.bdf +32653 -0
  85. scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +2 -0
  86. scrollkit/simulator/fonts/Junction_regular_24.bdf +8676 -0
  87. scrollkit/simulator/fonts/Junction_regular_24.bdf.license +3 -0
  88. scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +12458 -0
  89. scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +1921 -0
  90. scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +12458 -0
  91. scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +4 -0
  92. scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +16818 -0
  93. scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +1921 -0
  94. scrollkit/simulator/fonts/tom-thumb.bdf +2353 -0
  95. scrollkit/simulator/fonts/viii-bold.bdf +2673 -0
  96. scrollkit/simulator/fonts/viii.bdf +2659 -0
  97. scrollkit/simulator/terminalio/__init__.py +20 -0
  98. scrollkit/utils/__init__.py +1 -0
  99. scrollkit/utils/color_utils.py +54 -0
  100. scrollkit/utils/diagnostics.py +227 -0
  101. scrollkit/utils/error_handler.py +347 -0
  102. scrollkit/utils/system_utils.py +245 -0
  103. scrollkit/utils/url_utils.py +46 -0
  104. scrollkit/web/__init__.py +6 -0
  105. scrollkit/web/settings_server.py +328 -0
  106. scrollkit/web/wifi_setup.py +331 -0
  107. scrollkit-0.8.3.dist-info/METADATA +248 -0
  108. scrollkit-0.8.3.dist-info/RECORD +111 -0
  109. scrollkit-0.8.3.dist-info/WHEEL +5 -0
  110. scrollkit-0.8.3.dist-info/licenses/LICENSE +31 -0
  111. scrollkit-0.8.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,374 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Machine-readable catalog of what a ScrollKit app can use — for AI authoring.
3
+
4
+ ``capabilities()`` returns a JSON-able dict describing the building blocks an
5
+ agent should reach for when writing an app: the panel geometry, the content
6
+ types and their constructor parameters, priority levels, available effects,
7
+ named colors, and the display drawing API — plus a pointer at the headless
8
+ verification loop.
9
+
10
+ Everything is **introspected from live code** (the real ``Priority`` class, the
11
+ ``effects`` package's ``__all__``, ``NAMED_COLORS``, the
12
+ ``DisplayInterface`` methods, and the ``DisplayContent`` subclasses), so the
13
+ catalog can't silently drift out of sync with the library. Each lookup is
14
+ defensive: if a piece can't be imported, that section is simply omitted rather
15
+ than crashing the whole catalog.
16
+
17
+ Desktop-only (imported via ``scrollkit.dev``).
18
+ """
19
+
20
+ import inspect
21
+
22
+ # The Adafruit MatrixPortal S3's standard HUB75 panel — the canonical target the
23
+ # simulator emulates. (Color is 24-bit RGB; the panel itself is far coarser.)
24
+ PANEL_WIDTH = 64
25
+ PANEL_HEIGHT = 32
26
+
27
+
28
+ def _first_line(obj):
29
+ doc = inspect.getdoc(obj) or ""
30
+ return doc.strip().split("\n", 1)[0] if doc else ""
31
+
32
+
33
+ def _init_params(cls):
34
+ """``[{name, default}]`` for a class __init__, skipping self/var-args."""
35
+ out = []
36
+ try:
37
+ sig = inspect.signature(cls.__init__)
38
+ except (ValueError, TypeError):
39
+ return out
40
+ for name, p in sig.parameters.items():
41
+ if name == "self" or p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
42
+ continue
43
+ default = None if p.default is inspect.Parameter.empty else p.default
44
+ out.append({"name": name, "default": default})
45
+ return out
46
+
47
+
48
+ def _content_types():
49
+ from ..display import content as _content
50
+ base = _content.DisplayContent
51
+ types = []
52
+ for name in dir(_content):
53
+ obj = getattr(_content, name)
54
+ if (inspect.isclass(obj) and issubclass(obj, base)
55
+ and obj is not base
56
+ and obj.__module__ == _content.__name__):
57
+ types.append({"name": name, "doc": _first_line(obj),
58
+ "params": _init_params(obj),
59
+ "feasibility": getattr(obj, "FEASIBILITY", None)})
60
+ types.sort(key=lambda t: t["name"])
61
+ return types
62
+
63
+
64
+ def _text_fills():
65
+ """Gradient text-fill capability for the Label-based content types.
66
+
67
+ The fill *params* (``palette``/``direction``/``palette_steps``) already surface
68
+ via ``_content_types`` signature introspection; this section adds the
69
+ machine-usable detail an author needs — the valid directions and step bounds —
70
+ read **live** from ``display.text_fill`` (never a hardcoded copy), so it can't
71
+ drift from the renderer.
72
+ """
73
+ from ..display.text_fill import (DEFAULT_PALETTE_STEPS, MAX_PALETTE_STEPS,
74
+ gradient_directions)
75
+ return {
76
+ "gradient": {
77
+ "applies_to": ["StaticText", "ScrollingText"],
78
+ "params": ["palette", "direction", "palette_steps"],
79
+ "directions": list(gradient_directions()),
80
+ "default_direction": "vertical",
81
+ "default_palette_steps": DEFAULT_PALETTE_STEPS,
82
+ "max_palette_steps": MAX_PALETTE_STEPS,
83
+ "palette": ("a sequence of 2+ 0xRRGGBB stops (2 = simple gradient, 3+ "
84
+ "= multi-stop). When set, `color` is ignored. "
85
+ "depth_palette(color) derives a subtle close ramp from one "
86
+ "base colour. Static fill, zero per-frame cost — for "
87
+ "ANIMATED colour use BitmapText + a palette_effect."),
88
+ }
89
+ }
90
+
91
+
92
+ def _color_utilities():
93
+ """Continuous colour generators/transforms (NOT named palettes).
94
+
95
+ Surfaced as live signatures from ``display.colors`` so an author samples the
96
+ full 24-bit space rather than reaching for a fixed named subset.
97
+ """
98
+ from ..display import colors as _c
99
+ out = []
100
+ for nm in ("gradient", "multi_gradient", "depth_palette", "spectrum",
101
+ "hsv", "scale", "lerp", "wheel"):
102
+ fn = getattr(_c, nm, None)
103
+ if fn is None:
104
+ continue
105
+ try:
106
+ sig = "%s%s" % (nm, inspect.signature(fn))
107
+ except (ValueError, TypeError):
108
+ sig = nm
109
+ out.append({"name": nm, "signature": sig, "doc": _first_line(fn)})
110
+ return out
111
+
112
+
113
+ def _priorities():
114
+ from ..display.content import Priority
115
+ levels = {}
116
+ for name in dir(Priority):
117
+ if name.isupper() and isinstance(getattr(Priority, name), int):
118
+ levels[name] = getattr(Priority, name)
119
+ return dict(sorted(levels.items(), key=lambda kv: kv[1]))
120
+
121
+
122
+ def _effects():
123
+ """``[{name, doc}]`` for the standalone splash/particle effect classes.
124
+
125
+ Deep-imports each submodule directly (``effects/__init__.py`` is
126
+ deliberately import-free — see its docstring) so this catalog build never
127
+ loads a heavier module than the one it's cataloging.
128
+ """
129
+ out = []
130
+ try:
131
+ from ..effects.particles import ParticleEngine, Sparkle, Snow
132
+ for name, obj in (("ParticleEngine", ParticleEngine),
133
+ ("Sparkle", Sparkle), ("Snow", Snow)):
134
+ out.append({"name": name, "doc": _first_line(obj)})
135
+ except ImportError:
136
+ pass
137
+ try:
138
+ from ..effects.drip_splash import DripReveal
139
+ out.append({"name": "DripReveal", "doc": _first_line(DripReveal)})
140
+ except ImportError:
141
+ pass
142
+ try:
143
+ from ..effects.swarm_reveal import SwarmReveal
144
+ out.append({"name": "SwarmReveal", "doc": _first_line(SwarmReveal)})
145
+ except ImportError:
146
+ pass
147
+ return out
148
+
149
+
150
+ def _transitions():
151
+ """``[{name, doc, feasibility, pairs_with}]`` for the built-in content-swap transitions.
152
+
153
+ Enumerated via the explicit ``_TRANSITION_MAP`` (not ``Transition.__subclasses__``)
154
+ so the duck-typed ``DropFromSky`` is included and the user-facing names match the
155
+ settings UI. ``feasibility`` is each class's ``FEASIBILITY`` budget and
156
+ ``pairs_with`` says which content style it suits (static / scrolling / fullscreen).
157
+ """
158
+ from ..effects.transitions import _TRANSITION_MAP
159
+ out = []
160
+ for name, cls in _TRANSITION_MAP.items():
161
+ out.append({"name": name, "doc": _first_line(cls),
162
+ "feasibility": getattr(cls, "FEASIBILITY", None),
163
+ "pairs_with": list(getattr(cls, "PAIRS_WITH", ()))})
164
+ return out
165
+
166
+
167
+ def _scrolling():
168
+ """``[{name, doc, feasibility, pairs_with}]`` for the Class-1 scrolling effects.
169
+
170
+ These ARE content presentations (DisplayContent subclasses); ``pairs_with`` says
171
+ which content style each suits (see PAIRS_WITH in ``effects/scrolling.py``).
172
+ """
173
+ out = []
174
+ try:
175
+ from ..effects import scrolling as _sc
176
+ except ImportError:
177
+ return out
178
+ for nm in ("KineticMarquee", "WaveRider", "SplitFlap"):
179
+ cls = getattr(_sc, nm, None)
180
+ if cls is not None:
181
+ out.append({"name": nm, "doc": _first_line(cls),
182
+ "feasibility": getattr(cls, "FEASIBILITY", None),
183
+ "pairs_with": list(getattr(cls, "PAIRS_WITH", ()))})
184
+ return out
185
+
186
+
187
+ def _palette_effects():
188
+ """``[{name, doc, applies_to, pairs_with}]`` for the BitmapText palette animations.
189
+
190
+ They animate the colour palette of bitmap text (no glyph rebuild); ``pairs_with``
191
+ says they read well on static or scrolling text.
192
+ """
193
+ out = []
194
+ try:
195
+ from ..display import bitmap_text as _bt
196
+ except ImportError:
197
+ return out
198
+ for nm in ("RainbowChase", "NeonTubeCrawl", "ChromeSheen", "HazardStripes",
199
+ "MonoChase"):
200
+ cls = getattr(_bt, nm, None)
201
+ if cls is not None:
202
+ out.append({"name": nm, "doc": _first_line(cls),
203
+ "applies_to": "BitmapText",
204
+ "pairs_with": list(getattr(cls, "PAIRS_WITH", ()))})
205
+ return out
206
+
207
+
208
+ def _named_colors():
209
+ """name -> 0xRRGGBB int, from the tooling color table (deduped)."""
210
+ from ..utils.color_utils import NAMED_COLORS
211
+ colors = {}
212
+ for name, rgb in NAMED_COLORS.items():
213
+ try:
214
+ r, g, b = rgb
215
+ colors[name] = (int(r) << 16) | (int(g) << 8) | int(b)
216
+ except (TypeError, ValueError):
217
+ continue
218
+ return colors
219
+
220
+
221
+ def _display_api():
222
+ from ..display.interface import DisplayInterface
223
+ methods = []
224
+ for name in dir(DisplayInterface):
225
+ if name.startswith("_"):
226
+ continue
227
+ attr = inspect.getattr_static(DisplayInterface, name, None)
228
+ is_prop = isinstance(attr, property)
229
+ member = getattr(DisplayInterface, name, None)
230
+ if not (is_prop or inspect.isfunction(member) or inspect.iscoroutinefunction(member)):
231
+ continue
232
+ entry = {"name": name, "property": is_prop, "doc": _first_line(member)}
233
+ target = attr.fget if is_prop else member
234
+ try:
235
+ entry["signature"] = "%s%s" % (name, inspect.signature(target))
236
+ except (ValueError, TypeError):
237
+ entry["signature"] = name
238
+ methods.append(entry)
239
+ methods.sort(key=lambda m: m["name"])
240
+ return methods
241
+
242
+
243
+ def _hardware():
244
+ from ..simulator.core.hardware_profile import matrixportal_s3_profile
245
+ p = matrixportal_s3_profile()
246
+ ceiling = int(1_000_000 / p.full_refresh_us) if p.full_refresh_us else 0
247
+ return {
248
+ "target": p.name,
249
+ "usable_ram_bytes": p.usable_ram_bytes,
250
+ "confidence": p.confidence,
251
+ "calibrated": p.is_calibrated,
252
+ "refresh_fps_ceiling": ceiling,
253
+ "guidance": ("Every frame pays one display.refresh() (~%d us, a hard "
254
+ "ceiling near %d FPS) plus a glyph-bitmap rebuild for any "
255
+ "text that changed. Redrawing many text fields every frame "
256
+ "is the main way to fall to single-digit FPS — cache Labels "
257
+ "and only change .text when the value changes. RAM budget is "
258
+ "~%d KB." % (round(p.full_refresh_us), ceiling,
259
+ p.usable_ram_bytes // 1024)),
260
+ }
261
+
262
+
263
+ def _performance():
264
+ from .performance import performance_guide
265
+ return performance_guide()
266
+
267
+
268
+ def capabilities():
269
+ """Return the JSON-able capability catalog (see module docstring).
270
+
271
+ Sections are best-effort: any that can't be introspected is omitted, so the
272
+ call never fails just because one optional submodule is missing.
273
+ """
274
+ cat = {
275
+ "panel": {
276
+ "width": PANEL_WIDTH,
277
+ "height": PANEL_HEIGHT,
278
+ "color": "24-bit RGB int 0xRRGGBB, or an (r, g, b) tuple (0-255 each)",
279
+ "note": "Adafruit MatrixPortal S3 standard 64x32 panel",
280
+ "coordinates": {
281
+ "origin": "top-left",
282
+ "x": "rightward, 0..%d" % (PANEL_WIDTH - 1),
283
+ "y": "downward, 0..%d" % (PANEL_HEIGHT - 1),
284
+ "y_anchor": "baseline",
285
+ "note": ("(0, 0) is the top-left corner; x grows right, y grows "
286
+ "down (standard displayio). y sets the text baseline, "
287
+ "not the glyph top, so y=0 clips a line off the top. "
288
+ "For the 8px font, y=12 vertically centers one line."),
289
+ },
290
+ },
291
+ "verification": (
292
+ "Build a scrollkit.app.base.ScrollKitApp subclass, then run it "
293
+ "headless with scrollkit.dev.run_headless(app, frames=N, "
294
+ "screenshot=path) to get rendered-pixel metrics and an estimated "
295
+ "hardware feasibility report. Use scrollkit.dev.validate(app) for "
296
+ "structured pre-flight checks."
297
+ ),
298
+ }
299
+ for key, fn in (("content_types", _content_types), ("priorities", _priorities),
300
+ ("effects", _effects), ("transitions", _transitions),
301
+ ("scrolling", _scrolling), ("palette_effects", _palette_effects),
302
+ ("text_fills", _text_fills), ("color_utilities", _color_utilities),
303
+ ("named_colors", _named_colors),
304
+ ("display_api", _display_api), ("hardware", _hardware),
305
+ ("performance", _performance)):
306
+ try:
307
+ cat[key] = fn()
308
+ except Exception as e: # one bad section shouldn't sink the catalog
309
+ cat[key] = {"error": "introspection failed: %r" % (e,)}
310
+ return cat
311
+
312
+
313
+ def as_text(cat=None):
314
+ """Render the catalog as a compact human/AI-readable summary."""
315
+ cat = cat or capabilities()
316
+ lines = ["=== ScrollKit capabilities ==="]
317
+ p = cat.get("panel", {})
318
+ lines.append("Panel: %sx%s, color=%s" % (p.get("width"), p.get("height"),
319
+ p.get("color")))
320
+ coords = p.get("coordinates")
321
+ if isinstance(coords, dict) and coords.get("note"):
322
+ lines.append("Coordinates: %s" % coords["note"])
323
+ ct = cat.get("content_types")
324
+ if isinstance(ct, list):
325
+ lines.append("Content types:")
326
+ for t in ct:
327
+ params = ", ".join(
328
+ "%s=%r" % (q["name"], q["default"]) if q["default"] is not None
329
+ else q["name"] for q in t["params"])
330
+ lines.append(" - %s(%s) — %s" % (t["name"], params, t["doc"]))
331
+ pr = cat.get("priorities")
332
+ if isinstance(pr, dict):
333
+ lines.append("Priorities: " + ", ".join("%s=%d" % (k, v)
334
+ for k, v in pr.items()))
335
+ fx = cat.get("effects")
336
+ if isinstance(fx, list) and fx:
337
+ lines.append("Effects: " + ", ".join(e["name"] for e in fx))
338
+ def _pairs(e):
339
+ pw = e.get("pairs_with")
340
+ return " [best on: %s]" % ", ".join(pw) if pw else ""
341
+ tr = cat.get("transitions")
342
+ if isinstance(tr, list) and tr:
343
+ lines.append("Transitions (transition_style setting):")
344
+ for t in tr:
345
+ feas = t.get("feasibility") or {}
346
+ ms = feas.get("modeled_frame_ms")
347
+ budget = " (~%sms/frame)" % ms if ms is not None else ""
348
+ lines.append(" - %s%s%s — %s" % (t["name"], budget, _pairs(t), t["doc"]))
349
+ sc = cat.get("scrolling")
350
+ if isinstance(sc, list) and sc:
351
+ lines.append("Scrolling effects:")
352
+ for e in sc:
353
+ lines.append(" - %s%s — %s" % (e["name"], _pairs(e), e["doc"]))
354
+ pe = cat.get("palette_effects")
355
+ if isinstance(pe, list) and pe:
356
+ lines.append("Palette effects (on BitmapText):")
357
+ for e in pe:
358
+ lines.append(" - %s%s — %s" % (e["name"], _pairs(e), e["doc"]))
359
+ tf = cat.get("text_fills")
360
+ if isinstance(tf, dict) and isinstance(tf.get("gradient"), dict):
361
+ g = tf["gradient"]
362
+ lines.append("Text fills: gradient palette=(c1,c2[,...]) direction=%s on %s"
363
+ % ("|".join(g.get("directions", [])),
364
+ "/".join(g.get("applies_to", []))))
365
+ cu = cat.get("color_utilities")
366
+ if isinstance(cu, list) and cu:
367
+ lines.append("Color utilities: " + ", ".join(u["name"] for u in cu))
368
+ nc = cat.get("named_colors")
369
+ if isinstance(nc, dict) and nc:
370
+ lines.append("Named colors: " + ", ".join(sorted(nc)))
371
+ hw = cat.get("hardware")
372
+ if isinstance(hw, dict) and "guidance" in hw:
373
+ lines.append("Hardware: %s" % hw["guidance"])
374
+ return "\n".join(lines)