pyglet 2.1.dev5__py3-none-any.whl → 2.1.dev7__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.
Files changed (63) hide show
  1. pyglet/__init__.py +33 -17
  2. pyglet/__init__.pyi +15 -1
  3. pyglet/app/__init__.py +1 -1
  4. pyglet/app/base.py +1 -1
  5. pyglet/app/cocoa.py +65 -25
  6. pyglet/customtypes.py +1 -1
  7. pyglet/display/__init__.py +5 -1
  8. pyglet/display/__init__.pyi +4 -0
  9. pyglet/event.py +11 -15
  10. pyglet/experimental/geoshader_sprite.py +12 -9
  11. pyglet/experimental/jobs.py +124 -0
  12. pyglet/experimental/multitexture_sprite.py +20 -11
  13. pyglet/experimental/particles.py +11 -9
  14. pyglet/gl/__init__.py +5 -3
  15. pyglet/gl/cocoa.py +5 -0
  16. pyglet/graphics/__init__.py +57 -19
  17. pyglet/graphics/shader.py +164 -89
  18. pyglet/graphics/vertexbuffer.py +80 -36
  19. pyglet/graphics/vertexdomain.py +99 -144
  20. pyglet/gui/ninepatch.py +63 -17
  21. pyglet/gui/widgets.py +31 -18
  22. pyglet/image/__init__.py +580 -612
  23. pyglet/image/animation.py +7 -8
  24. pyglet/image/codecs/png.py +2 -2
  25. pyglet/input/__init__.py +6 -6
  26. pyglet/input/base.py +15 -36
  27. pyglet/input/controller_db.py +5 -1
  28. pyglet/input/linux/evdev.py +47 -65
  29. pyglet/input/macos/darwin_hid.py +3 -1
  30. pyglet/libs/darwin/cocoapy/cocoalibs.py +2 -0
  31. pyglet/libs/darwin/cocoapy/runtime.py +110 -72
  32. pyglet/libs/win32/com.py +9 -7
  33. pyglet/math.py +103 -72
  34. pyglet/media/codecs/base.py +3 -109
  35. pyglet/media/drivers/base.py +88 -4
  36. pyglet/media/player.py +2 -9
  37. pyglet/model/__init__.py +34 -41
  38. pyglet/model/codecs/base.py +104 -0
  39. pyglet/model/codecs/gltf.py +295 -172
  40. pyglet/model/codecs/obj.py +33 -39
  41. pyglet/resource.py +30 -50
  42. pyglet/shapes.py +8 -5
  43. pyglet/sprite.py +23 -4
  44. pyglet/text/__init__.py +1 -1
  45. pyglet/text/document.py +53 -7
  46. pyglet/text/formats/attributed.py +1 -3
  47. pyglet/text/formats/plaintext.py +1 -1
  48. pyglet/text/formats/structured.py +1 -1
  49. pyglet/text/layout/base.py +18 -14
  50. pyglet/text/layout/incremental.py +2 -5
  51. pyglet/text/runlist.py +114 -0
  52. pyglet/window/__init__.py +87 -49
  53. pyglet/window/cocoa/__init__.py +69 -25
  54. pyglet/window/cocoa/pyglet_delegate.py +36 -8
  55. pyglet/window/cocoa/pyglet_textview.py +8 -5
  56. pyglet/window/cocoa/pyglet_view.py +31 -31
  57. pyglet/window/headless/__init__.py +1 -1
  58. pyglet/window/win32/__init__.py +50 -28
  59. pyglet/window/xlib/__init__.py +26 -16
  60. {pyglet-2.1.dev5.dist-info → pyglet-2.1.dev7.dist-info}/METADATA +1 -1
  61. {pyglet-2.1.dev5.dist-info → pyglet-2.1.dev7.dist-info}/RECORD +63 -60
  62. {pyglet-2.1.dev5.dist-info → pyglet-2.1.dev7.dist-info}/LICENSE +0 -0
  63. {pyglet-2.1.dev5.dist-info → pyglet-2.1.dev7.dist-info}/WHEEL +0 -0
pyglet/__init__.py CHANGED
@@ -8,14 +8,14 @@ import os
8
8
  import sys
9
9
  from collections.abc import ItemsView, Sequence
10
10
  from dataclasses import dataclass
11
- from typing import TYPE_CHECKING
11
+ from typing import TYPE_CHECKING, Literal
12
12
 
13
13
  if TYPE_CHECKING:
14
14
  from types import FrameType
15
15
  from typing import Any, Callable, ItemsView, Sized
16
16
 
17
17
  #: The release version
18
- version = '2.1.dev5'
18
+ version = '2.1.dev7'
19
19
  __version__ = version
20
20
 
21
21
  MIN_PYTHON_VERSION = 3, 8
@@ -25,9 +25,6 @@ if sys.version_info < MIN_PYTHON_VERSION:
25
25
  msg = f"pyglet {version} requires Python {MIN_PYTHON_VERSION_STR} or newer."
26
26
  raise Exception(msg)
27
27
 
28
- if "sphinx" in sys.modules:
29
- sys.is_pyglet_doc_run = True
30
-
31
28
  # pyglet platform treats *BSD systems as Linux
32
29
  compat_platform = sys.platform
33
30
  if "bsd" in compat_platform:
@@ -48,6 +45,7 @@ _OPTION_TYPE_VALIDATORS = {
48
45
  "int": lambda x: isinstance(x, int),
49
46
  }
50
47
 
48
+
51
49
  @dataclass
52
50
  class Options:
53
51
  """Dataclass for global pyglet options."""
@@ -249,16 +247,29 @@ class Options:
249
247
 
250
248
  .. versionadded:: 2.0.5"""
251
249
 
252
- scale_with_dpi: bool = False
253
- """For 'HiDPI' ('Retina') displays, scale Window creation size with desktop scaling. Defaults to ``False``.
254
-
255
- For high pixel density displays, it is common for the desktop to have some form of application window scaling.
256
- Setting this option to ``True`` will make pyglet aware of these settings when Windows are created. For instance,
257
- if the desktop scaling factor is set to 150%, a Window created with a resolution of 1000x1000 will have an actual
258
- framebuffer size of 1500x1500. Keep in mind that pyglet objects may not be scaled proportionately, so this is left
259
- up to the developer. The :py:attr:`~pyglet.window.Window.scale` & :py:attr:`~pyglet.window.Window.dpi` attributes
260
- can be queried as a reference when determining object creation. By default, windows are created with the actual
261
- number of pixels requested.
250
+ dpi_scaling: Literal["real", "scaled", "stretch"] = "real"
251
+ """For 'HiDPI' displays, Window behavior can differ between operating systems. Defaults to `'real'`.
252
+
253
+ The current options are an attempt to create consistent behavior across all of the operating systems.
254
+
255
+ `'real'` (default): Provides a 1:1 pixel for Window frame size and framebuffer. Primarily used for game applications
256
+ to ensure you are getting the exact pixels for the resolution. If you provide an 800x600 window, you can ensure it
257
+ will be 800x600 pixels when the user chooses it.
258
+
259
+ `'scaled'`: Window size is scaled based on the DPI ratio. Window size and content (projection) size matches the full
260
+ framebuffer. Primarily used for any applications that wish to become DPI aware. You must rescale and reposition your
261
+ content to take advantage of the larger framebuffer. An 800x600 with a 150% DPI scaling would be changed to
262
+ 1200x900 for both `window.get_size` and `window.get_framebuffer_size()`.
263
+
264
+ Keep in mind that pyglet objects may not be scaled proportionately, so this is left up to the developer.
265
+ The :py:attr:`~pyglet.window.Window.scale` & :py:attr:`~pyglet.window.Window.dpi` attributes can be queried as a
266
+ reference when determining object creation.
267
+
268
+ `'stretch'`: Window is scaled based on the DPI ratio. However, content size matches original requested size of the
269
+ window, and is stretched to fit the full framebuffer. This mimics behavior of having no DPI scaling at all. No
270
+ rescaling and repositioning of content will be necessary, but at the cost of blurry content depending on the extent
271
+ of the stretch. For example, 800x600 at 150% DPI will be 800x600 for `window.get_size()` and 1200x900 for
272
+ `window.get_framebuffer_size()`.
262
273
  """
263
274
 
264
275
  shader_bind_management: bool = True
@@ -282,7 +293,7 @@ class Options:
282
293
 
283
294
  def __setitem__(self, key: str, value: Any) -> None:
284
295
  assert key in self.__annotations__, f"Invalid option name: '{key}'"
285
- assert (_SPECIAL_OPTION_VALIDATORS.get(key, None) or _OPTION_TYPE_VALIDATORS[self.__annotations__[key]])(value), \
296
+ assert (_SPECIAL_OPTION_VALIDATORS.get(key) or _OPTION_TYPE_VALIDATORS[self.__annotations__[key]])(value), \
286
297
  f"Invalid type: '{type(value)}' for '{key}'"
287
298
  self.__dict__[key] = value
288
299
 
@@ -290,11 +301,16 @@ class Options:
290
301
  #: Instance of :py:class:`~pyglet.Options` used to set runtime options.
291
302
  options: Options = Options()
292
303
 
304
+ _OPTION_TYPE_REMAPS = {
305
+ "audio": "sequence",
306
+ "vsync": "bool",
307
+ }
293
308
 
294
309
  for _key, _type in options.__annotations__.items():
295
310
  """Check Environment Variables for pyglet options"""
296
311
  if _value := os.environ.get(f"PYGLET_{_key.upper()}"):
297
- if _type == 'tuple':
312
+ _type = _OPTION_TYPE_REMAPS.get(_key, _type)
313
+ if _type == 'sequence':
298
314
  options[_key] = _value.split(",")
299
315
  elif _type == 'bool':
300
316
  options[_key] = _value in ("true", "TRUE", "True", "1")
pyglet/__init__.pyi CHANGED
@@ -1,6 +1,6 @@
1
1
 
2
2
  from dataclasses import dataclass
3
- from typing import Sequence
3
+ from typing import Any, ItemsView, Literal, Sequence
4
4
 
5
5
  from . import app as app
6
6
  from . import clock as clock
@@ -62,6 +62,20 @@ class Options:
62
62
  win32_disable_xinput: bool
63
63
  com_mta: bool
64
64
  osx_alt_loop: bool
65
+ dpi_scaling: Literal["real", "scaled", "stretch"]
66
+ scale_with_dpi: bool
65
67
  shader_bind_management: bool
66
68
 
69
+ def get(self, item: str, default: Any = None) -> Any:
70
+ ...
71
+
72
+ def items(self) -> ItemsView[str, Any]:
73
+ ...
74
+
75
+ def __getitem__(self, item: str) -> Any:
76
+ ...
77
+
78
+ def __setitem__(self, key: str, item: Any) -> None:
79
+ ...
80
+
67
81
  options: Options
pyglet/app/__init__.py CHANGED
@@ -68,7 +68,7 @@ the set when they are no longer referenced or are closed explicitly.
68
68
  """
69
69
 
70
70
 
71
- def run(interval: None | float = 1/60) -> None:
71
+ def run(interval: float | None = 1 / 60) -> None:
72
72
  """Begin processing events, scheduled functions and window updates.
73
73
 
74
74
  This is a convenience function, equivalent to::
pyglet/app/base.py CHANGED
@@ -112,7 +112,7 @@ class EventLoop(event.EventDispatcher):
112
112
  for window in app.windows:
113
113
  window.draw(dt)
114
114
 
115
- def run(self, interval: None | float = 1/60) -> None:
115
+ def run(self, interval: float | None = 1/60) -> None:
116
116
  """Begin processing events, scheduled functions and window updates.
117
117
 
118
118
  This method enters into the main event loop and, if the ``interval``
pyglet/app/cocoa.py CHANGED
@@ -1,4 +1,6 @@
1
1
  import signal
2
+ import time
3
+
2
4
  from pyglet import app
3
5
  from pyglet.app.base import PlatformEventLoop, EventLoop
4
6
  from pyglet.libs.darwin import cocoapy, AutoReleasePool, ObjCSubclass, PyObjectEncoding, ObjCInstance, send_super, \
@@ -11,7 +13,7 @@ NSDate = cocoapy.ObjCClass('NSDate')
11
13
  NSEvent = cocoapy.ObjCClass('NSEvent')
12
14
  NSUserDefaults = cocoapy.ObjCClass('NSUserDefaults')
13
15
  NSTimer = cocoapy.ObjCClass('NSTimer')
14
-
16
+ NSRunningApplication = cocoapy.ObjCClass('NSRunningApplication')
15
17
 
16
18
 
17
19
  def add_menu_item(menu, title, action, key):
@@ -71,6 +73,34 @@ class _AppDelegate_Implementation:
71
73
  def applicationDidFinishLaunching_(self, notification):
72
74
  self._pyglet_loop._finished_launching = True
73
75
 
76
+ # Force App to activate to the foreground due to being an unbundled CLI program.
77
+ # This prevents an issue where if you move the mouse when launching the program, it's focus can be stolen
78
+ # by an app under/behind it leading to a weird state of input and the menu bar being greyed out until
79
+ # reactivating it.
80
+ NSApp = NSApplication.sharedApplication()
81
+
82
+ # Activate dock to ensure all other apps are deactivated.
83
+ dock_str = cocoapy.get_NSString("com.apple.dock")
84
+ running_apps = NSRunningApplication.runningApplicationsWithBundleIdentifier_(dock_str)
85
+ app_count = running_apps.count()
86
+ for i in range(app_count):
87
+ running_app = running_apps.objectAtIndex_(i)
88
+ running_app.activateWithOptions_(cocoapy.NSApplicationActivateIgnoringOtherApps)
89
+ break
90
+
91
+ # Doesn't seem to work unless we add a small sleep for some reason...
92
+ time.sleep(0.01)
93
+
94
+ NSApp.activateIgnoringOtherApps_(True)
95
+
96
+ @_AppDelegate.method('v@')
97
+ def applicationWillFinishLaunching_(self, notification):
98
+ pass
99
+
100
+ @_AppDelegate.method('B')
101
+ def applicationSupportsSecureRestorableState_(self):
102
+ return True
103
+
74
104
  _AppDelegate = ObjCClass('_AppDelegate') # the actual class
75
105
 
76
106
  class CocoaAlternateEventLoop(EventLoop):
@@ -125,7 +155,9 @@ class CocoaAlternateEventLoop(EventLoop):
125
155
  class CocoaPlatformEventLoop(PlatformEventLoop):
126
156
 
127
157
  def __init__(self):
128
- super(CocoaPlatformEventLoop, self).__init__()
158
+ super().__init__()
159
+ self._timer = None
160
+
129
161
  with AutoReleasePool():
130
162
  # Prepare the default application.
131
163
  self.NSApp = NSApplication.sharedApplication()
@@ -147,8 +179,23 @@ class CocoaPlatformEventLoop(PlatformEventLoop):
147
179
  if not defaults.objectForKey_(holdEnabled):
148
180
  defaults.setBool_forKey_(False, holdEnabled)
149
181
 
182
+ self.appdelegate = _AppDelegate.alloc().init(self)
183
+ self.NSApp.setDelegate_(self.appdelegate)
184
+
150
185
  self._finished_launching = False
151
186
 
187
+ def term_received(*args):
188
+ if self._timer:
189
+ self._timer.invalidate()
190
+ self._timer = None
191
+
192
+ if self.NSApp:
193
+ self.NSApp.terminate_(None)
194
+
195
+ # Force NSApp to close if Python receives sig events.
196
+ signal.signal(signal.SIGINT, term_received)
197
+ signal.signal(signal.SIGTERM, term_received)
198
+
152
199
  def start(self):
153
200
  with AutoReleasePool():
154
201
  if not self.NSApp.isRunning() and not self._finished_launching:
@@ -163,38 +210,31 @@ class CocoaPlatformEventLoop(PlatformEventLoop):
163
210
  from pyglet.app import event_loop
164
211
  self._event_loop = event_loop
165
212
 
166
- def term_received(*args):
167
- if self.timer:
168
- self.timer.invalidate()
169
- self.timer = None
213
+ assert self._timer is None
170
214
 
171
- self.nsapp_stop()
172
-
173
- # Force NSApp to close if Python receives sig events.
174
- signal.signal(signal.SIGINT, term_received)
175
- signal.signal(signal.SIGTERM, term_received)
176
-
177
- self.appdelegate = _AppDelegate.alloc().init(self)
178
- self.NSApp.setDelegate_(self.appdelegate)
179
-
180
- self.timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
181
- interval, # Clamped internally to 0.0001 (including 0)
182
- self.appdelegate,
183
- get_selector('updatePyglet:'),
184
- False,
185
- True
186
- )
215
+ self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
216
+ interval, # Clamped internally to 0.0001 (including 0)
217
+ self.appdelegate,
218
+ get_selector('updatePyglet:'),
219
+ False,
220
+ True
221
+ )
187
222
 
188
223
  self.NSApp.run()
189
224
 
190
225
  def nsapp_step(self):
191
226
  """Used only for CocoaAlternateEventLoop"""
192
227
  self._event_loop.idle()
193
- self.dispatch_posted_events()
228
+ with AutoReleasePool():
229
+ self.dispatch_posted_events()
194
230
 
195
231
  def nsapp_stop(self):
196
232
  """Used only for CocoaAlternateEventLoop"""
197
- self.NSApp.terminate_(None)
233
+ self.NSApp.stop_(None)
234
+
235
+ if self._timer:
236
+ self._timer.invalidate()
237
+ self._timer = None
198
238
 
199
239
  def step(self, timeout=None):
200
240
  with AutoReleasePool():
@@ -206,7 +246,7 @@ class CocoaPlatformEventLoop(PlatformEventLoop):
206
246
  # will wait until the next event comes along.
207
247
  timeout_date = NSDate.distantFuture()
208
248
  elif timeout == 0.0:
209
- timeout_date = NSDate.distantPast()
249
+ timeout_date = None
210
250
  else:
211
251
  timeout_date = NSDate.dateWithTimeIntervalSinceNow_(timeout)
212
252
 
pyglet/customtypes.py CHANGED
@@ -14,7 +14,7 @@ else:
14
14
  HorizontalAlign = Literal["left", "center", "right"]
15
15
  AnchorX = Literal["left", "center", "right"]
16
16
  AnchorY = Literal["top", "bottom", "center", "baseline"]
17
- ContentVAlign = Literal["left", "center", "top"]
17
+ ContentVAlign = Literal["bottom", "center", "top"]
18
18
 
19
19
 
20
20
 
@@ -44,10 +44,13 @@ else:
44
44
  from pyglet.display.win32 import Win32Display as Display
45
45
  from pyglet.display.win32 import Win32Screen as Screen
46
46
  from pyglet.display.win32 import Win32Canvas as Canvas
47
- else:
47
+ elif compat_platform == 'linux':
48
48
  from pyglet.display.xlib import XlibDisplay as Display
49
49
  from pyglet.display.xlib import XlibScreen as Screen
50
50
  from pyglet.display.xlib import XlibCanvas as Canvas
51
+ else:
52
+ msg = f"A display interface for '{compat_platform}' is not yet implemented."
53
+ raise NotImplementedError(msg)
51
54
 
52
55
 
53
56
  _displays: weakref.WeakSet = weakref.WeakSet()
@@ -73,4 +76,5 @@ def get_display() -> Display:
73
76
  # Otherwise, create a new display and return it.
74
77
  return Display()
75
78
 
79
+
76
80
  __all__ = ['Display', 'Screen', 'Canvas', 'ScreenMode', 'get_display']
@@ -0,0 +1,4 @@
1
+ from .base import Display
2
+
3
+ def get_display() -> Display:
4
+ ...
pyglet/event.py CHANGED
@@ -120,23 +120,19 @@ from __future__ import annotations
120
120
 
121
121
  import inspect
122
122
  import os.path
123
-
124
123
  from functools import partial
125
- from typing import TYPE_CHECKING
124
+ from typing import TYPE_CHECKING, Literal, Union
126
125
  from weakref import WeakMethod
127
126
 
128
127
  if TYPE_CHECKING:
129
- import sys
130
128
  from typing import Any, Callable, Generator
131
- if sys.version_info >= (3, 11):
132
- from typing import Self
133
- else:
134
- Self = Any
135
129
 
136
130
 
137
131
  EVENT_HANDLED = True
138
132
  EVENT_UNHANDLED = None
139
133
 
134
+ EVENT_HANDLE_STATE = Union[Literal[True], None]
135
+
140
136
 
141
137
  class EventException(Exception): # noqa: N818
142
138
  """An exception raised when an event handler could not be attached."""
@@ -152,7 +148,7 @@ class EventDispatcher:
152
148
  _event_stack: tuple | list = ()
153
149
 
154
150
  @classmethod
155
- def register_event_type(cls: type[Self], name: str) -> str:
151
+ def register_event_type(cls: type[object], name: str) -> str:
156
152
  """Register an event type with the dispatcher.
157
153
 
158
154
  Before dispatching events, they must first be registered by name.
@@ -161,8 +157,8 @@ class EventDispatcher:
161
157
  for suitable handlers.
162
158
  """
163
159
  if not hasattr(cls, 'event_types'):
164
- cls.event_types = []
165
- cls.event_types.append(name)
160
+ cls.event_types = [] # type: ignore reportAttributeAccessIssue
161
+ cls.event_types.append(name) # type: ignore reportAttributeAccessIssue
166
162
  return name
167
163
 
168
164
  def push_handlers(self, *args: Any, **kwargs: Any) -> None:
@@ -181,15 +177,15 @@ class EventDispatcher:
181
177
  self._event_stack = []
182
178
 
183
179
  # Place dict full of new handlers at beginning of stack
184
- self._event_stack.insert(0, {})
180
+ self._event_stack.insert(0, {}) # type: ignore reportAttributeAccessIssue
185
181
  self.set_handlers(*args, **kwargs)
186
182
 
187
- def _get_handlers(self, args: list, kwargs: dict) -> Generator[str, Callable]:
183
+ def _get_handlers(self, args: list, kwargs: dict) -> Generator[tuple[str, Callable], None, None]:
188
184
  """Implement handler matching on arguments for set_handlers and remove_handlers."""
189
185
  for obj in args:
190
186
  if inspect.isroutine(obj):
191
187
  # Single magically named function
192
- name = obj.__name__
188
+ name: str = obj.__name__
193
189
  if name not in self.event_types:
194
190
  msg = f'Unknown event "{name}"'
195
191
  raise EventException(msg)
@@ -438,10 +434,10 @@ class EventDispatcher:
438
434
  def _dump_handlers(self) -> None:
439
435
 
440
436
  for level, handlers in enumerate(self._event_stack):
441
- print(f"level: {level}")
437
+ print(f"level: {level}")
442
438
 
443
439
  for event_type, handler in handlers.items():
444
- print(f" - '{event_type}': {handler}")
440
+ print(f" - '{event_type}': {handler}")
445
441
 
446
442
  # Decorator
447
443
 
@@ -1,12 +1,10 @@
1
+ from __future__ import annotations
2
+
1
3
  import sys
2
4
 
3
5
  import pyglet
4
-
6
+ from pyglet import clock, event, graphics, image
5
7
  from pyglet.gl import *
6
- from pyglet import clock
7
- from pyglet import event
8
- from pyglet import graphics
9
- from pyglet import image
10
8
 
11
9
  _is_pyglet_doc_run = hasattr(sys, "is_pyglet_doc_run") and sys.is_pyglet_doc_run
12
10
 
@@ -209,7 +207,7 @@ class SpriteGroup(graphics.Group):
209
207
  self.program.stop()
210
208
 
211
209
  def __repr__(self):
212
- return "{0}({1})".format(self.__class__.__name__, self.texture)
210
+ return f"{self.__class__.__name__}({self.texture})"
213
211
 
214
212
  def __eq__(self, other):
215
213
  return (other.__class__ is self.__class__ and
@@ -328,8 +326,14 @@ class Sprite(event.EventDispatcher):
328
326
  self._group.blend_dest,
329
327
  program,
330
328
  self._user_group)
331
- self._batch.migrate(self._vertex_list, GL_POINTS, self._group, self._batch)
332
- self._program = program
329
+ if (self._batch and
330
+ self._batch.update_shader(self._vertex_list, GL_POINTS, self._group, program)):
331
+ # Exit early if changing domain is not needed.
332
+ return
333
+
334
+ # Recreate vertex list.
335
+ self._vertex_list.delete()
336
+ self._create_vertex_list()
333
337
 
334
338
  def delete(self):
335
339
  """Force immediate removal of the sprite from video memory.
@@ -596,7 +600,6 @@ class Sprite(event.EventDispatcher):
596
600
  `scale_y` : float
597
601
  Vertical scaling factor.
598
602
  """
599
-
600
603
  translations_outdated = False
601
604
 
602
605
  # only bother updating if the translation actually changed
@@ -0,0 +1,124 @@
1
+ """Experimental multithreaded Job system.
2
+
3
+ This module provides the `JobExecutor` class, which allows submitting
4
+ batches of highly parallizable work in the form of functions and arguments.
5
+ This is commonly known as "task-based multithreading" or a "multithreaded
6
+ job system".
7
+
8
+ ..note:: This module is only really useful with the recent Python 3.13
9
+ releases that are built with experimental free-threading support.
10
+ With typical Python releases that contain a GIL, this is not of
11
+ much practical use.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+
18
+ from queue import Queue
19
+ from threading import Event, Thread
20
+ from typing import TYPE_CHECKING
21
+
22
+ if TYPE_CHECKING:
23
+ from typing import Callable, Any
24
+
25
+
26
+ class _Worker(Thread):
27
+ """A worker thread to pop and run jobs."""
28
+ def __init__(self, workqueue: Queue, exitevent: Event, name: str) -> None:
29
+ super().__init__(name=name, daemon=True)
30
+ self.queue = workqueue
31
+ self.exit = exitevent
32
+ self.start()
33
+
34
+ def run(self) -> None:
35
+ """Parallel thread of execution."""
36
+ _exit = self.exit
37
+ _queue = self.queue
38
+ while not _exit.is_set():
39
+ func, args = _queue.get()
40
+ func(*args)
41
+ _queue.task_done()
42
+
43
+ def __repr__(self) -> str:
44
+ return f"{self.name}(id={self.native_id})"
45
+
46
+
47
+ class JobExecutor:
48
+ """A light-weight multithreaded job system.
49
+
50
+ A JobExecutor loosely mimics the design of the Executor classes from the
51
+ `concurrent.futures` module, but does not share any code with those classes.
52
+ Instead, it is a more light-weight implementation intended for execution of
53
+ highly parallizable functions (jobs). A key difference is that JobExecutor
54
+ does not return `Futures`, and instead depends on the submitted jobs to
55
+ self-contained.
56
+ """
57
+
58
+ def __init__(self, max_workers: int | None = None) -> None:
59
+ """Create an instance of a JobExecutor.
60
+
61
+ Args:
62
+ max_workers: The number of threads to use. If `None`, will
63
+ create half as many threads as reported CPU cores.
64
+ """
65
+ self._max_workers = max_workers or os.cpu_count() // 2
66
+ self._exitevent = Event()
67
+ self._queue = Queue()
68
+ self._threads = [_Worker(self._queue, self._exitevent, f"Thread{i+1}") for i in range(self._max_workers)]
69
+
70
+ def submit(self, func: Callable, *args: Any) -> None:
71
+ """Submit a job to be executed on .
72
+
73
+ A "job" consists of a function to be called, alone with any arguments
74
+ that should be passed to it. Jobs are automatically executed by the next
75
+ free worker thread. No values are returned when jobs are submitted,
76
+ and the functions should also not return any values; return values are
77
+ lost.
78
+
79
+ Args:
80
+ func: A function to execute.
81
+ *args: Any arguments to pass to the function.
82
+ """
83
+ try:
84
+ self._queue.put((func, args))
85
+ except AttributeError:
86
+ raise RuntimeError("cannot submit new tasks after shutdown")
87
+
88
+ def shutdown(self) -> None:
89
+ """Shut down the JobExecutor, terminating all worker threads.
90
+
91
+ All JobExecutor workers are Daemon Threads, so it is not strictly
92
+ necessary to call shutdown() at program termination. However, if
93
+ it is no longer needed, shutdown() can be called ot free up the
94
+ thread resources.
95
+ """
96
+ if not self._queue:
97
+ return
98
+ self._exitevent.set()
99
+ for _ in range(self._max_workers):
100
+ self.submit(lambda: None)
101
+ self._threads.clear()
102
+ self._exitevent = None
103
+ self._queue = None
104
+
105
+ def sync(self) -> None:
106
+ """Wait for all currently submitted jobs to complete.
107
+
108
+ This method will wait until the internal queue is empty, AND all
109
+ currently submitted jobs have completed execution. It can be used
110
+ to fence between separate batches of jobs that should not be run
111
+ at the same time. For example::
112
+
113
+ for chunk in worklist:
114
+ executor.submit(some_function, chunk)
115
+
116
+ executor.sync()
117
+
118
+ window.draw()
119
+
120
+ """
121
+ self._queue.join()
122
+
123
+ def __repr__(self) -> str:
124
+ return f"{self.__class__.__name__}(workers={self._threads})"