CreativePython 1.1.1__py3-none-any.whl → 1.1.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.
@@ -15,8 +15,13 @@
15
15
 
16
16
  import multiprocessing
17
17
  import threading
18
+ import queue
18
19
  import atexit
19
20
 
21
+ # Sender thread tick rate. High enough that the final partial batch after a
22
+ # burst (e.g. a tight for loop) reaches Qt within a few milliseconds.
23
+ _FLUSH_RATE = 200
24
+ _MAX_BUFFER_SIZE = 512 # flush inline when buffer reaches this size
20
25
 
21
26
  #######################################################################################
22
27
  # Message Protocol Helpers
@@ -61,18 +66,28 @@ def _createEvent(eventType, target, args=None):
61
66
  # Child process entry point
62
67
  #######################################################################################
63
68
 
64
- def _launchRenderer(childConn):
69
+ def _launchRenderer(childConn, adminChildConn, adminParentConn):
65
70
  """
66
71
  Entry point for the GuiRenderer child process.
67
72
  Defined here so GuiHandler can reference it without importing from GuiRenderer
68
73
  globally (which would also import Qt/PySide6 in the parent process).
69
74
 
75
+ adminChildConn is the child end of the duplex admin pipe; the child watches it
76
+ with QSocketNotifier. Admin commands (setRate, getRate) arrive here, bypassing
77
+ the command buffer. When the parent closes its end (adminParentConn), the child
78
+ receives EOF and initiates shutdown.
79
+
80
+ adminParentConn is the parent's end; the child closes it immediately so that
81
+ only the parent holds it — ensuring the child sees EOF when the parent closes.
82
+
70
83
  At runtime this function only executes in the child process.
71
84
  """
72
85
  import sys, os
73
86
 
74
- # Suppress macOS system noise (stale XPC connections inherited via fork)
75
- # that would confuse students. Redirect at the OS level (fd 2) so that
87
+ adminParentConn.close() # child must not hold the parent end open
88
+
89
+ # Suppress macOS system noise that would confuse students.
90
+ # Redirect at the OS level (fd 2) so that
76
91
  # C-level libraries (Cocoa, XPC) are also silenced.
77
92
  _devnull = os.open(os.devnull, os.O_WRONLY)
78
93
  os.dup2(_devnull, 2)
@@ -80,7 +95,7 @@ def _launchRenderer(childConn):
80
95
  sys.stderr = open(os.devnull, 'w')
81
96
 
82
97
  from CreativePython.GuiRenderer import GuiRenderer
83
- renderer = GuiRenderer(childConn)
98
+ renderer = GuiRenderer(childConn, adminChildConn)
84
99
  renderer.run()
85
100
 
86
101
 
@@ -123,23 +138,33 @@ class GuiHandler:
123
138
  # which would re-run the user's script. Used on Windows where
124
139
  # fork is unavailable.
125
140
  #
126
- # Detection: if tkinter has been imported into the current process, Cocoa is
127
- # live and we must avoid fork. Otherwise, fork is safe and avoids the
128
- # __main__ re-import problem that spawn/forkserver have.
141
+ # On macOS, fork() is unsafe whenever the parent has any CoreFoundation /
142
+ # Cocoa state not just when tkinter is explicitly imported. The Python
143
+ # runtime on macOS initialises parts of CoreFoundation at startup, so a
144
+ # forked child that then creates a QApplication reliably triggers the
145
+ # "You MUST exec()" crash. forkserver avoids this by forking from a clean
146
+ # helper process that has never touched Cocoa.
147
+ # On Windows, spawn is required (no fork).
148
+ # Inside a PyInstaller frozen binary, forkserver can't work (sys.executable
149
+ # is the frozen app, not a Python interpreter), so spawn is used instead.
129
150
  import sys
130
151
  if sys.platform == 'win32':
131
152
  ctx = multiprocessing.get_context('spawn')
132
153
  elif getattr(sys, 'frozen', False):
133
- # Running inside a PyInstaller frozen executable. forkserver spawns
134
- # its helper by calling sys.executable as a Python interpreter, which
135
- # doesn't work when sys.executable is the frozen app binary. spawn
136
- # is safe here because freeze_support() in the entry point intercepts
137
- # the --multiprocessing-fork flag before any app code runs.
138
154
  ctx = multiprocessing.get_context('spawn')
139
- elif 'tkinter' in sys.modules:
155
+ elif sys.platform == 'darwin':
140
156
  ctx = multiprocessing.get_context('forkserver')
141
157
  else:
142
158
  ctx = multiprocessing.get_context('fork')
159
+
160
+ # Dedicated duplex admin pipe. The parent holds adminParentConn; the child
161
+ # holds adminChildConn. Admin commands (setRate, getRate) travel here,
162
+ # bypassing the command buffer entirely. Closing adminParentConn sends EOF
163
+ # to the child, which Qt detects via QSocketNotifier and treats as shutdown —
164
+ # independent of the command queue and independent of whether atexit runs.
165
+ adminChildConn, adminParentConn = multiprocessing.Pipe(duplex=True)
166
+ self._adminConn = adminParentConn
167
+
143
168
  # With 'spawn' on POSIX (fork+exec), the forked helper calls
144
169
  # get_preparation_data() which reads sys.modules['__main__'].__file__.
145
170
  # When a user script is running in PENCIL's subprocess, __main__.__file__
@@ -159,7 +184,7 @@ class GuiHandler:
159
184
  try:
160
185
  self.childProcess = ctx.Process(
161
186
  target = _launchRenderer,
162
- args = (childConn,),
187
+ args = (childConn, adminChildConn, adminParentConn),
163
188
  daemon = True # child is killed automatically if parent dies unexpectedly
164
189
  )
165
190
  self.childProcess.start()
@@ -169,7 +194,8 @@ class GuiHandler:
169
194
  _main_mod.__file__ = _saved_file
170
195
  if _saved_spec is not _UNSET:
171
196
  _main_mod.__spec__ = _saved_spec
172
- childConn.close() # parent no longer needs the child end of the Pipe
197
+ childConn.close() # parent no longer needs the child end of the command pipe
198
+ adminChildConn.close() # parent no longer needs the child end of the admin pipe
173
199
 
174
200
  # responseId counter — each sendQuery() call gets a unique ID so the
175
201
  # listener thread can route the response back to the correct caller
@@ -190,14 +216,51 @@ class GuiHandler:
190
216
  # must acquire this lock before writing to the pipe
191
217
  self._sendLock = threading.Lock()
192
218
 
219
+ # command buffer — fire-and-forget commands are queued here and sent as
220
+ # a single batch by the sender thread (or flushed early on a query)
221
+ self._commandBuffer = []
222
+ self._bufferLock = threading.Lock()
223
+
224
+ # flush rate - how often to flush the buffer
225
+ self._flushRate = _FLUSH_RATE
226
+ self._maxBufferSize = _MAX_BUFFER_SIZE
227
+
228
+ # callback active lock — held by the dispatch thread while an event callback
229
+ # is executing. The sender thread checks this non-blocking; if it can't
230
+ # acquire, it skips that flush tick so mid-callback commands don't travel as
231
+ # a partial batch. After the callback, the dispatch thread does one explicit
232
+ # flush to send everything the callback accumulated as a single pipe message.
233
+ self._callbackActive = threading.Lock()
234
+
235
+ # sender thread — wakes every 1/FLUSH_RATE seconds and flushes the buffer
236
+ self._senderStopEvent = threading.Event()
237
+ self._senderThread = threading.Thread(
238
+ target = self._runSenderThread,
239
+ daemon = True
240
+ )
241
+ self._senderThread.start()
242
+
243
+ # event queue — listener thread enqueues incoming events here; dispatch
244
+ # thread drains it and runs callbacks. Decoupling the two threads means
245
+ # callbacks can call sendQuery() without deadlocking the listener.
246
+ self._eventQueue = queue.SimpleQueue()
247
+
193
248
  # single listener thread handles both responses and events, since both
194
249
  # arrive on the same connection and reading from it on two threads would race
195
250
  self._listenerThread = threading.Thread(
196
251
  target = self._listenForMessages,
197
- daemon = True # thread exits automatically when the main process exits
252
+ daemon = True
198
253
  )
199
254
  self._listenerThread.start()
200
255
 
256
+ # dispatch thread runs event callbacks sequentially off the event queue,
257
+ # keeping the listener thread free to receive query responses at all times
258
+ self._dispatchThread = threading.Thread(
259
+ target = self._runDispatchThread,
260
+ daemon = True
261
+ )
262
+ self._dispatchThread.start()
263
+
201
264
  atexit.register(self._shutdown)
202
265
 
203
266
  # ── Response ID ───────────────────────────────────────────────────────────
@@ -214,11 +277,11 @@ class GuiHandler:
214
277
 
215
278
  def _listenForMessages(self):
216
279
  """
217
- Background thread. Reads all incoming messages from GuiRenderer and
218
- routes each one based on its type:
219
- - 'responseId' key present response for a waiting sendQuery() call
220
- - 'type' key present → event for a registered callback
221
- Exits when the pipe is closed (EOFError).
280
+ Background thread. Reads incoming messages from GuiRenderer and routes
281
+ each one: responses unblock waiting sendQuery() callers; events are
282
+ pushed onto _eventQueue for the dispatch thread. Exits when the child
283
+ closes the pipe (EOFError), releases any waiting sendQuery() callers,
284
+ and sends a None sentinel to stop the dispatch thread.
222
285
  """
223
286
  while True:
224
287
  try:
@@ -226,9 +289,14 @@ class GuiHandler:
226
289
  if 'responseId' in message:
227
290
  self._handleResponse(message)
228
291
  elif 'type' in message:
229
- self._dispatchEvent(message)
292
+ self._eventQueue.put(message)
230
293
  except EOFError:
231
- break # pipe closed; thread exits cleanly
294
+ break
295
+ with self._pendingLock:
296
+ for slot in self._pendingResponses.values():
297
+ slot['values'] = ['shutdown']
298
+ slot['event'].set()
299
+ self._eventQueue.put(None) # sentinel: tell dispatch thread to stop
232
300
 
233
301
  def _handleResponse(self, responseDict):
234
302
  """
@@ -245,22 +313,91 @@ class GuiHandler:
245
313
  pendingSlot['values'] = values # store result before signalling
246
314
  pendingSlot['event'].set() # unblock the waiting sendQuery() call
247
315
 
316
+ # ── Sender thread ─────────────────────────────────────────────────────────
317
+
318
+ def _runSenderThread(self):
319
+ """
320
+ Background thread. Wakes every 1 / _flushRate seconds and flushes the
321
+ command buffer. Skips a tick if a callback is currently executing
322
+ (_callbackActive is held) so the callback's commands don't get split
323
+ across multiple pipe messages. Exits when _senderStopEvent is set.
324
+ """
325
+ while not self._senderStopEvent.wait(timeout=1.0 / self._flushRate):
326
+ if self._callbackActive.acquire(blocking=False):
327
+ self._callbackActive.release()
328
+ self._flushBuffer()
329
+ # else: callback in progress — skip this tick
330
+ self._flushBuffer() # final drain on shutdown
331
+
332
+ def _flushBuffer(self):
333
+ """
334
+ Sends the current command buffer as a single batch message, then resets
335
+ the buffer. Thread-safe; concurrent callers are safe — only one will
336
+ get a non-empty batch, the others are no-ops.
337
+ """
338
+ with self._bufferLock:
339
+ batch = self._commandBuffer
340
+ if not batch:
341
+ return
342
+ self._commandBuffer = []
343
+ with self._sendLock:
344
+ try:
345
+ self.connection.send(batch)
346
+ except (BrokenPipeError, OSError):
347
+ self._senderStopEvent.set() # pipe is gone; stop the sender thread
348
+
349
+ def getFlushRate(self):
350
+ """
351
+ Returns the current tick rate for flushing the command buffer.
352
+ """
353
+ return self._flushRate
354
+
355
+ def setFlushRate(self, flushRate):
356
+ """
357
+ Sets the current tick rate for flushing the command buffer.
358
+ """
359
+ self._flushRate = flushRate
360
+
361
+ def getRate(self):
362
+ """
363
+ Returns the current render rate (timer ticks per second) of the Qt renderer.
364
+ Sends directly on the admin pipe, bypassing the command buffer.
365
+ """
366
+ self._adminConn.send({'action': 'getRate'})
367
+ return self._adminConn.recv()
368
+
369
+ def setRate(self, rate):
370
+ """
371
+ Sets the render rate (timer ticks per second) of the Qt renderer.
372
+ Sends directly on the admin pipe, bypassing the command buffer.
373
+ """
374
+ self._adminConn.send({'action': 'setRate', 'rate': rate})
375
+
248
376
  # ── Sending ───────────────────────────────────────────────────────────────
249
377
 
250
378
  def sendCommand(self, action, target, args=None):
251
379
  """
252
- Sends a fire-and-forget command to GuiRenderer (no response expected).
253
- Thread-safe.
380
+ Queues a fire-and-forget command in the command buffer. The sender thread
381
+ flushes on each tick. Thread-safe.
254
382
  """
255
383
  command = _createCommand(action, target, args)
256
- with self._sendLock:
257
- self.connection.send(command)
384
+ with self._bufferLock:
385
+ self._commandBuffer.append(command)
386
+ flush_now = len(self._commandBuffer) >= self._maxBufferSize
387
+ if flush_now and self._callbackActive.acquire(blocking=False):
388
+ self._callbackActive.release()
389
+ self._flushBuffer()
258
390
 
259
391
  def sendQuery(self, action, target, args=None):
260
392
  """
261
393
  Sends a command to GuiRenderer and blocks until a response is received.
262
394
  Returns the response's values list. Thread-safe; multiple callers may
263
395
  block concurrently — each is matched to its response by responseId.
396
+
397
+ Flushes any buffered fire-and-forget commands before sending the query
398
+ so that GuiRenderer processes them first and the query reflects up-to-date
399
+ state. The flush and query send are both performed under _sendLock so
400
+ no other thread can interleave a send between them.
264
401
  """
265
402
  responseId = self._nextResponseId()
266
403
  pendingSlot = {'event': threading.Event(), 'values': None}
@@ -270,8 +407,16 @@ class GuiHandler:
270
407
  self._pendingResponses[responseId] = pendingSlot
271
408
 
272
409
  command = _createCommand(action, target, args, responseId=responseId)
410
+
411
+ # grab any buffered commands, then send them + the query atomically as one list
412
+ with self._bufferLock:
413
+ batch = self._commandBuffer
414
+ self._commandBuffer = []
273
415
  with self._sendLock:
274
- self.connection.send(command)
416
+ try:
417
+ self.connection.send(batch + [command])
418
+ except (BrokenPipeError, OSError):
419
+ pass # pipe is gone; listener thread will release the pending slot via EOFError
275
420
 
276
421
  pendingSlot['event'].wait() # block until the listener signals a response
277
422
 
@@ -280,25 +425,6 @@ class GuiHandler:
280
425
 
281
426
  return pendingSlot['values']
282
427
 
283
- # ── Developer utilities ───────────────────────────────────────────────────
284
-
285
- def getBatchSize(self):
286
- """
287
- Returns the current _BATCH_SIZE used by GuiRenderer's pipe-polling loop.
288
- Not part of the public API — intended for developer tuning only.
289
- """
290
- result = self.sendQuery('getBatchSize', None)
291
- return result[0]
292
-
293
- def setBatchSize(self, batchSize):
294
- """
295
- Sets GuiRenderer's _BATCH_SIZE on the fly.
296
- Higher values process more commands between Qt repaints (better throughput,
297
- less responsive feel); lower values yield more frequent repaints.
298
- Not part of the public API — intended for developer tuning only.
299
- """
300
- self.sendCommand('setBatchSize', None, {'batchSize': batchSize})
301
-
302
428
  # ── Events ────────────────────────────────────────────────────────────────
303
429
 
304
430
  def registerEvent(self, objectId, eventType, callback):
@@ -314,10 +440,70 @@ class GuiHandler:
314
440
  registrationArgs = {'objectId': objectId, 'eventType': eventType}
315
441
  self.sendCommand('registerEvent', None, registrationArgs)
316
442
 
443
+ def _runDispatchThread(self):
444
+ """
445
+ Background thread. Drains _eventQueue and runs event callbacks
446
+ sequentially. Decoupled from the listener thread so callbacks can call
447
+ sendQuery() without deadlocking the listener.
448
+
449
+ Coalesces consecutive position events (mouseMove, mouseDrag) for the same
450
+ target: when multiple are queued back-to-back, only the last one is
451
+ dispatched. This prevents lag when a callback involves a query round-trip
452
+ (e.g. intersects()) that causes events to accumulate faster than they are
453
+ consumed. All other event types (clicks, keys, enter/exit) are always
454
+ dispatched in full order.
455
+
456
+ Exits when it receives the None sentinel pushed by _listenForMessages.
457
+ """
458
+ _COALESCE = frozenset({'mouseMove', 'mouseDrag'})
459
+
460
+ while True:
461
+ # block until at least one event is available
462
+ batch = [self._eventQueue.get()]
463
+ if batch[0] is None:
464
+ break
465
+
466
+ # drain any additional immediately-available events into the batch
467
+ try:
468
+ while True:
469
+ msg = self._eventQueue.get(block=False)
470
+ batch.append(msg)
471
+ if msg is None:
472
+ break # sentinel — process batch then exit
473
+ except queue.Empty:
474
+ pass
475
+
476
+ # process the batch in order, coalescing consecutive position events
477
+ i = 0
478
+ while i < len(batch):
479
+ msg = batch[i]
480
+ if msg is None:
481
+ return # sentinel reached mid-batch
482
+
483
+ eventType = msg.get('type')
484
+ if eventType in _COALESCE:
485
+ target = msg.get('target')
486
+ # find the last consecutive event of the same type+target
487
+ j = i + 1
488
+ while (j < len(batch)
489
+ and batch[j] is not None
490
+ and batch[j].get('type') == eventType
491
+ and batch[j].get('target') == target):
492
+ j += 1
493
+ with self._callbackActive:
494
+ self._dispatchEvent(batch[j - 1]) # skip stale positions
495
+ self._flushBuffer()
496
+ i = j
497
+ else:
498
+ with self._callbackActive:
499
+ self._dispatchEvent(msg)
500
+ self._flushBuffer()
501
+ i += 1
502
+
317
503
  def _dispatchEvent(self, eventDict):
318
504
  """
319
505
  Looks up and calls the callback registered for the incoming event.
320
- Called from the listener thread; the callback runs on that thread.
506
+ Called from the dispatch thread.
321
507
  """
322
508
  eventType = eventDict.get('type')
323
509
  objectId = eventDict.get('target')
@@ -334,23 +520,18 @@ class GuiHandler:
334
520
 
335
521
  def _shutdown(self):
336
522
  """
337
- Kills the child process immediately and closes the pipe.
523
+ Signals the child to shut down and waits for it to exit.
524
+ Stops the sender thread first so any remaining buffered commands are
525
+ flushed before the pipe closes. Closing the shutdown pipe delivers EOF
526
+ to the child's QSocketNotifier, which fires _onShutdownSignal on Qt's
527
+ main thread immediately — even if atexit does not run, since the OS
528
+ closes the pipe fd on process death.
338
529
  Called automatically via atexit when the parent process exits.
339
- The child is killed with SIGKILL so it does not drain its event queue —
340
- exit() in the parent should return promptly.
341
- """
342
- if self.childProcess.is_alive():
343
- # SIGTERM is overridden in the child to call os._exit(0) immediately,
344
- # bypassing Qt's graceful shutdown (which drains the event queue).
345
- # Python delivers SIGTERM between bytecodes, so it fires within one
346
- # iteration of the child's _pollPipe loop — far faster than SIGKILL,
347
- # which macOS may defer while a Metal/GPU operation is in flight.
348
- self.childProcess.terminate()
349
- self.childProcess.join(timeout=0.5) # should die in < 1 ms
350
- if self.childProcess.is_alive():
351
- self.childProcess.kill() # backup: SIGKILL
352
- self.childProcess.join(timeout=2.0)
353
- self.connection.close()
530
+ """
531
+ self._senderStopEvent.set()
532
+ self._senderThread.join(timeout=1.0)
533
+ self._adminConn.close() # EOF on child's admin pipe triggers shutdown
534
+ self.childProcess.join(timeout=1.0)
354
535
 
355
536
 
356
537
  #######################################################################################
@@ -370,8 +551,10 @@ class _NullHandler:
370
551
  def sendCommand(self, action, target, args=None): pass
371
552
  def sendQuery(self, action, target, args=None): return [0, 0, 0, 0]
372
553
  def registerEvent(self, objectId, eventType, callback): pass
373
- def getBatchSize(self): return 0
374
- def setBatchSize(self, batchSize): pass
554
+ def getFlushRate(self): return _FLUSH_RATE
555
+ def setFlushRate(self, flushRate): pass
556
+ def getRate(self): return _FLUSH_RATE
557
+ def setRate(self, _rate): pass
375
558
 
376
559
 
377
560
  def _createHandler():