CreativePython 1.0.0__tar.gz → 1.1.1__tar.gz
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.
- {creativepython-1.0.0/src/CreativePython.egg-info → creativepython-1.1.1}/PKG-INFO +2 -1
- {creativepython-1.0.0 → creativepython-1.1.1}/pyproject.toml +2 -1
- creativepython-1.1.1/src/CreativePython/GuiHandler.py +518 -0
- creativepython-1.1.1/src/CreativePython/GuiRenderer.py +3222 -0
- {creativepython-1.0.0 → creativepython-1.1.1/src/CreativePython.egg-info}/PKG-INFO +2 -1
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython.egg-info/SOURCES.txt +2 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython.egg-info/requires.txt +1 -0
- creativepython-1.1.1/src/gui.py +3925 -0
- creativepython-1.1.1/src/image.py +70 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/midi.py +5 -5
- {creativepython-1.0.0 → creativepython-1.1.1}/src/music.py +20 -20
- {creativepython-1.0.0 → creativepython-1.1.1}/src/timer.py +32 -171
- creativepython-1.0.0/src/gui.py +0 -4458
- creativepython-1.0.0/src/image.py +0 -128
- {creativepython-1.0.0 → creativepython-1.1.1}/LICENSE +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/LICENSE-PSF +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/MANIFEST.in +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/README.md +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/setup.cfg +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/RealtimeAudioPlayer.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/__init__.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/Java-Comparison-Tests/advMetricRunner.pythonSurvey.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/Java-Comparison-Tests/compareMetrics_Java-Vs-Python.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/RunMetrics.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/Surveyor.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/__init__.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/data/Confidence.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/data/Contig.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/data/ExtendedNote.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/data/Histogram.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/data/Judgement.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/data/Measurement.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/data/PianoRoll.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/data/PianoRollOld.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/data/__init__.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/data/test_ExtendedNote.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/data/test_Histogram.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/data/test_Measurement.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/data/test_PianoRoll_assertions.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/data/test_PianoRoll_integration.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/data/test_PianoRoll_quantization.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/data/test_PianoRoll_unit.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/Metric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/ZipfMetrics.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/__init__.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/ChordDensityMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/ChordDistanceMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/ChordMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/ChordNormalizedMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/ChromaticToneMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/ContourBasslineDurationMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/ContourBasslineDurationQuantizedMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/ContourBasslinePitchMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyDurationMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyDurationQuantizedMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyPitchMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/DurationBigramMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/DurationDistanceMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/DurationMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedBigramMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedDistanceMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/HarmonicBigramMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/HarmonicConsonanceMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/HarmonicIntervalMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/MelodicBigramMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/MelodicConsonanceMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/MelodicIntervalMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/PitchDistanceMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/PitchDurationMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/PitchDurationQuantizedMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/PitchMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/RestMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/__init__.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/test_DurationMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_BasicIntervalsAndBigrams.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_ChordsAndConsonance.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_ContoursAndChromatic.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_QuantizedDurationsAndDistances.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/test_PitchMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/simple/test_RestMetric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/metrics/test_Metric.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/utilities/CSVWriter.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/utilities/PowerLawRandom.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/nevmuse/utilities/__init__.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython/notationRenderer.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython.egg-info/dependency_links.txt +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/CreativePython.egg-info/top_level.txt +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/bin/libportaudio.2.dylib +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/iannix.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/markov.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/osc.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/src/zipf.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/tests/testAnimate.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/tests/testPeer.py +0 -0
- {creativepython-1.0.0 → creativepython-1.1.1}/tests/test_keyEvent.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: CreativePython
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.1
|
|
4
4
|
Summary: A Python-based software environment for developing algorithmic art projects.
|
|
5
5
|
Author-email: "Dr. Bill Manaris" <manaris@cofc.edu>, Taj Ballinger <ballingertj@g.cofc.edu>, Trevor Ritchie <ritchiets@g.cofc.edu>
|
|
6
6
|
License: MIT License
|
|
@@ -53,6 +53,7 @@ Description-Content-Type: text/markdown
|
|
|
53
53
|
License-File: LICENSE
|
|
54
54
|
License-File: LICENSE-PSF
|
|
55
55
|
Requires-Dist: setuptools>=69.0
|
|
56
|
+
Requires-Dist: wheel>=0.46.0
|
|
56
57
|
Requires-Dist: tinysoundfont>=0.3.6
|
|
57
58
|
Requires-Dist: osc4py3>=1.0.8
|
|
58
59
|
Requires-Dist: mido>=1.3.3
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "CreativePython"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.1.1"
|
|
8
8
|
description = "A Python-based software environment for developing algorithmic art projects."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -22,6 +22,7 @@ classifiers = [
|
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
24
|
"setuptools>=69.0",
|
|
25
|
+
"wheel>=0.46.0",
|
|
25
26
|
"tinysoundfont>=0.3.6",
|
|
26
27
|
"osc4py3>=1.0.8",
|
|
27
28
|
"mido>=1.3.3",
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
#######################################################################################
|
|
2
|
+
# GuiHandler.py Version 1.0 12-Mar-2026
|
|
3
|
+
# Taj Ballinger, Bill Manaris
|
|
4
|
+
#######################################################################################
|
|
5
|
+
#
|
|
6
|
+
# GuiHandler runs in the main CreativePython process.
|
|
7
|
+
# It manages the GuiRenderer child process, delivers commands to Qt, and
|
|
8
|
+
# receives responses and events in return.
|
|
9
|
+
#
|
|
10
|
+
# This file contains no Qt code and never imports PySide6.
|
|
11
|
+
# All Qt activity lives in GuiRenderer.py, which runs in the child process.
|
|
12
|
+
#
|
|
13
|
+
# See GuiRenderer.py for the full protocol description.
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
import multiprocessing
|
|
17
|
+
import threading
|
|
18
|
+
import atexit
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
#######################################################################################
|
|
22
|
+
# Message Protocol Helpers
|
|
23
|
+
#
|
|
24
|
+
# Plain-dict message constructors shared by GuiHandler (parent) and GuiRenderer (child).
|
|
25
|
+
# Keeping them here avoids any import of Qt in the parent process — GuiRenderer imports
|
|
26
|
+
# these helpers from this file, not the other way around.
|
|
27
|
+
#######################################################################################
|
|
28
|
+
|
|
29
|
+
def _createCommand(action, target, args=None, responseId=None):
|
|
30
|
+
"""
|
|
31
|
+
Creates a command message dict (CreativePython -> Qt).
|
|
32
|
+
"""
|
|
33
|
+
return {
|
|
34
|
+
'action': action,
|
|
35
|
+
'target': target,
|
|
36
|
+
'args': args if args is not None else {},
|
|
37
|
+
'responseId': responseId
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def _createResponse(responseId, values=None):
|
|
41
|
+
"""
|
|
42
|
+
Creates a response message dict (Qt -> CreativePython).
|
|
43
|
+
"""
|
|
44
|
+
return {
|
|
45
|
+
'responseId': responseId,
|
|
46
|
+
'values': values if values is not None else []
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def _createEvent(eventType, target, args=None):
|
|
50
|
+
"""
|
|
51
|
+
Creates an event message dict (Qt -> CreativePython).
|
|
52
|
+
"""
|
|
53
|
+
return {
|
|
54
|
+
'type': eventType,
|
|
55
|
+
'target': target,
|
|
56
|
+
'args': args if args is not None else {}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
#######################################################################################
|
|
61
|
+
# Child process entry point
|
|
62
|
+
#######################################################################################
|
|
63
|
+
|
|
64
|
+
def _launchRenderer(childConn):
|
|
65
|
+
"""
|
|
66
|
+
Entry point for the GuiRenderer child process.
|
|
67
|
+
Defined here so GuiHandler can reference it without importing from GuiRenderer
|
|
68
|
+
globally (which would also import Qt/PySide6 in the parent process).
|
|
69
|
+
|
|
70
|
+
At runtime this function only executes in the child process.
|
|
71
|
+
"""
|
|
72
|
+
import sys, os
|
|
73
|
+
|
|
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
|
|
76
|
+
# C-level libraries (Cocoa, XPC) are also silenced.
|
|
77
|
+
_devnull = os.open(os.devnull, os.O_WRONLY)
|
|
78
|
+
os.dup2(_devnull, 2)
|
|
79
|
+
os.close(_devnull)
|
|
80
|
+
sys.stderr = open(os.devnull, 'w')
|
|
81
|
+
|
|
82
|
+
from CreativePython.GuiRenderer import GuiRenderer
|
|
83
|
+
renderer = GuiRenderer(childConn)
|
|
84
|
+
renderer.run()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
#######################################################################################
|
|
88
|
+
# GuiHandler
|
|
89
|
+
#######################################################################################
|
|
90
|
+
|
|
91
|
+
class GuiHandler:
|
|
92
|
+
"""
|
|
93
|
+
Runs in the main CreativePython process.
|
|
94
|
+
Manages the GuiRenderer child process, sends commands, and receives
|
|
95
|
+
responses and events. Instantiated once as the module-level singleton
|
|
96
|
+
_GUI_HANDLER when gui.py is imported; never exposed directly to the user.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self):
|
|
100
|
+
"""
|
|
101
|
+
Spawns the GuiRenderer child process and establishes the IPC Pipe.
|
|
102
|
+
Starts a background listener thread that routes incoming messages to
|
|
103
|
+
waiting sendQuery() callers or registered event callbacks.
|
|
104
|
+
Registers an atexit handler so the child is always cleaned up when
|
|
105
|
+
the parent process exits — no cleanup code required in gui.py.
|
|
106
|
+
"""
|
|
107
|
+
parentConn, childConn = multiprocessing.Pipe(duplex=True)
|
|
108
|
+
self.connection = parentConn
|
|
109
|
+
|
|
110
|
+
# Choose the spawn context based on the runtime environment:
|
|
111
|
+
#
|
|
112
|
+
# fork — fastest; child inherits parent memory without re-importing
|
|
113
|
+
# anything. Safe when the parent has no Cocoa/AppKit state.
|
|
114
|
+
#
|
|
115
|
+
# forkserver — the forkserver helper is started once via 'spawn' (clean,
|
|
116
|
+
# no inherited GUI state); each child is then forked from
|
|
117
|
+
# that clean server. Required under PENCIL: PENCIL's
|
|
118
|
+
# user-subprocess imports tkinter (Cocoa/AppKit), and forking
|
|
119
|
+
# directly from that process prevents Qt from connecting to
|
|
120
|
+
# the macOS window server.
|
|
121
|
+
#
|
|
122
|
+
# spawn — every child is a fresh Python process; re-imports __main__,
|
|
123
|
+
# which would re-run the user's script. Used on Windows where
|
|
124
|
+
# fork is unavailable.
|
|
125
|
+
#
|
|
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.
|
|
129
|
+
import sys
|
|
130
|
+
if sys.platform == 'win32':
|
|
131
|
+
ctx = multiprocessing.get_context('spawn')
|
|
132
|
+
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
|
+
ctx = multiprocessing.get_context('spawn')
|
|
139
|
+
elif 'tkinter' in sys.modules:
|
|
140
|
+
ctx = multiprocessing.get_context('forkserver')
|
|
141
|
+
else:
|
|
142
|
+
ctx = multiprocessing.get_context('fork')
|
|
143
|
+
# With 'spawn' on POSIX (fork+exec), the forked helper calls
|
|
144
|
+
# get_preparation_data() which reads sys.modules['__main__'].__file__.
|
|
145
|
+
# When a user script is running in PENCIL's subprocess, __main__.__file__
|
|
146
|
+
# is set to that script's path (IDLE does this before exec'ing the script).
|
|
147
|
+
# That path ends up in the bootstrap pipe; the GuiRenderer child's prepare()
|
|
148
|
+
# then re-imports the user's script before _launchRenderer ever runs.
|
|
149
|
+
# If the script raises at module level (no __main__ guard, missing imports,
|
|
150
|
+
# etc.) GuiRenderer crashes silently and the pipe breaks.
|
|
151
|
+
# Fix: clear __main__.__file__ and __spec__ for the duration of the fork so
|
|
152
|
+
# get_preparation_data() omits main_path and prepare() skips the re-import.
|
|
153
|
+
_main_mod = sys.modules.get('__main__')
|
|
154
|
+
_saved_file = getattr(_main_mod, '__file__', _UNSET := object())
|
|
155
|
+
_saved_spec = getattr(_main_mod, '__spec__', _UNSET)
|
|
156
|
+
if _main_mod is not None:
|
|
157
|
+
_main_mod.__file__ = None
|
|
158
|
+
_main_mod.__spec__ = None
|
|
159
|
+
try:
|
|
160
|
+
self.childProcess = ctx.Process(
|
|
161
|
+
target = _launchRenderer,
|
|
162
|
+
args = (childConn,),
|
|
163
|
+
daemon = True # child is killed automatically if parent dies unexpectedly
|
|
164
|
+
)
|
|
165
|
+
self.childProcess.start()
|
|
166
|
+
finally:
|
|
167
|
+
if _main_mod is not None:
|
|
168
|
+
if _saved_file is not _UNSET:
|
|
169
|
+
_main_mod.__file__ = _saved_file
|
|
170
|
+
if _saved_spec is not _UNSET:
|
|
171
|
+
_main_mod.__spec__ = _saved_spec
|
|
172
|
+
childConn.close() # parent no longer needs the child end of the Pipe
|
|
173
|
+
|
|
174
|
+
# responseId counter — each sendQuery() call gets a unique ID so the
|
|
175
|
+
# listener thread can route the response back to the correct caller
|
|
176
|
+
self._responseIdCount = 0
|
|
177
|
+
self._responseIdLock = threading.Lock()
|
|
178
|
+
|
|
179
|
+
# pending responses — maps responseId -> {'event': threading.Event, 'values': list}
|
|
180
|
+
# sendQuery() registers a slot here before sending, then blocks on the Event.
|
|
181
|
+
# The listener thread writes values into the slot and signals the Event.
|
|
182
|
+
self._pendingResponses = {}
|
|
183
|
+
self._pendingLock = threading.Lock()
|
|
184
|
+
|
|
185
|
+
# event registry — maps (objectId, eventType) -> callback function
|
|
186
|
+
# Populated by registerEvent(); consulted by the listener thread on each event.
|
|
187
|
+
self._eventRegistry = {}
|
|
188
|
+
|
|
189
|
+
# send lock — Pipe.send() is not thread-safe; all outgoing messages
|
|
190
|
+
# must acquire this lock before writing to the pipe
|
|
191
|
+
self._sendLock = threading.Lock()
|
|
192
|
+
|
|
193
|
+
# single listener thread handles both responses and events, since both
|
|
194
|
+
# arrive on the same connection and reading from it on two threads would race
|
|
195
|
+
self._listenerThread = threading.Thread(
|
|
196
|
+
target = self._listenForMessages,
|
|
197
|
+
daemon = True # thread exits automatically when the main process exits
|
|
198
|
+
)
|
|
199
|
+
self._listenerThread.start()
|
|
200
|
+
|
|
201
|
+
atexit.register(self._shutdown)
|
|
202
|
+
|
|
203
|
+
# ── Response ID ───────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
def _nextResponseId(self):
|
|
206
|
+
"""
|
|
207
|
+
Returns the next unique responseId. Thread-safe.
|
|
208
|
+
"""
|
|
209
|
+
with self._responseIdLock:
|
|
210
|
+
self._responseIdCount += 1
|
|
211
|
+
return self._responseIdCount
|
|
212
|
+
|
|
213
|
+
# ── Listener thread ───────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
def _listenForMessages(self):
|
|
216
|
+
"""
|
|
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).
|
|
222
|
+
"""
|
|
223
|
+
while True:
|
|
224
|
+
try:
|
|
225
|
+
message = self.connection.recv()
|
|
226
|
+
if 'responseId' in message:
|
|
227
|
+
self._handleResponse(message)
|
|
228
|
+
elif 'type' in message:
|
|
229
|
+
self._dispatchEvent(message)
|
|
230
|
+
except EOFError:
|
|
231
|
+
break # pipe closed; thread exits cleanly
|
|
232
|
+
|
|
233
|
+
def _handleResponse(self, responseDict):
|
|
234
|
+
"""
|
|
235
|
+
Routes an incoming response to the sendQuery() call that is waiting for it.
|
|
236
|
+
Called from the listener thread.
|
|
237
|
+
"""
|
|
238
|
+
responseId = responseDict.get('responseId')
|
|
239
|
+
values = responseDict.get('values', [])
|
|
240
|
+
|
|
241
|
+
with self._pendingLock:
|
|
242
|
+
pendingSlot = self._pendingResponses.get(responseId)
|
|
243
|
+
|
|
244
|
+
if pendingSlot is not None:
|
|
245
|
+
pendingSlot['values'] = values # store result before signalling
|
|
246
|
+
pendingSlot['event'].set() # unblock the waiting sendQuery() call
|
|
247
|
+
|
|
248
|
+
# ── Sending ───────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
def sendCommand(self, action, target, args=None):
|
|
251
|
+
"""
|
|
252
|
+
Sends a fire-and-forget command to GuiRenderer (no response expected).
|
|
253
|
+
Thread-safe.
|
|
254
|
+
"""
|
|
255
|
+
command = _createCommand(action, target, args)
|
|
256
|
+
with self._sendLock:
|
|
257
|
+
self.connection.send(command)
|
|
258
|
+
|
|
259
|
+
def sendQuery(self, action, target, args=None):
|
|
260
|
+
"""
|
|
261
|
+
Sends a command to GuiRenderer and blocks until a response is received.
|
|
262
|
+
Returns the response's values list. Thread-safe; multiple callers may
|
|
263
|
+
block concurrently — each is matched to its response by responseId.
|
|
264
|
+
"""
|
|
265
|
+
responseId = self._nextResponseId()
|
|
266
|
+
pendingSlot = {'event': threading.Event(), 'values': None}
|
|
267
|
+
|
|
268
|
+
# register the slot before sending so the listener never misses the response
|
|
269
|
+
with self._pendingLock:
|
|
270
|
+
self._pendingResponses[responseId] = pendingSlot
|
|
271
|
+
|
|
272
|
+
command = _createCommand(action, target, args, responseId=responseId)
|
|
273
|
+
with self._sendLock:
|
|
274
|
+
self.connection.send(command)
|
|
275
|
+
|
|
276
|
+
pendingSlot['event'].wait() # block until the listener signals a response
|
|
277
|
+
|
|
278
|
+
with self._pendingLock:
|
|
279
|
+
del self._pendingResponses[responseId] # clean up the slot
|
|
280
|
+
|
|
281
|
+
return pendingSlot['values']
|
|
282
|
+
|
|
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
|
+
# ── Events ────────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
def registerEvent(self, objectId, eventType, callback):
|
|
305
|
+
"""
|
|
306
|
+
Registers a callback for a specific event type on a specific object.
|
|
307
|
+
Stores the callback locally and notifies GuiRenderer to watch for the
|
|
308
|
+
corresponding Qt event on that object.
|
|
309
|
+
When GuiRenderer sends a matching event, the callback is invoked with
|
|
310
|
+
the event's args dict unpacked as keyword arguments.
|
|
311
|
+
"""
|
|
312
|
+
self._eventRegistry[(objectId, eventType)] = callback
|
|
313
|
+
|
|
314
|
+
registrationArgs = {'objectId': objectId, 'eventType': eventType}
|
|
315
|
+
self.sendCommand('registerEvent', None, registrationArgs)
|
|
316
|
+
|
|
317
|
+
def _dispatchEvent(self, eventDict):
|
|
318
|
+
"""
|
|
319
|
+
Looks up and calls the callback registered for the incoming event.
|
|
320
|
+
Called from the listener thread; the callback runs on that thread.
|
|
321
|
+
"""
|
|
322
|
+
eventType = eventDict.get('type')
|
|
323
|
+
objectId = eventDict.get('target')
|
|
324
|
+
args = eventDict.get('args', {})
|
|
325
|
+
|
|
326
|
+
callback = self._eventRegistry.get((objectId, eventType))
|
|
327
|
+
if callback is not None and callable(callback):
|
|
328
|
+
if isinstance(args, list):
|
|
329
|
+
callback(*args)
|
|
330
|
+
else:
|
|
331
|
+
callback(**args)
|
|
332
|
+
|
|
333
|
+
# ── Shutdown ──────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
def _shutdown(self):
|
|
336
|
+
"""
|
|
337
|
+
Kills the child process immediately and closes the pipe.
|
|
338
|
+
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()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
#######################################################################################
|
|
357
|
+
# Handler factory
|
|
358
|
+
#######################################################################################
|
|
359
|
+
|
|
360
|
+
class _NullHandler:
|
|
361
|
+
"""
|
|
362
|
+
Stub returned by _createHandler() when called from a spawned child process.
|
|
363
|
+
|
|
364
|
+
On Windows (and forkserver on macOS), multiprocessing re-imports __main__
|
|
365
|
+
to reconstruct the child's environment. Any top-level Display or shape
|
|
366
|
+
creation in the user's script would run again during that re-import.
|
|
367
|
+
Returning this stub makes all those calls silently do nothing instead of
|
|
368
|
+
crashing with a RuntimeError or starting another child process.
|
|
369
|
+
"""
|
|
370
|
+
def sendCommand(self, action, target, args=None): pass
|
|
371
|
+
def sendQuery(self, action, target, args=None): return [0, 0, 0, 0]
|
|
372
|
+
def registerEvent(self, objectId, eventType, callback): pass
|
|
373
|
+
def getBatchSize(self): return 0
|
|
374
|
+
def setBatchSize(self, batchSize): pass
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _createHandler():
|
|
378
|
+
"""
|
|
379
|
+
Returns a GuiHandler if called from the main process, or a _NullHandler
|
|
380
|
+
if called from a spawned/forked child process (e.g. during __main__ re-import).
|
|
381
|
+
Also calls freeze_support() and waits briefly for the child process to start.
|
|
382
|
+
"""
|
|
383
|
+
if multiprocessing.parent_process() is not None:
|
|
384
|
+
return _NullHandler()
|
|
385
|
+
multiprocessing.freeze_support()
|
|
386
|
+
import time
|
|
387
|
+
handler = GuiHandler()
|
|
388
|
+
time.sleep(0.2) # allow child process to start
|
|
389
|
+
return handler
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
#######################################################################################
|
|
393
|
+
# Phase 4 Test
|
|
394
|
+
#######################################################################################
|
|
395
|
+
|
|
396
|
+
if __name__ == '__main__':
|
|
397
|
+
# On macOS/Windows, multiprocessing uses 'spawn' by default.
|
|
398
|
+
# This guard prevents the child process from re-running the test on import.
|
|
399
|
+
multiprocessing.freeze_support()
|
|
400
|
+
|
|
401
|
+
import time
|
|
402
|
+
|
|
403
|
+
PASS = '\033[92m✓\033[0m'
|
|
404
|
+
FAIL = '\033[91m✗\033[0m'
|
|
405
|
+
|
|
406
|
+
def check(label, condition):
|
|
407
|
+
print(f' {PASS if condition else FAIL} {label}')
|
|
408
|
+
if not condition:
|
|
409
|
+
raise AssertionError(f'FAILED: {label}')
|
|
410
|
+
|
|
411
|
+
print()
|
|
412
|
+
print('GuiHandler / GuiRenderer — Phase 4 Test')
|
|
413
|
+
print('=' * 40)
|
|
414
|
+
print(' (A small window will appear briefly during this test.)')
|
|
415
|
+
|
|
416
|
+
handler = GuiHandler()
|
|
417
|
+
time.sleep(0.2) # allow Qt event loop to start
|
|
418
|
+
|
|
419
|
+
DISPLAY_ID = 1 # objectId used for the test Display
|
|
420
|
+
|
|
421
|
+
# ── Create Display ────────────────────────────────────────────────────────
|
|
422
|
+
print('\n[1] Create Display')
|
|
423
|
+
|
|
424
|
+
createArgs = {
|
|
425
|
+
'type': 'Display',
|
|
426
|
+
'title': 'Phase 4 Test',
|
|
427
|
+
'width': 300,
|
|
428
|
+
'height': 200,
|
|
429
|
+
'x': 100,
|
|
430
|
+
'y': 100,
|
|
431
|
+
'color': [255, 255, 255, 255] # white
|
|
432
|
+
}
|
|
433
|
+
handler.sendCommand('create', DISPLAY_ID, createArgs)
|
|
434
|
+
time.sleep(0.2) # allow Qt to create and show the window
|
|
435
|
+
|
|
436
|
+
pingResult = handler.sendQuery('ping', None)
|
|
437
|
+
check('Qt event loop alive after create', pingResult == ['pong'])
|
|
438
|
+
|
|
439
|
+
# ── Query initial state ───────────────────────────────────────────────────
|
|
440
|
+
print('\n[2] Query initial state')
|
|
441
|
+
|
|
442
|
+
titleResult = handler.sendQuery('getTitle', DISPLAY_ID)
|
|
443
|
+
check('getTitle returns "Phase 4 Test"', titleResult == ['Phase 4 Test'])
|
|
444
|
+
|
|
445
|
+
sizeResult = handler.sendQuery('getSize', DISPLAY_ID)
|
|
446
|
+
check('getSize returns [300, 200]', sizeResult == [300, 200])
|
|
447
|
+
|
|
448
|
+
widthResult = handler.sendQuery('getWidth', DISPLAY_ID)
|
|
449
|
+
check('getWidth returns 300', widthResult == [300])
|
|
450
|
+
|
|
451
|
+
heightResult = handler.sendQuery('getHeight', DISPLAY_ID)
|
|
452
|
+
check('getHeight returns 200', heightResult == [200])
|
|
453
|
+
|
|
454
|
+
colorResult = handler.sendQuery('getColor', DISPLAY_ID)
|
|
455
|
+
check('getColor returns [255, 255, 255, 255]', colorResult == [255, 255, 255, 255])
|
|
456
|
+
|
|
457
|
+
posResult = handler.sendQuery('getPosition', DISPLAY_ID)
|
|
458
|
+
check('getPosition returns two integers',
|
|
459
|
+
len(posResult) == 2 and all(isinstance(v, int) for v in posResult))
|
|
460
|
+
|
|
461
|
+
# ── Setters round-trip ────────────────────────────────────────────────────
|
|
462
|
+
print('\n[3] Setters round-trip')
|
|
463
|
+
|
|
464
|
+
handler.sendCommand('setTitle', DISPLAY_ID, {'title': 'Updated Title'})
|
|
465
|
+
titleResult2 = handler.sendQuery('getTitle', DISPLAY_ID)
|
|
466
|
+
check('setTitle → getTitle round-trips', titleResult2 == ['Updated Title'])
|
|
467
|
+
|
|
468
|
+
handler.sendCommand('setColor', DISPLAY_ID, {'color': [0, 0, 255, 255]})
|
|
469
|
+
colorResult2 = handler.sendQuery('getColor', DISPLAY_ID)
|
|
470
|
+
check('setColor → getColor round-trips', colorResult2 == [0, 0, 255, 255])
|
|
471
|
+
|
|
472
|
+
handler.sendCommand('setSize', DISPLAY_ID, {'width': 400, 'height': 300})
|
|
473
|
+
sizeResult2 = handler.sendQuery('getSize', DISPLAY_ID)
|
|
474
|
+
check('setSize → getSize round-trips', sizeResult2 == [400, 300])
|
|
475
|
+
|
|
476
|
+
handler.sendCommand('setPosition', DISPLAY_ID, {'x': 150, 'y': 150})
|
|
477
|
+
posResult2 = handler.sendQuery('getPosition', DISPLAY_ID)
|
|
478
|
+
check('setPosition returns two integers',
|
|
479
|
+
len(posResult2) == 2 and all(isinstance(v, int) for v in posResult2))
|
|
480
|
+
|
|
481
|
+
# ── Visibility ────────────────────────────────────────────────────────────
|
|
482
|
+
print('\n[4] Visibility')
|
|
483
|
+
|
|
484
|
+
handler.sendCommand('hide', DISPLAY_ID)
|
|
485
|
+
time.sleep(0.1)
|
|
486
|
+
check('Qt alive after hide', handler.sendQuery('ping', None) == ['pong'])
|
|
487
|
+
|
|
488
|
+
handler.sendCommand('show', DISPLAY_ID)
|
|
489
|
+
time.sleep(0.1)
|
|
490
|
+
check('Qt alive after show', handler.sendQuery('ping', None) == ['pong'])
|
|
491
|
+
|
|
492
|
+
time.sleep(5) # hang for a few seconds to show Display
|
|
493
|
+
|
|
494
|
+
# ── displayClose event ────────────────────────────────────────────────────
|
|
495
|
+
print('\n[5] displayClose event')
|
|
496
|
+
|
|
497
|
+
closeReceived = []
|
|
498
|
+
|
|
499
|
+
def onDisplayClose():
|
|
500
|
+
closeReceived.append(True)
|
|
501
|
+
|
|
502
|
+
handler.registerEvent(DISPLAY_ID, 'displayClose', onDisplayClose)
|
|
503
|
+
time.sleep(0.1)
|
|
504
|
+
|
|
505
|
+
handler.sendCommand('close', DISPLAY_ID)
|
|
506
|
+
time.sleep(0.2)
|
|
507
|
+
|
|
508
|
+
check('displayClose event received', len(closeReceived) == 1)
|
|
509
|
+
|
|
510
|
+
# ── Shutdown ──────────────────────────────────────────────────────────────
|
|
511
|
+
print('\n[6] Shutdown')
|
|
512
|
+
|
|
513
|
+
handler._shutdown()
|
|
514
|
+
check('child process has exited', not handler.childProcess.is_alive())
|
|
515
|
+
|
|
516
|
+
print()
|
|
517
|
+
print('All Phase 4 tests passed.')
|
|
518
|
+
print()
|