CreativePython 1.1.2__py3-none-any.whl → 1.1.4__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,36 +66,49 @@ def _createEvent(eventType, target, args=None):
61
66
  # Child process entry point
62
67
  #######################################################################################
63
68
 
64
- def _launchRenderer(childConn, shutdownReadConn, shutdownWriteConn):
69
+ def _launchRenderer(childCommandConnection, childPriorityConnection, parentPriorityConnection):
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
 
70
- shutdownReadConn is the read end of the dedicated shutdown pipe; the child
71
- watches it with QSocketNotifier and shuts down when it becomes readable (EOF).
72
- shutdownWriteConn is the write end inherited from the parent (fork) or passed
73
- via pickling (forkserver/spawn); it is closed immediately so that only the
74
- parent's copy of the write end remains open — ensuring the child sees EOF when
75
- the parent closes its copy.
75
+ childPriorityConnection 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 (parentPriorityConnection), the child
78
+ receives EOF and initiates shutdown.
79
+
80
+ parentPriorityConnection 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.
76
82
 
77
83
  At runtime this function only executes in the child process.
78
84
  """
79
- import sys, os
80
-
81
- shutdownWriteConn.close() # child must not hold the write end open
82
-
83
- # Suppress macOS system noise (stale XPC connections inherited via fork)
84
- # that would confuse students. Redirect at the OS level (fd 2) so that
85
- # C-level libraries (Cocoa, XPC) are also silenced.
86
- _devnull = os.open(os.devnull, os.O_WRONLY)
87
- os.dup2(_devnull, 2)
88
- os.close(_devnull)
89
- sys.stderr = open(os.devnull, 'w')
90
-
91
- from CreativePython.GuiRenderer import GuiRenderer
92
- renderer = GuiRenderer(childConn, shutdownReadConn)
93
- renderer.run()
85
+ import sys, os, traceback
86
+
87
+ parentPriorityConnection.close() # child must not hold the parent end open
88
+
89
+ # Redirect stderr (fd 2) to a log file so both Python tracebacks and C-level
90
+ # output from Qt/Cocoa/XPC are captured for diagnostics.
91
+ # The log is removed on clean exit if nothing was written to it.
92
+ _logPath = os.path.join(os.getcwd(), "PENCIL_debug.log")
93
+ _logFile = open(_logPath, "w")
94
+ os.dup2(_logFile.fileno(), 2)
95
+ sys.stderr = _logFile
96
+
97
+ try:
98
+ from CreativePython.GuiRenderer import GuiRenderer
99
+ renderer = GuiRenderer(childCommandConnection, childPriorityConnection)
100
+ renderer.run()
101
+ except Exception:
102
+ traceback.print_exc()
103
+ raise
104
+ finally:
105
+ _logFile.flush()
106
+ if os.path.getsize(_logPath) == 0:
107
+ try:
108
+ _logFile.close()
109
+ os.unlink(_logPath)
110
+ except OSError:
111
+ pass
94
112
 
95
113
 
96
114
  #######################################################################################
@@ -113,8 +131,8 @@ class GuiHandler:
113
131
  Registers an atexit handler so the child is always cleaned up when
114
132
  the parent process exits — no cleanup code required in gui.py.
115
133
  """
116
- parentConn, childConn = multiprocessing.Pipe(duplex=True)
117
- self.connection = parentConn
134
+ parentCommandConnection, childCommandConnection = multiprocessing.Pipe(duplex=True)
135
+ self.connection = parentCommandConnection
118
136
 
119
137
  # Choose the spawn context based on the runtime environment:
120
138
  #
@@ -132,31 +150,32 @@ class GuiHandler:
132
150
  # which would re-run the user's script. Used on Windows where
133
151
  # fork is unavailable.
134
152
  #
135
- # Detection: if tkinter has been imported into the current process, Cocoa is
136
- # live and we must avoid fork. Otherwise, fork is safe and avoids the
137
- # __main__ re-import problem that spawn/forkserver have.
153
+ # On macOS, fork() is unsafe whenever the parent has any CoreFoundation /
154
+ # Cocoa state not just when tkinter is explicitly imported. The Python
155
+ # runtime on macOS initialises parts of CoreFoundation at startup, so a
156
+ # forked child that then creates a QApplication reliably triggers the
157
+ # "You MUST exec()" crash. forkserver avoids this by forking from a clean
158
+ # helper process that has never touched Cocoa.
159
+ # On Windows, spawn is required (no fork).
160
+ # Inside a PyInstaller frozen binary, forkserver can't work (sys.executable
161
+ # is the frozen app, not a Python interpreter), so spawn is used instead.
138
162
  import sys
139
163
  if sys.platform == 'win32':
140
164
  ctx = multiprocessing.get_context('spawn')
141
165
  elif getattr(sys, 'frozen', False):
142
- # Running inside a PyInstaller frozen executable. forkserver spawns
143
- # its helper by calling sys.executable as a Python interpreter, which
144
- # doesn't work when sys.executable is the frozen app binary. spawn
145
- # is safe here because freeze_support() in the entry point intercepts
146
- # the --multiprocessing-fork flag before any app code runs.
147
166
  ctx = multiprocessing.get_context('spawn')
148
- elif 'tkinter' in sys.modules:
167
+ elif sys.platform == 'darwin':
149
168
  ctx = multiprocessing.get_context('forkserver')
150
169
  else:
151
170
  ctx = multiprocessing.get_context('fork')
152
171
 
153
- # Dedicated one-directional shutdown pipe. The parent holds the write
154
- # end; the child holds the read end. Closing the write end in the parent
155
- # sends EOF to the child's read end, which Qt detects immediately via
156
- # QSocketNotifier independent of the command queue and independent of
157
- # whether atexit runs (the OS closes the fd automatically on process death).
158
- shutdownReadConn, shutdownWriteConn = multiprocessing.Pipe(duplex=False)
159
- self._shutdownPipe = shutdownWriteConn
172
+ # Dedicated duplex admin pipe. The parent holds parentPriorityConnection; the child
173
+ # holds childPriorityConnection. Admin commands (setRate, getRate) travel here,
174
+ # bypassing the command buffer entirely. Closing parentPriorityConnection sends EOF
175
+ # to the child, which Qt detects via QSocketNotifier and treats as shutdown —
176
+ # independent of the command queue and independent of whether atexit runs.
177
+ childPriorityConnection, parentPriorityConnection = multiprocessing.Pipe(duplex=True)
178
+ self._adminConn = parentPriorityConnection
160
179
 
161
180
  # With 'spawn' on POSIX (fork+exec), the forked helper calls
162
181
  # get_preparation_data() which reads sys.modules['__main__'].__file__.
@@ -177,7 +196,7 @@ class GuiHandler:
177
196
  try:
178
197
  self.childProcess = ctx.Process(
179
198
  target = _launchRenderer,
180
- args = (childConn, shutdownReadConn, shutdownWriteConn),
199
+ args = (childCommandConnection, childPriorityConnection, parentPriorityConnection),
181
200
  daemon = True # child is killed automatically if parent dies unexpectedly
182
201
  )
183
202
  self.childProcess.start()
@@ -187,8 +206,8 @@ class GuiHandler:
187
206
  _main_mod.__file__ = _saved_file
188
207
  if _saved_spec is not _UNSET:
189
208
  _main_mod.__spec__ = _saved_spec
190
- childConn.close() # parent no longer needs the child end of the command pipe
191
- shutdownReadConn.close() # parent no longer needs the read end of the shutdown pipe
209
+ childCommandConnection.close() # parent no longer needs the child end of the command pipe
210
+ childPriorityConnection.close() # parent no longer needs the child end of the admin pipe
192
211
 
193
212
  # responseId counter — each sendQuery() call gets a unique ID so the
194
213
  # listener thread can route the response back to the correct caller
@@ -209,14 +228,51 @@ class GuiHandler:
209
228
  # must acquire this lock before writing to the pipe
210
229
  self._sendLock = threading.Lock()
211
230
 
231
+ # command buffer — fire-and-forget commands are queued here and sent as
232
+ # a single batch by the sender thread (or flushed early on a query)
233
+ self._commandBuffer = []
234
+ self._bufferLock = threading.Lock()
235
+
236
+ # flush rate - how often to flush the buffer
237
+ self._flushRate = _FLUSH_RATE
238
+ self._maxBufferSize = _MAX_BUFFER_SIZE
239
+
240
+ # callback active lock — held by the dispatch thread while an event callback
241
+ # is executing. The sender thread checks this non-blocking; if it can't
242
+ # acquire, it skips that flush tick so mid-callback commands don't travel as
243
+ # a partial batch. After the callback, the dispatch thread does one explicit
244
+ # flush to send everything the callback accumulated as a single pipe message.
245
+ self._callbackActive = threading.Lock()
246
+
247
+ # sender thread — wakes every 1/FLUSH_RATE seconds and flushes the buffer
248
+ self._senderStopEvent = threading.Event()
249
+ self._senderThread = threading.Thread(
250
+ target = self._runSenderThread,
251
+ daemon = True
252
+ )
253
+ self._senderThread.start()
254
+
255
+ # event queue — listener thread enqueues incoming events here; dispatch
256
+ # thread drains it and runs callbacks. Decoupling the two threads means
257
+ # callbacks can call sendQuery() without deadlocking the listener.
258
+ self._eventQueue = queue.SimpleQueue()
259
+
212
260
  # single listener thread handles both responses and events, since both
213
261
  # arrive on the same connection and reading from it on two threads would race
214
262
  self._listenerThread = threading.Thread(
215
263
  target = self._listenForMessages,
216
- daemon = True # thread exits automatically when the main process exits
264
+ daemon = True
217
265
  )
218
266
  self._listenerThread.start()
219
267
 
268
+ # dispatch thread runs event callbacks sequentially off the event queue,
269
+ # keeping the listener thread free to receive query responses at all times
270
+ self._dispatchThread = threading.Thread(
271
+ target = self._runDispatchThread,
272
+ daemon = True
273
+ )
274
+ self._dispatchThread.start()
275
+
220
276
  atexit.register(self._shutdown)
221
277
 
222
278
  # ── Response ID ───────────────────────────────────────────────────────────
@@ -234,9 +290,10 @@ class GuiHandler:
234
290
  def _listenForMessages(self):
235
291
  """
236
292
  Background thread. Reads incoming messages from GuiRenderer and routes
237
- each one: responses unblock waiting sendQuery() callers; events invoke
238
- registered callbacks. Exits when the child closes the pipe (EOFError)
239
- and releases any sendQuery() callers still waiting for a response.
293
+ each one: responses unblock waiting sendQuery() callers; events are
294
+ pushed onto _eventQueue for the dispatch thread. Exits when the child
295
+ closes the pipe (EOFError), releases any waiting sendQuery() callers,
296
+ and sends a None sentinel to stop the dispatch thread.
240
297
  """
241
298
  while True:
242
299
  try:
@@ -244,13 +301,14 @@ class GuiHandler:
244
301
  if 'responseId' in message:
245
302
  self._handleResponse(message)
246
303
  elif 'type' in message:
247
- self._dispatchEvent(message)
304
+ self._eventQueue.put(message)
248
305
  except EOFError:
249
306
  break
250
307
  with self._pendingLock:
251
308
  for slot in self._pendingResponses.values():
252
309
  slot['values'] = ['shutdown']
253
310
  slot['event'].set()
311
+ self._eventQueue.put(None) # sentinel: tell dispatch thread to stop
254
312
 
255
313
  def _handleResponse(self, responseDict):
256
314
  """
@@ -267,22 +325,91 @@ class GuiHandler:
267
325
  pendingSlot['values'] = values # store result before signalling
268
326
  pendingSlot['event'].set() # unblock the waiting sendQuery() call
269
327
 
328
+ # ── Sender thread ─────────────────────────────────────────────────────────
329
+
330
+ def _runSenderThread(self):
331
+ """
332
+ Background thread. Wakes every 1 / _flushRate seconds and flushes the
333
+ command buffer. Skips a tick if a callback is currently executing
334
+ (_callbackActive is held) so the callback's commands don't get split
335
+ across multiple pipe messages. Exits when _senderStopEvent is set.
336
+ """
337
+ while not self._senderStopEvent.wait(timeout=1.0 / self._flushRate):
338
+ if self._callbackActive.acquire(blocking=False):
339
+ self._callbackActive.release()
340
+ self._flushBuffer()
341
+ # else: callback in progress — skip this tick
342
+ self._flushBuffer() # final drain on shutdown
343
+
344
+ def _flushBuffer(self):
345
+ """
346
+ Sends the current command buffer as a single batch message, then resets
347
+ the buffer. Thread-safe; concurrent callers are safe — only one will
348
+ get a non-empty batch, the others are no-ops.
349
+ """
350
+ with self._bufferLock:
351
+ batch = self._commandBuffer
352
+ if not batch:
353
+ return
354
+ self._commandBuffer = []
355
+ with self._sendLock:
356
+ try:
357
+ self.connection.send(batch)
358
+ except (BrokenPipeError, OSError):
359
+ self._senderStopEvent.set() # pipe is gone; stop the sender thread
360
+
361
+ def getFlushRate(self):
362
+ """
363
+ Returns the current tick rate for flushing the command buffer.
364
+ """
365
+ return self._flushRate
366
+
367
+ def setFlushRate(self, flushRate):
368
+ """
369
+ Sets the current tick rate for flushing the command buffer.
370
+ """
371
+ self._flushRate = flushRate
372
+
373
+ def getRate(self):
374
+ """
375
+ Returns the current render rate (timer ticks per second) of the Qt renderer.
376
+ Sends directly on the admin pipe, bypassing the command buffer.
377
+ """
378
+ self._adminConn.send({'action': 'getRate'})
379
+ return self._adminConn.recv()
380
+
381
+ def setRate(self, rate):
382
+ """
383
+ Sets the render rate (timer ticks per second) of the Qt renderer.
384
+ Sends directly on the admin pipe, bypassing the command buffer.
385
+ """
386
+ self._adminConn.send({'action': 'setRate', 'rate': rate})
387
+
270
388
  # ── Sending ───────────────────────────────────────────────────────────────
271
389
 
272
390
  def sendCommand(self, action, target, args=None):
273
391
  """
274
- Sends a fire-and-forget command to GuiRenderer (no response expected).
275
- Thread-safe.
392
+ Queues a fire-and-forget command in the command buffer. The sender thread
393
+ flushes on each tick. Thread-safe.
276
394
  """
277
395
  command = _createCommand(action, target, args)
278
- with self._sendLock:
279
- self.connection.send(command)
396
+ with self._bufferLock:
397
+ self._commandBuffer.append(command)
398
+ flush_now = len(self._commandBuffer) >= self._maxBufferSize
399
+ if flush_now and self._callbackActive.acquire(blocking=False):
400
+ self._callbackActive.release()
401
+ self._flushBuffer()
280
402
 
281
403
  def sendQuery(self, action, target, args=None):
282
404
  """
283
405
  Sends a command to GuiRenderer and blocks until a response is received.
284
406
  Returns the response's values list. Thread-safe; multiple callers may
285
407
  block concurrently — each is matched to its response by responseId.
408
+
409
+ Flushes any buffered fire-and-forget commands before sending the query
410
+ so that GuiRenderer processes them first and the query reflects up-to-date
411
+ state. The flush and query send are both performed under _sendLock so
412
+ no other thread can interleave a send between them.
286
413
  """
287
414
  responseId = self._nextResponseId()
288
415
  pendingSlot = {'event': threading.Event(), 'values': None}
@@ -292,8 +419,16 @@ class GuiHandler:
292
419
  self._pendingResponses[responseId] = pendingSlot
293
420
 
294
421
  command = _createCommand(action, target, args, responseId=responseId)
422
+
423
+ # grab any buffered commands, then send them + the query atomically as one list
424
+ with self._bufferLock:
425
+ batch = self._commandBuffer
426
+ self._commandBuffer = []
295
427
  with self._sendLock:
296
- self.connection.send(command)
428
+ try:
429
+ self.connection.send(batch + [command])
430
+ except (BrokenPipeError, OSError):
431
+ pass # pipe is gone; listener thread will release the pending slot via EOFError
297
432
 
298
433
  pendingSlot['event'].wait() # block until the listener signals a response
299
434
 
@@ -302,25 +437,6 @@ class GuiHandler:
302
437
 
303
438
  return pendingSlot['values']
304
439
 
305
- # ── Developer utilities ───────────────────────────────────────────────────
306
-
307
- def getBatchSize(self):
308
- """
309
- Returns the current _BATCH_SIZE used by GuiRenderer's pipe-polling loop.
310
- Not part of the public API — intended for developer tuning only.
311
- """
312
- result = self.sendQuery('getBatchSize', None)
313
- return result[0]
314
-
315
- def setBatchSize(self, batchSize):
316
- """
317
- Sets GuiRenderer's _BATCH_SIZE on the fly.
318
- Higher values process more commands between Qt repaints (better throughput,
319
- less responsive feel); lower values yield more frequent repaints.
320
- Not part of the public API — intended for developer tuning only.
321
- """
322
- self.sendCommand('setBatchSize', None, {'batchSize': batchSize})
323
-
324
440
  # ── Events ────────────────────────────────────────────────────────────────
325
441
 
326
442
  def registerEvent(self, objectId, eventType, callback):
@@ -336,10 +452,70 @@ class GuiHandler:
336
452
  registrationArgs = {'objectId': objectId, 'eventType': eventType}
337
453
  self.sendCommand('registerEvent', None, registrationArgs)
338
454
 
455
+ def _runDispatchThread(self):
456
+ """
457
+ Background thread. Drains _eventQueue and runs event callbacks
458
+ sequentially. Decoupled from the listener thread so callbacks can call
459
+ sendQuery() without deadlocking the listener.
460
+
461
+ Coalesces consecutive position events (mouseMove, mouseDrag) for the same
462
+ target: when multiple are queued back-to-back, only the last one is
463
+ dispatched. This prevents lag when a callback involves a query round-trip
464
+ (e.g. intersects()) that causes events to accumulate faster than they are
465
+ consumed. All other event types (clicks, keys, enter/exit) are always
466
+ dispatched in full order.
467
+
468
+ Exits when it receives the None sentinel pushed by _listenForMessages.
469
+ """
470
+ _COALESCE = frozenset({'mouseMove', 'mouseDrag'})
471
+
472
+ while True:
473
+ # block until at least one event is available
474
+ batch = [self._eventQueue.get()]
475
+ if batch[0] is None:
476
+ break
477
+
478
+ # drain any additional immediately-available events into the batch
479
+ try:
480
+ while True:
481
+ msg = self._eventQueue.get(block=False)
482
+ batch.append(msg)
483
+ if msg is None:
484
+ break # sentinel — process batch then exit
485
+ except queue.Empty:
486
+ pass
487
+
488
+ # process the batch in order, coalescing consecutive position events
489
+ i = 0
490
+ while i < len(batch):
491
+ msg = batch[i]
492
+ if msg is None:
493
+ return # sentinel reached mid-batch
494
+
495
+ eventType = msg.get('type')
496
+ if eventType in _COALESCE:
497
+ target = msg.get('target')
498
+ # find the last consecutive event of the same type+target
499
+ j = i + 1
500
+ while (j < len(batch)
501
+ and batch[j] is not None
502
+ and batch[j].get('type') == eventType
503
+ and batch[j].get('target') == target):
504
+ j += 1
505
+ with self._callbackActive:
506
+ self._dispatchEvent(batch[j - 1]) # skip stale positions
507
+ self._flushBuffer()
508
+ i = j
509
+ else:
510
+ with self._callbackActive:
511
+ self._dispatchEvent(msg)
512
+ self._flushBuffer()
513
+ i += 1
514
+
339
515
  def _dispatchEvent(self, eventDict):
340
516
  """
341
517
  Looks up and calls the callback registered for the incoming event.
342
- Called from the listener thread; the callback runs on that thread.
518
+ Called from the dispatch thread.
343
519
  """
344
520
  eventType = eventDict.get('type')
345
521
  objectId = eventDict.get('target')
@@ -357,12 +533,16 @@ class GuiHandler:
357
533
  def _shutdown(self):
358
534
  """
359
535
  Signals the child to shut down and waits for it to exit.
360
- Closing the shutdown pipe delivers EOF to the child's QSocketNotifier,
361
- which fires _onShutdownSignal on Qt's main thread immediately even if
362
- atexit does not run, since the OS closes the pipe fd on process death.
536
+ Stops the sender thread first so any remaining buffered commands are
537
+ flushed before the pipe closes. Closing the shutdown pipe delivers EOF
538
+ to the child's QSocketNotifier, which fires _onShutdownSignal on Qt's
539
+ main thread immediately — even if atexit does not run, since the OS
540
+ closes the pipe fd on process death.
363
541
  Called automatically via atexit when the parent process exits.
364
542
  """
365
- self._shutdownPipe.close()
543
+ self._senderStopEvent.set()
544
+ self._senderThread.join(timeout=1.0)
545
+ self._adminConn.close() # EOF on child's admin pipe triggers shutdown
366
546
  self.childProcess.join(timeout=1.0)
367
547
 
368
548
 
@@ -383,8 +563,10 @@ class _NullHandler:
383
563
  def sendCommand(self, action, target, args=None): pass
384
564
  def sendQuery(self, action, target, args=None): return [0, 0, 0, 0]
385
565
  def registerEvent(self, objectId, eventType, callback): pass
386
- def getBatchSize(self): return 0
387
- def setBatchSize(self, batchSize): pass
566
+ def getFlushRate(self): return _FLUSH_RATE
567
+ def setFlushRate(self, flushRate): pass
568
+ def getRate(self): return _FLUSH_RATE
569
+ def setRate(self, _rate): pass
388
570
 
389
571
 
390
572
  def _createHandler():