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.
- pycodecad/__init__.py +18 -0
- pycodecad/__main__.py +3 -0
- pycodecad/api.py +105 -0
- pycodecad/app.py +34 -0
- pycodecad/cad.py +261 -0
- pycodecad/camera.py +136 -0
- pycodecad/cli.py +241 -0
- pycodecad/context.py +178 -0
- pycodecad/editor.py +256 -0
- pycodecad/embed.py +16 -0
- pycodecad/examples/assembly.py +7 -0
- pycodecad/examples/assets/logo.svg +1 -0
- pycodecad/examples/assets/pyramid.stl +44 -0
- pycodecad/examples/embedded_app.py +77 -0
- pycodecad/examples/gear.py +6 -0
- pycodecad/examples/gearbox.py +30 -0
- pycodecad/examples/gears_turning.py +11 -0
- pycodecad/examples/import_files.py +13 -0
- pycodecad/examples/parts.py +46 -0
- pycodecad/examples/tray.py +28 -0
- pycodecad/files.py +262 -0
- pycodecad/icons/LICENSE +43 -0
- pycodecad/icons/__init__.py +31 -0
- pycodecad/icons/lucide.ttf +0 -0
- pycodecad/imgui_backend.py +269 -0
- pycodecad/params.py +177 -0
- pycodecad/renderer.py +327 -0
- pycodecad/runner.py +404 -0
- pycodecad/sidecar.py +86 -0
- pycodecad/textedit.py +290 -0
- pycodecad/ui.py +677 -0
- pycodecad/viewcube.py +120 -0
- pycodecad/viewer.py +70 -0
- pycodecad/window.py +129 -0
- pycodecad/workspace.py +545 -0
- pycodecad-1.0.0.dist-info/METADATA +117 -0
- pycodecad-1.0.0.dist-info/RECORD +40 -0
- pycodecad-1.0.0.dist-info/WHEEL +4 -0
- pycodecad-1.0.0.dist-info/entry_points.txt +2 -0
- pycodecad-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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]
|