pycodecad 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.
@@ -0,0 +1,269 @@
1
+ """Dear ImGui (slimgui) on a GLFW window, rendered with ModernGL: input callbacks, textures, drawing."""
2
+ from __future__ import annotations
3
+
4
+ import ctypes
5
+ import math
6
+ import struct
7
+ from typing import Any, cast
8
+
9
+ import glfw
10
+ import moderngl
11
+ from slimgui import imgui
12
+
13
+ from .icons import font_data
14
+
15
+ VERTEX_SHADER = """#version 330 core
16
+ uniform mat4 projection;
17
+ in vec2 position; in vec2 uv; in vec4 color;
18
+ out vec2 frag_uv; out vec4 frag_color;
19
+ void main() {
20
+ frag_uv = uv; frag_color = color / 255.0;
21
+ gl_Position = projection * vec4(position, 0.0, 1.0);
22
+ }
23
+ """
24
+ FRAGMENT_SHADER = """#version 330 core
25
+ uniform sampler2D image_texture;
26
+ in vec2 frag_uv; in vec4 frag_color;
27
+ out vec4 out_color;
28
+ void main() { out_color = frag_color * texture(image_texture, frag_uv); }
29
+ """
30
+
31
+
32
+ class ImguiBackend:
33
+ """Owns the ImGui context. Call new_frame(), build the UI, then render(); shutdown() before GLFW."""
34
+
35
+ def __init__(self, window, ctx: moderngl.Context):
36
+ self.window, self.ctx = window, ctx
37
+ self.context = imgui.create_context()
38
+ self.io = imgui.get_io()
39
+ self.io.ini_filename = None
40
+ self.io.backend_flags |= (imgui.BackendFlags.RENDERER_HAS_TEXTURES | imgui.BackendFlags.RENDERER_HAS_VTX_OFFSET
41
+ | imgui.BackendFlags.HAS_MOUSE_CURSORS)
42
+ load_fonts(self.io)
43
+ platform = imgui.get_platform_io()
44
+ platform.renderer_texture_max_width = platform.renderer_texture_max_height = ctx.info["GL_MAX_TEXTURE_SIZE"]
45
+ self.program = ctx.program(vertex_shader=VERTEX_SHADER, fragment_shader=FRAGMENT_SHADER)
46
+ cast(moderngl.Uniform, self.program["image_texture"]).value = 0
47
+ self.vbo = ctx.buffer(reserve=4096, dynamic=True)
48
+ self.ibo = ctx.buffer(reserve=4096, dynamic=True)
49
+ self.vao = ctx.vertex_array(self.program, [(self.vbo, "2f 2f 4u1", "position", "uv", "color")],
50
+ self.ibo, index_element_size=imgui.INDEX_SIZE)
51
+ # ImGui texture ids are OpenGL texture names (as in Dear ImGui's own OpenGL backend): GL name ->
52
+ # the texture, or a moderngl handle made for a name drawn with imgui.image() (e.g. a Viewer's).
53
+ self.textures: dict[int, moderngl.Texture] = {}
54
+ self.font_textures: set[int] = set() # the ones ImGui created (and we must release)
55
+ # Typed text (str) and key presses ((glfw key, mods, repeat)) since the last input_events(),
56
+ # in arrival order: the code editor applies them in that order.
57
+ self.events: list = []
58
+ self.copying = False # Ctrl+C/X was pressed: hand what ImGui copies to the system clipboard
59
+ self.last_time: float | None = None
60
+ self.cursors = {kind: glfw.create_standard_cursor(shape) for kind, shape in (
61
+ (imgui.MouseCursor.ARROW, glfw.ARROW_CURSOR), (imgui.MouseCursor.TEXT_INPUT, glfw.IBEAM_CURSOR),
62
+ (imgui.MouseCursor.RESIZE_EW, glfw.HRESIZE_CURSOR), (imgui.MouseCursor.RESIZE_NS, glfw.VRESIZE_CURSOR),
63
+ (imgui.MouseCursor.HAND, glfw.HAND_CURSOR))}
64
+ # The window's input callbacks, and the ones they replace (shutdown() puts those back).
65
+ installed = (
66
+ (glfw.set_key_callback, self._key), (glfw.set_char_callback, self._char),
67
+ (glfw.set_cursor_pos_callback, lambda w, x, y: self.io.add_mouse_pos_event(x, y)),
68
+ (glfw.set_mouse_button_callback, self._mouse_button),
69
+ (glfw.set_scroll_callback, lambda w, x, y: self.io.add_mouse_wheel_event(x, y)),
70
+ (glfw.set_window_focus_callback, lambda w, focused: self.io.add_focus_event(bool(focused))),
71
+ (glfw.set_cursor_enter_callback, self._mouse_enter))
72
+ # Any: glfw's stubs type each setter apart (and refuse None, which puts no callback)
73
+ self.callbacks: list[tuple[Any, Any]] = [(setter, setter(window, callback))
74
+ for setter, callback in cast(Any, installed)]
75
+
76
+ # --- input -----------------------------------------------------------------------------
77
+
78
+ def _modifiers(self, mods: int) -> None:
79
+ for flag, key in ((glfw.MOD_CONTROL, imgui.Key.MOD_CTRL), (glfw.MOD_SHIFT, imgui.Key.MOD_SHIFT),
80
+ (glfw.MOD_ALT, imgui.Key.MOD_ALT), (glfw.MOD_SUPER, imgui.Key.MOD_SUPER)):
81
+ self.io.add_key_event(key, bool(mods & flag))
82
+
83
+ def _key(self, window, key: int, scancode: int, action: int, mods: int) -> None:
84
+ # ImGui text fields (Save as) use their own clipboard: bridge it to the system one.
85
+ ctrl, shift = mods & glfw.MOD_CONTROL, mods & glfw.MOD_SHIFT
86
+ if action == glfw.PRESS and ((ctrl and key == glfw.KEY_V) or (shift and key == glfw.KEY_INSERT)):
87
+ imgui.set_clipboard_text(clipboard_text())
88
+ if action == glfw.PRESS and ((ctrl and key in (glfw.KEY_C, glfw.KEY_X, glfw.KEY_INSERT))
89
+ or (shift and key == glfw.KEY_DELETE)):
90
+ imgui.set_clipboard_text("")
91
+ self.copying = True
92
+ if action in (glfw.PRESS, glfw.REPEAT):
93
+ self.events.append((key, mods, action == glfw.REPEAT))
94
+ if action not in (glfw.PRESS, glfw.RELEASE):
95
+ return
96
+ self._modifiers(mods)
97
+ mapped = imgui_key(key)
98
+ if mapped is not None:
99
+ self.io.add_key_event(mapped, action == glfw.PRESS)
100
+
101
+ def _char(self, window, codepoint: int) -> None:
102
+ if 0 < codepoint <= 0x10FFFF:
103
+ self.io.add_input_character(codepoint)
104
+ if chr(codepoint).isprintable():
105
+ self.events.append(chr(codepoint))
106
+
107
+ def _mouse_button(self, window, button: int, action: int, mods: int) -> None:
108
+ self._modifiers(mods)
109
+ if 0 <= button < 5:
110
+ self.io.add_mouse_button_event(button, action == glfw.PRESS)
111
+
112
+ def _mouse_enter(self, window, entered: int) -> None:
113
+ position = glfw.get_cursor_pos(window) if entered else (-3.4e38, -3.4e38)
114
+ self.io.add_mouse_pos_event(*position)
115
+
116
+ def input_events(self) -> list:
117
+ """Typed text and key presses since the last call, in order (see self.events)."""
118
+ events, self.events = self.events, []
119
+ return events
120
+
121
+ # --- frames ----------------------------------------------------------------------------
122
+
123
+ def new_frame(self) -> None:
124
+ width, height = glfw.get_window_size(self.window)
125
+ fb_width, fb_height = glfw.get_framebuffer_size(self.window)
126
+ self.io.display_size = width, height
127
+ self.io.display_framebuffer_scale = (fb_width / width if width else 1.0, fb_height / height if height else 1.0)
128
+ now = glfw.get_time()
129
+ self.io.delta_time = max(now - self.last_time, 1e-6) if self.last_time is not None else 1 / 60
130
+ self.last_time = now
131
+ cursor = imgui.get_mouse_cursor()
132
+ glfw.set_cursor(self.window, self.cursors.get(cursor, self.cursors[imgui.MouseCursor.ARROW]))
133
+ imgui.new_frame()
134
+
135
+ def texture(self, name: int) -> moderngl.Texture:
136
+ """The texture of a GL name, to bind it (a handle of any size binds the same texture)."""
137
+ if name not in self.textures:
138
+ self.textures[name] = self.ctx.external_texture(name, (1, 1), 4, 0, "f1")
139
+ return self.textures[name]
140
+
141
+ def _update_texture(self, data) -> None:
142
+ """ImGui 1.92 creates and updates its font atlas through the renderer."""
143
+ if data.status == imgui.TextureStatus.WANT_CREATE:
144
+ texture = self.ctx.texture((data.width, data.height), data.bytes_per_pixel, data.get_pixels(), alignment=1)
145
+ texture.filter = moderngl.LINEAR, moderngl.LINEAR
146
+ if data.format == imgui.TextureFormat.ALPHA8:
147
+ texture.swizzle = "111R"
148
+ self.textures[texture.glo] = texture
149
+ self.font_textures.add(texture.glo)
150
+ data.set_tex_id(texture.glo)
151
+ data.set_status(imgui.TextureStatus.OK)
152
+ elif data.status == imgui.TextureStatus.WANT_UPDATES:
153
+ texture = self.textures[data.get_tex_id()]
154
+ pixels = data.get_pixels().reshape(data.height, data.width, data.bytes_per_pixel)
155
+ for rect in data.updates:
156
+ block = pixels[rect.y:rect.y + rect.h, rect.x:rect.x + rect.w].tobytes()
157
+ texture.write(block, viewport=(rect.x, rect.y, rect.w, rect.h), alignment=1)
158
+ data.set_status(imgui.TextureStatus.OK)
159
+ elif data.status == imgui.TextureStatus.WANT_DESTROY and data.unused_frames > 0:
160
+ texture_id = data.get_tex_id()
161
+ if texture_id in self.font_textures:
162
+ self.font_textures.discard(texture_id)
163
+ self.textures.pop(texture_id).release()
164
+ data.set_tex_id(0)
165
+ data.set_status(imgui.TextureStatus.DESTROYED)
166
+
167
+ def render(self) -> None:
168
+ """Finish the ImGui frame and draw it into the current framebuffer."""
169
+ imgui.render()
170
+ copied = imgui.get_clipboard_text() if self.copying else None
171
+ if copied: # copied or cut in an ImGui text field
172
+ glfw.set_clipboard_string(None, copied) # pyright: ignore[reportArgumentType] # glfw ignores the window
173
+ self.copying = False
174
+ data = imgui.get_draw_data()
175
+ for texture in data.textures or ():
176
+ self._update_texture(texture)
177
+ scale_x, scale_y = data.framebuffer_scale
178
+ width, height = int(self.io.display_size[0] * scale_x), int(self.io.display_size[1] * scale_y)
179
+ if width <= 0 or height <= 0:
180
+ return
181
+ ctx = self.ctx
182
+ ctx.enable_only(moderngl.BLEND)
183
+ # rgb and alpha blended separately: moderngl takes 4 values, its stub only 2
184
+ ctx.blend_func = ( # pyright: ignore[reportAttributeAccessIssue]
185
+ moderngl.SRC_ALPHA, moderngl.ONE_MINUS_SRC_ALPHA, moderngl.ONE, moderngl.ONE_MINUS_SRC_ALPHA)
186
+ ctx.viewport = (0, 0, width, height)
187
+ w, h = self.io.display_size
188
+ projection = struct.pack("16f", 2 / w, 0, 0, 0, 0, -2 / h, 0, 0, 0, 0, -1, 0, -1, 1, 0, 1)
189
+ cast(moderngl.Uniform, self.program["projection"]).write(projection)
190
+ for drawlist in data.commands_lists:
191
+ for buffer, pointer, size in (
192
+ (self.vbo, drawlist.vtx_buffer_data, drawlist.vtx_buffer_size * imgui.VERTEX_SIZE),
193
+ (self.ibo, drawlist.idx_buffer_data, drawlist.idx_buffer_size * imgui.INDEX_SIZE),
194
+ ):
195
+ if size:
196
+ buffer.orphan(max(buffer.size, 1 << (size - 1).bit_length()))
197
+ buffer.write(ctypes.string_at(pointer, size))
198
+ for command in drawlist.commands:
199
+ if not command.elem_count:
200
+ continue
201
+ x0, y0, x1, y1 = command.clip_rect
202
+ left, top = max(0, math.floor(x0 * scale_x)), max(0, math.floor(y0 * scale_y))
203
+ right, bottom = min(width, math.ceil(x1 * scale_x)), min(height, math.ceil(y1 * scale_y))
204
+ if right <= left or bottom <= top:
205
+ continue
206
+ ctx.scissor = left, height - bottom, right - left, bottom - top
207
+ self.texture(command.tex_ref.get_tex_id()).use(0)
208
+ base = command.vtx_offset * imgui.VERTEX_SIZE
209
+ # Vertex offsets: rebind the attributes at the command's first vertex.
210
+ for name, fmt, field in (("position", "2f", imgui.VERTEX_BUFFER_POS_OFFSET),
211
+ ("uv", "2f", imgui.VERTEX_BUFFER_UV_OFFSET),
212
+ ("color", "4u1", imgui.VERTEX_BUFFER_COL_OFFSET)):
213
+ self.vao.bind(cast(moderngl.Attribute, self.program[name]).location, "f", self.vbo, fmt,
214
+ offset=base + field, stride=imgui.VERTEX_SIZE)
215
+ self.vao.render(moderngl.TRIANGLES, vertices=command.elem_count, first=command.idx_offset)
216
+ ctx.scissor = None
217
+
218
+ def shutdown(self) -> None:
219
+ for setter, previous in self.callbacks: # no input may reach the destroyed ImGui context
220
+ setter(self.window, previous)
221
+ for texture_id in self.font_textures:
222
+ self.textures[texture_id].release()
223
+ for resource in (self.vao, self.ibo, self.vbo, self.program):
224
+ resource.release()
225
+ for cursor in self.cursors.values():
226
+ glfw.destroy_cursor(cursor)
227
+ imgui.destroy_context(self.context)
228
+
229
+
230
+ FONT_SIZE = 13.0 # ImGui's built-in font, drawn at its native size
231
+
232
+
233
+ def load_fonts(io) -> None:
234
+ """ImGui's default font with the Lucide icons merged in (see pycodecad.icons), centred on the text."""
235
+ base = imgui.FontConfig()
236
+ base.size_pixels = FONT_SIZE # explicit, so the icons can merge in at the same size
237
+ io.fonts.add_font_default(base)
238
+ config = imgui.FontConfig()
239
+ config.merge_mode = True
240
+ config.pixel_snap_h = True
241
+ config.glyph_offset = ICON_OFFSET
242
+ io.fonts.add_font_from_memory_ttf(font_data(), FONT_SIZE, config)
243
+
244
+
245
+ ICON_OFFSET = (-2.0, 3.5)
246
+
247
+
248
+ def clipboard_text() -> str:
249
+ text = glfw.get_clipboard_string(None) or b"" # pyright: ignore[reportArgumentType] # glfw ignores the window
250
+ return text.decode("utf-8", "replace") if isinstance(text, bytes) else text
251
+
252
+
253
+ def imgui_key(key: int):
254
+ """GLFW key code -> imgui.Key (None for keys ImGui does not know)."""
255
+ for first, last, target in ((glfw.KEY_A, glfw.KEY_Z, imgui.Key.KEY_A), (glfw.KEY_0, glfw.KEY_9, imgui.Key.KEY_0),
256
+ (glfw.KEY_F1, glfw.KEY_F12, imgui.Key.KEY_F1),
257
+ (glfw.KEY_KP_0, glfw.KEY_KP_EQUAL, imgui.Key.KEY_KEYPAD0)):
258
+ if first <= key <= last:
259
+ return imgui.Key(int(target) + key - first)
260
+ name = KEY_NAMES.get(key)
261
+ return getattr(imgui.Key, "KEY_" + name) if name else None
262
+
263
+
264
+ KEY_NAMES = {getattr(glfw, "KEY_" + name): name for name in (
265
+ "TAB", "PAGE_UP", "PAGE_DOWN", "HOME", "END", "INSERT", "DELETE", "BACKSPACE", "SPACE", "ENTER", "ESCAPE",
266
+ "APOSTROPHE", "COMMA", "MINUS", "PERIOD", "SLASH", "SEMICOLON", "EQUAL", "LEFT_BRACKET", "BACKSLASH",
267
+ "RIGHT_BRACKET", "GRAVE_ACCENT", "LEFT_SHIFT", "LEFT_ALT", "LEFT_SUPER", "RIGHT_SHIFT", "RIGHT_ALT", "RIGHT_SUPER")}
268
+ KEY_NAMES.update({glfw.KEY_LEFT: "LEFT_ARROW", glfw.KEY_RIGHT: "RIGHT_ARROW", glfw.KEY_UP: "UP_ARROW",
269
+ glfw.KEY_DOWN: "DOWN_ARROW", glfw.KEY_LEFT_CONTROL: "LEFT_CTRL", glfw.KEY_RIGHT_CONTROL: "RIGHT_CTRL"})
pycodecad/params.py ADDED
@@ -0,0 +1,177 @@
1
+ """Parameters of exposed functions.
2
+
3
+ `expose(fn)` in a script turns the parameters of `fn` into controls in the window. Only simple
4
+ parameters, so a control can always be drawn for them: int, float, bool or str, each with a
5
+ default, annotated only with pytypehint Min, Max, Step, Slider, Label and Description. Anything
6
+ else is an error that names the parameter.
7
+
8
+ Values reach a run as a dict keyed "function.param" (or just "param"): typed values from the
9
+ window, strings from `--set` on the command line (converted here to the parameter's type).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import inspect
14
+ import typing
15
+ from dataclasses import asdict
16
+ from typing import Literal
17
+
18
+ from pytypehint import Bool, Float, Int, Signature, Str, immutable, signature_of
19
+
20
+ Kind = Literal["int", "float", "bool", "str"]
21
+ KINDS: dict[type, Kind] = {Int: "int", Float: "float", Bool: "bool", Str: "str"}
22
+ TYPES = {"int": int, "float": float, "bool": bool, "str": str}
23
+ OPTIONS = {"int": ("min", "max", "step"), "float": ("min", "max", "step"), "str": ("min", "max"), "bool": ()}
24
+ LIMIT = 10**9 # numbers within ±LIMIT: what the window's int and float controls handle
25
+ TRUE, FALSE = ("true", "1", "yes", "on"), ("false", "0", "no", "off")
26
+
27
+
28
+ @immutable
29
+ class Param:
30
+ """One parameter as the window draws it: only states a control can show can be built."""
31
+ name: str
32
+ kind: Kind
33
+ default: int | float | bool | str
34
+ value: int | float | bool | str # the value the run used
35
+ min: int | float | None = None # for str: length
36
+ max: int | float | None = None
37
+ step: int | float | None = None
38
+ slider: bool = False
39
+ label: str | None = None
40
+ description: str | None = None
41
+
42
+ def __post_init__(self):
43
+ if self.kind not in TYPES:
44
+ return # the field validation names the bad kind
45
+ if not self.name.isidentifier():
46
+ raise ValueError(f"name must be an identifier, not {self.name!r}")
47
+ for which in ("default", "value"):
48
+ if type(getattr(self, which)) is not TYPES[self.kind]:
49
+ raise ValueError(f"needs a {which} of type {self.kind}")
50
+ bound = float if self.kind == "float" else int # str: min/max are lengths
51
+ for option in ("min", "max", "step"):
52
+ given = getattr(self, option)
53
+ if given is not None and option not in OPTIONS[self.kind]:
54
+ raise ValueError(f"{option} is not supported for {self.kind}")
55
+ if given is not None and type(given) is not bound:
56
+ raise ValueError(f"{option} must be of type {bound.__name__}")
57
+ if self.min is not None and self.max is not None and self.min > self.max:
58
+ raise ValueError(f"min {self.min} is greater than max {self.max}")
59
+ if self.slider and (self.min is None or self.max is None):
60
+ raise ValueError("slider requires min and max")
61
+ if self.kind in ("int", "float") and any(
62
+ isinstance(v, (int, float)) and not -LIMIT <= v <= LIMIT # checked numbers above
63
+ for v in (self.default, self.value, self.min, self.max, self.step and self.step * 10)):
64
+ raise ValueError("numbers must be within ±1e9 (step within ±1e8)")
65
+ if self.step is not None and self.step < 1e-6:
66
+ raise ValueError("Step must be at least 1e-6")
67
+ if self.kind != "bool":
68
+ for which in ("default", "value"):
69
+ size = getattr(self, which)
70
+ size = len(size) if self.kind == "str" else size
71
+ if self.min is not None and size < self.min or self.max is not None and size > self.max:
72
+ raise ValueError(f"{which} {getattr(self, which)!r} is outside [{self.min}, {self.max}]")
73
+
74
+
75
+ @immutable
76
+ class Exposed:
77
+ function: str
78
+ params: tuple[Param, ...]
79
+
80
+ def __post_init__(self):
81
+ if not self.function.isidentifier():
82
+ raise ValueError(f"function must be an identifier, not {self.function!r}")
83
+ names = [p.name for p in self.params]
84
+ if len(set(names)) != len(names):
85
+ raise ValueError(f"{self.function}: parameter names repeat: {names}")
86
+
87
+
88
+ def compile_function(fn) -> tuple[Signature, list[Param]]:
89
+ """The pytypehint signature of fn and its parameters (value = default).
90
+
91
+ Raises TypeError naming the first parameter that is not simple.
92
+ """
93
+ if not inspect.isfunction(fn) or not fn.__name__.isidentifier() or inspect.iscoroutinefunction(fn):
94
+ raise TypeError(f"expose() takes a function defined with def, not {fn!r}")
95
+ name = fn.__name__
96
+ try:
97
+ signature = signature_of(fn)
98
+ except (TypeError, ValueError, NameError) as exc:
99
+ raise TypeError(f"expose({name}): {name_parameter(fn, str(exc))}") from None
100
+ params = []
101
+ for field in signature.params:
102
+ where = f"expose({name}): parameter {field.name!r}"
103
+ if len(field.shape) != 1 or type(field.shape[0]) not in KINDS:
104
+ raise TypeError(f"{where}: only int, float, bool and str are supported")
105
+ shape = field.shape[0]
106
+ kind = KINDS[type(shape)]
107
+ allowed = (*OPTIONS[kind], "slider") if kind in ("int", "float") else OPTIONS[kind]
108
+ for option, value in vars(shape).items():
109
+ if option == "_extras" and value or not option.startswith("_") and value is not None \
110
+ and option not in allowed:
111
+ raise TypeError(f"{where}: {option.strip('_')} is not supported (only Min, Max, Step, "
112
+ "Slider, Label and Description)")
113
+ for bound in (getattr(shape, "min", None), getattr(shape, "max", None)):
114
+ if bound is not None and bound.exclusive:
115
+ raise TypeError(f"{where}: exclusive Min/Max is not supported")
116
+ low, high, step = (getattr(getattr(shape, option, None), "value", None) for option in ("min", "max", "step"))
117
+ default = typing.cast(int | float | bool | str, field.default) # pytypehint checked it against the shape
118
+ try:
119
+ if kind == "float": # Min(20) on a float is 20.0, as the control shows it
120
+ low, high, step = (None if v is None else float(v) for v in (low, high, step))
121
+ params.append(Param(name=field.name, kind=kind, default=default, value=default, min=low,
122
+ max=high, step=step, slider=getattr(shape, "slider", None) is not None,
123
+ label=field.label.value if field.label else None,
124
+ description=field.description.value if field.description else None))
125
+ except (TypeError, ValueError, OverflowError) as exc:
126
+ raise TypeError(f"{where}: {exc}") from None
127
+ return signature, params
128
+
129
+
130
+ def name_parameter(fn, message: str) -> str:
131
+ """A pytypehint error as "parameter 'x': ...", finding x when the message does not name it."""
132
+ parameters = inspect.signature(fn).parameters
133
+ head, _, rest = message.partition(": ")
134
+ if head in parameters:
135
+ return f"parameter {head!r}: {rest}"
136
+ try:
137
+ hints = typing.get_type_hints(fn, include_extras=True)
138
+ except NameError:
139
+ return message
140
+ def probe():
141
+ pass
142
+
143
+ for parameter in parameters.values(): # the same check, one parameter at a time
144
+ probe.__signature__ = inspect.Signature([parameter])
145
+ probe.__annotations__ = {parameter.name: hints[parameter.name]} if parameter.name in hints else {}
146
+ try:
147
+ signature_of(probe)
148
+ except (TypeError, ValueError):
149
+ return f"parameter {parameter.name!r}: {message}"
150
+ return message
151
+
152
+
153
+ def convert(param: Param, value: object) -> object:
154
+ """A value for param: strings (from --set) become its type, an int is a valid float;
155
+ anything else is kept as is (and checked when the function is called)."""
156
+ if param.kind == "float" and type(value) is int:
157
+ return float(value)
158
+ if not isinstance(value, str) or param.kind == "str":
159
+ return value
160
+ text = value.strip()
161
+ try:
162
+ if param.kind == "int":
163
+ return int(text)
164
+ if param.kind == "float":
165
+ return float(text)
166
+ except ValueError:
167
+ raise ValueError(f"{param.name}: {value!r} is not a valid {param.kind}") from None
168
+ if text.lower() in TRUE:
169
+ return True
170
+ if text.lower() in FALSE:
171
+ return False
172
+ raise ValueError(f"{param.name}: {value!r} is not a valid bool (true/false)")
173
+
174
+
175
+ def to_json(exposed: list[Exposed]) -> list[dict]:
176
+ """The parameters as plain JSON data (for `check`, last-run files and the AI context)."""
177
+ return [dict(function=e.function, params=[asdict(p) for p in e.params]) for e in exposed]