mujoco-toolbox 0.3.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.
@@ -0,0 +1,77 @@
1
+ """Mujoco Toolbox.
2
+ ==============
3
+ A toolbox for working with MuJoCo simulations. This package provides various utilities and controllers to facilitate
4
+ the simulation process.
5
+
6
+ Modules
7
+ --------
8
+ - `Wrapper`: Contains the Wrapper class for interfacing with MuJoCo.
9
+ - `Controller`: Includes sineController, cosineController, and randomController for controlling simulations.
10
+ - `Builder`: Contains the Builder class for creating and merging MuJoCo models.
11
+ - `assets`: Contains pre-defined assets for building MuJoCo models.
12
+
13
+ Constants
14
+ ----------
15
+ - CAPTURE_PARAMETERS: List of MjData fields to capture during simulation.
16
+
17
+ Notes
18
+ -----
19
+ This package is still under development. Report any issues to https://github.com/MGross21/mujoco-toolbox/issues.
20
+
21
+ """
22
+
23
+ from .assets import WORLD_ASSETS, glovebox
24
+ from .builder import Builder
25
+ from .controller import (
26
+ cosine_controller,
27
+ live_controller,
28
+ random_controller,
29
+ sine_controller,
30
+ step_controller,
31
+ )
32
+ from .utils import _Platform
33
+ from .wrapper import Wrapper
34
+
35
+ __version__ = "0.3.4"
36
+ __author__ = "Michael Gross"
37
+ __github_repo__ = "mujoco-toolbox"
38
+ __license__ = "MIT"
39
+ __status__ = "Development"
40
+ __all__ = [
41
+ "CAPTURE_PARAMETERS",
42
+ "COMPUTER",
43
+ "WORLD_ASSETS",
44
+ "Builder",
45
+ "Wrapper",
46
+ "cosine_controller",
47
+ "glovebox",
48
+ "live_controller",
49
+ "random_controller",
50
+ "sine_controller",
51
+ "step_controller",
52
+ ]
53
+
54
+ COMPUTER = _Platform() # Singleton Instance
55
+ MAX_GEOM_SCALAR: int = 2 # Scalar value for mujoco.Renderer.max_geom
56
+ PROGRESS_BAR: bool = False # Enable/Disable progress bar
57
+ CAPTURE_PARAMETERS = [ # MjData default fields to capture during simulation
58
+ "time",
59
+ "qpos",
60
+ "qvel",
61
+ "act",
62
+ "qacc",
63
+ "xpos",
64
+ "xquat",
65
+ "xmat",
66
+ "ctrl",
67
+ "sensordata",
68
+ ]
69
+
70
+ if __version__.startswith("0"):
71
+ from .utils import _print_warning
72
+ _print_warning(
73
+ f"{__package__} (v{__version__}) is still under development.",
74
+ f"Report any issues to https://github.com/MGross21/{__github_repo__}/issues",
75
+ )
76
+ del _print_warning
77
+ del _Platform
@@ -0,0 +1,29 @@
1
+ WORLD_ASSETS = """
2
+ <asset>
3
+ <texture type="skybox" builtin="gradient" rgb1=".3 .5 .7" rgb2="0 0 0" width="32" height="512"/>
4
+ <texture name="body" type="cube" builtin="flat" mark="cross" width="128" height="128" rgb1="0.8 0.6 0.4" rgb2="0.8 0.6 0.4" markrgb="1 1 1"/>
5
+ <material name="body" texture="body" texuniform="true" rgba="0.8 0.6 .4 1"/>
6
+ <texture name="grid" type="2d" builtin="checker" width="512" height="512" rgb1=".1 .2 .3" rgb2=".2 .3 .4"/>
7
+ <material name="grid" texture="grid" texrepeat="1 1" texuniform="true" reflectance=".2"/>
8
+ <texture name="background" type="2d" builtin="flat" width="256" height="256" rgb1="1 1 1"/>
9
+ <material name="background" texture="background" texuniform="true" rgba="1 1 1 1"/>
10
+ </asset>
11
+ """
12
+
13
+ def glovebox(width=1.25, depth=0.75, height=1.0, glass_thickness=0.05) -> str:
14
+ return f"""
15
+ <mujoco>
16
+ <asset>
17
+ <material name="glass" rgba="1 1 1 0.2"/>
18
+ </asset>
19
+ <worldbody>
20
+ <body name="walls" pos="0 0 0">
21
+ <geom type="box" size="{width/2} {glass_thickness/2} {height/2}" material="glass" pos="0 {depth/2+glass_thickness/2} {height/2}"/>
22
+ <geom type="box" size="{width/2} {glass_thickness/2} {height/2}" material="glass" pos="0 {-depth/2-glass_thickness/2} {height/2}"/>
23
+ <geom type="box" size="{glass_thickness/2} {depth/2} {height/2}" material="glass" pos="{width/2-glass_thickness/2} 0 {height/2}"/>
24
+ <geom type="box" size="{glass_thickness/2} {depth/2} {height/2}" material="glass" pos="{-width/2+glass_thickness/2} 0 {height/2}"/>
25
+ <geom type="plane" size="{width/2} {depth/2+glass_thickness} {glass_thickness/2}" material="glass" pos="0 0 {height}"/>
26
+ </body>
27
+ </worldbody>
28
+ </mujoco>
29
+ """
@@ -0,0 +1,229 @@
1
+ import os
2
+ import xml.etree.ElementTree as ET
3
+
4
+
5
+ class Builder:
6
+ """Builder class for loading, merging, and saving MuJoCo models from XML strings or file paths.
7
+
8
+ Attributes:
9
+ xml (str): XML string of the model.
10
+
11
+ Examples:
12
+ >>> # Load a model
13
+ >>> builder1 = Builder("path/to/model1.xml")
14
+ >>> # Merge two models from two object instances
15
+ >>> builder2 = Builder("path/to/model2.xml")
16
+ >>> merged_builder = builder1 + builder2
17
+ >>> merged_builder.save("path/to/merged_model.xml")
18
+ >>> # Merge two models from two file paths
19
+ >>> builder = Builder("path/to/model1.xml", "path/to/model2.xml")
20
+ >>> builder.save("path/to/merged_model.xml")
21
+
22
+ """
23
+
24
+ def __init__(self, *args: str) -> None:
25
+ """Initialize with an XML string or a file path. Input is required."""
26
+ for arg in args:
27
+ if not isinstance(arg, str):
28
+ msg = "Input must be an XML string or a file path"
29
+ raise TypeError(msg)
30
+ if len(args) == 0:
31
+ msg = "Input is required to initialize the Builder"
32
+ raise ValueError(msg)
33
+ if len(args) > 1:
34
+ # Create a new Builder instance for each additional argument and merge them
35
+ self.tree, self.root = self._load_model(args[0])
36
+ for additional_arg in args[1:]:
37
+ additional_builder = Builder(additional_arg)
38
+ self.__add__(additional_builder)
39
+ else:
40
+ xml_input = args[0]
41
+ self.tree, self.root = self._load_model(xml_input)
42
+
43
+ def _load_model(self, xml_input):
44
+ """Load a MuJoCo model from a file or XML string."""
45
+ if not isinstance(xml_input, str):
46
+ msg = "Input must be either an XML string or a file path"
47
+ raise ValueError(msg)
48
+
49
+ # Check if input is an XML string
50
+ if xml_input.strip().startswith("<"):
51
+ # Try parsing as XML string
52
+ try:
53
+ root = ET.fromstring(xml_input)
54
+ tree = ET.ElementTree(root)
55
+
56
+ # If it's a URDF (robot tag), wrap it in mujoco
57
+ if root.tag == "robot":
58
+ return self._wrap_urdf_in_mujoco(tree)
59
+
60
+ # If root is not mujoco, wrap it
61
+ if root.tag != "mujoco":
62
+ mujoco_elem = ET.Element("mujoco")
63
+ mujoco_elem.append(root)
64
+ tree = ET.ElementTree(mujoco_elem)
65
+
66
+ return tree, tree.getroot()
67
+ except ET.ParseError:
68
+ msg = "Invalid XML string provided"
69
+ raise ValueError(msg)
70
+ else:
71
+ # Treat input as file path
72
+ if not os.path.exists(xml_input):
73
+ msg = f"File not found: {xml_input}"
74
+ raise FileNotFoundError(msg)
75
+
76
+ # Handle URDF files
77
+ if xml_input.lower().endswith(".urdf"):
78
+ return self._wrap_urdf_in_mujoco(ET.parse(xml_input))
79
+
80
+ # Parse regular XML file
81
+ try:
82
+ tree = ET.parse(xml_input)
83
+ root = tree.getroot()
84
+
85
+ # If root is not mujoco, wrap it
86
+ if root.tag != "mujoco":
87
+ mujoco_elem = ET.Element("mujoco")
88
+ mujoco_elem.append(root)
89
+ tree = ET.ElementTree(mujoco_elem)
90
+
91
+ return tree, tree.getroot()
92
+ except ET.ParseError:
93
+ msg = f"Invalid XML file: {xml_input}"
94
+ raise ValueError(msg)
95
+
96
+ def _wrap_urdf_in_mujoco(self, urdf_input):
97
+ """Wrap a URDF inside <mujoco> tags for MuJoCo compatibility.
98
+ Input can be a file path, ElementTree, or XML string.
99
+ """
100
+ # Handle string input (could be a file path or XML string)
101
+ if isinstance(urdf_input, str):
102
+ # Check if it's an XML string
103
+ if urdf_input.strip().startswith("<"):
104
+ try:
105
+ root = ET.fromstring(urdf_input)
106
+ tree = ET.ElementTree(root)
107
+ except ET.ParseError:
108
+ msg = "Invalid URDF XML string provided"
109
+ raise ValueError(msg)
110
+ else:
111
+ # It's a file path
112
+ try:
113
+ tree = ET.parse(urdf_input)
114
+ except:
115
+ msg = f"Could not parse URDF file: {urdf_input}"
116
+ raise ValueError(msg)
117
+ # Handle ElementTree input
118
+ elif isinstance(urdf_input, ET.ElementTree):
119
+ tree = urdf_input
120
+ else:
121
+ msg = "URDF input must be a string or ElementTree"
122
+ raise TypeError(msg)
123
+
124
+ root = tree.getroot()
125
+
126
+ # Handle the case where the root might already be a mujoco element
127
+ if root.tag == "mujoco":
128
+ return tree, root
129
+
130
+ # Create mujoco element and append the URDF root
131
+ mujoco_elem = ET.Element("mujoco")
132
+ mujoco_elem.append(root)
133
+
134
+ # Create a new tree with the <mujoco> root
135
+ return ET.ElementTree(mujoco_elem), mujoco_elem
136
+
137
+ def _merge_tags(self, tag_name, root_1, root_2) -> None:
138
+ """Merge a specific tag (e.g., worldbody, asset) from two models."""
139
+ section_1 = root_1.find(tag_name)
140
+ section_2 = root_2.find(tag_name)
141
+
142
+ # If target section doesn't exist in model 1 but exists in model 2, create it
143
+ if section_1 is None and section_2 is not None:
144
+ section_1 = ET.SubElement(root_1, tag_name)
145
+
146
+ # Only proceed if both sections exist now
147
+ if section_1 is not None and section_2 is not None:
148
+ # Merge the contents by appending all elements from model 2 to model 1
149
+ for element in list(section_2): # Use list() to avoid modification during iteration
150
+ section_1.append(element)
151
+
152
+ def __add__(self, other):
153
+ """Implement the + operator for merging two models."""
154
+ model_root = None
155
+
156
+ if isinstance(other, Builder):
157
+ # Merge the current model with another Builder instance
158
+ model_root = other.root
159
+ elif isinstance(other, str):
160
+ # If the input is a string, load as XML
161
+ _, model_root = self._load_model(other)
162
+ else:
163
+ msg = "Addition only supported between Builder objects or with XML strings/files"
164
+ raise TypeError(msg)
165
+
166
+ # List of sections to merge
167
+ sections_to_merge = ["asset", "worldbody", "camera", "light", "contact", "equality",
168
+ "sensor", "actuator", "default", "tendon", "include"]
169
+
170
+ # Merge all relevant sections
171
+ for section in sections_to_merge:
172
+ self._merge_tags(section, self.root, model_root)
173
+
174
+ # Return self to allow for method chaining
175
+ return self
176
+
177
+ def __radd__(self, other):
178
+ """Implement the reverse + operator for merging two models."""
179
+ if other == 0: # Handle sum() starting value
180
+ return self
181
+ return self.__add__(other)
182
+
183
+ def save(self, file_path) -> "os.path":
184
+ """Save the merged model to a file."""
185
+ if self.tree is not None:
186
+ # Format the XML with proper indentation before saving
187
+ self._indent_xml(self.root)
188
+ self.tree.write(file_path, encoding="utf-8", xml_declaration=True)
189
+ else:
190
+ msg = "No model loaded. Cannot save."
191
+ raise ValueError(msg)
192
+ return os.path.abspath(file_path)
193
+
194
+ def _indent_xml(self, elem, level=0) -> None:
195
+ """Add proper indentation to make the XML file more readable."""
196
+ i = "\n" + level * " "
197
+ if len(elem):
198
+ if not elem.text or not elem.text.strip():
199
+ elem.text = i + " "
200
+ if not elem.tail or not elem.tail.strip():
201
+ elem.tail = i
202
+ for sub_elem in elem:
203
+ self._indent_xml(sub_elem, level + 1)
204
+ if not elem[-1].tail or not elem[-1].tail.strip():
205
+ elem[-1].tail = i
206
+ elif level and (not elem.tail or not elem.tail.strip()):
207
+ elem.tail = i
208
+
209
+ def __str__(self) -> str:
210
+ """Return the XML string of the model."""
211
+ if self.tree is not None:
212
+ # Format the XML with proper indentation
213
+ self._indent_xml(self.root)
214
+ return ET.tostring(self.root, encoding="unicode", method="xml")
215
+ msg = "No model loaded. Cannot generate string."
216
+ raise ValueError(msg)
217
+
218
+ def __repr__(self) -> str:
219
+ """Return the XML string of the model."""
220
+ return self.__str__()
221
+
222
+ def __len__(self) -> int:
223
+ """Return the number of elements in the model."""
224
+ return len(self.root) if self.root is not None else 0
225
+
226
+ @property
227
+ def xml(self):
228
+ """XML string of the model."""
229
+ return str(self)
@@ -0,0 +1,164 @@
1
+ from numpy import cos, pi, random, sin
2
+
3
+
4
+ def _apply_control(model, data, value, joint=None, axis=None, delay=0) -> None:
5
+ """Common helper function for controller logic to reduce redundancy.
6
+
7
+ Args:
8
+ model: MuJoCo model.
9
+ data: MuJoCo data.
10
+ value: Control value to apply.
11
+ joint (list[int], optional): Joints to apply control to.
12
+ axis (int, optional): Axis to apply control to.
13
+ delay (float, optional): Delay before applying control.
14
+
15
+ """
16
+ # Don't apply control until after delay
17
+ if data.time < delay:
18
+ return
19
+
20
+ # Determine targets if not explicitly provided
21
+ if joint is None and axis is None:
22
+ joint = range(model.nu)
23
+
24
+ # Apply control to joints or specific axis
25
+ if joint is not None and model.nu > 0:
26
+ for j in joint:
27
+ data.ctrl[j] = value
28
+ elif axis is not None:
29
+ data.qpos[axis] = value
30
+
31
+
32
+ def step_controller(model, data, **kwargs) -> None:
33
+ """A step controller for the simulation.
34
+
35
+ Args:
36
+ amplitude (float): The amplitude of the step signal (default=1).
37
+ joint (list[int]): The joints to apply the step signal to (default=all).
38
+ axis (int): The axis to apply the step signal to (default=None).
39
+ delay (float): The delay before applying the step signal (default=0).
40
+
41
+ Returns:
42
+ None
43
+
44
+ """
45
+ amplitude = kwargs.get("amplitude", 1)
46
+ joint = kwargs.get("joint")
47
+ axis = kwargs.get("axis")
48
+ delay = kwargs.get("delay", 0)
49
+
50
+ if delay < 0:
51
+ msg = "Delay must be non-negative."
52
+ raise ValueError(msg)
53
+ if joint is not None and axis is not None:
54
+ msg = "Cannot specify both 'joint' and 'axis'."
55
+ raise ValueError(msg)
56
+
57
+ _apply_control(model, data, amplitude, joint=joint, axis=axis, delay=delay)
58
+
59
+
60
+ def sine_controller(model, data, **kwargs) -> None:
61
+ """A simple sine wave controller for the simulation.
62
+
63
+ Args:
64
+ amplitude (float): The amplitude of the sine wave (default=1).
65
+ frequency (float): The frequency of the sine wave (default=1).
66
+ phase (float): The phase shift of the sine wave (default=0).
67
+ joint (list[int]): The joint to apply the sine wave to (default=all).
68
+ delay (float): The delay before applying the sine wave (default=0).
69
+
70
+ Returns:
71
+ None
72
+
73
+ """
74
+ amplitude = kwargs.get("amplitude", 1)
75
+ frequency = kwargs.get("frequency", 1)
76
+ phase = kwargs.get("phase", 0)
77
+ joint = kwargs.get("joint")
78
+ delay = kwargs.get("delay", 0)
79
+
80
+ if delay < 0:
81
+ msg = "Delay must be non-negative."
82
+ raise ValueError(msg)
83
+
84
+ value = amplitude * sin(2 * pi * frequency * data.time + phase)
85
+ _apply_control(model, data, value, joint=joint, delay=delay)
86
+
87
+
88
+ def cosine_controller(model, data, **kwargs) -> None:
89
+ """A simple cosine wave controller for the simulation.
90
+
91
+ Args:
92
+ amplitude (float): The amplitude of the cosine wave (default=1).
93
+ frequency (float): The frequency of the cosine wave (default=1).
94
+ phase (float): The phase shift of the cosine wave (default=0).
95
+ joint (list[int]): The joint to apply the cosine wave to (default=all).
96
+ delay (float): The delay before applying the cosine wave (default=0).
97
+
98
+ Returns:
99
+ None
100
+
101
+ """
102
+ amplitude = kwargs.get("amplitude", 1)
103
+ frequency = kwargs.get("frequency", 1)
104
+ phase = kwargs.get("phase", 0)
105
+ joint = kwargs.get("joint")
106
+ delay = kwargs.get("delay", 0)
107
+
108
+ if delay < 0:
109
+ msg = "Delay must be non-negative."
110
+ raise ValueError(msg)
111
+
112
+ value = amplitude * cos(2 * pi * frequency * data.time + phase)
113
+ _apply_control(model, data, value, joint=joint, delay=delay)
114
+
115
+
116
+ def random_controller(model, data, **kwargs) -> None:
117
+ """A random controller for the simulation.
118
+
119
+ Args:
120
+ amplitude (float): The maximum amplitude of the random signal (default=1).
121
+ joint (list[int]): The joints to apply the random signal to (default=all).
122
+ axis (int): The axis to apply the random signal to (default=None).
123
+ delay (float): The delay before applying the random signal (default=0).
124
+
125
+ Returns:
126
+ None
127
+
128
+ """
129
+ amplitude = kwargs.get("amplitude", 1)
130
+ joint = kwargs.get("joint")
131
+ axis = kwargs.get("axis")
132
+ delay = kwargs.get("delay", 0)
133
+
134
+ if delay < 0:
135
+ msg = "Delay must be non-negative."
136
+ raise ValueError(msg)
137
+ if joint is not None and axis is not None:
138
+ msg = "Cannot specify both 'joint' and 'axis'."
139
+ raise ValueError(msg)
140
+
141
+ value = amplitude * random.rand()
142
+ _apply_control(model, data, value, joint=joint, axis=axis, delay=delay)
143
+
144
+ def live_controller(model, data, **kwargs) -> None:
145
+ """A real-time controller for the simulation.
146
+
147
+ Args:
148
+ controller_params (dict): Dictionary of parameters to pass to the controller.
149
+
150
+ Returns:
151
+ None
152
+
153
+ """
154
+ from .utils import _print_warning
155
+ for key, value in kwargs.get("controller_params", {}).items():
156
+ if hasattr(data, key):
157
+ setattr(data, key, value)
158
+ else:
159
+ _print_warning(f"'{key}' is not a valid attribute of MjData. Skipping...")
160
+
161
+ # untested
162
+ # if hasattr(data, 'control'):
163
+ # data.control = kwargs.get("control", data.control)
164
+
@@ -0,0 +1,134 @@
1
+ import platform
2
+ import sys
3
+ from functools import wraps
4
+ from multiprocessing import cpu_count
5
+ from time import time
6
+
7
+ from colorama import Fore, Style, init
8
+ from screeninfo import get_monitors
9
+
10
+
11
+ def timer(func):
12
+ @wraps(func)
13
+ def time_wrapper(*args, **kwargs):
14
+ start_time = time()
15
+ result = func(*args, **kwargs)
16
+ end_time = time()
17
+ total_time = end_time - start_time
18
+ print(f"Function {func.__name__} took {total_time:.4f} seconds to execute")
19
+ return result
20
+
21
+ return time_wrapper
22
+
23
+ def _print_warning(*args, prefix: bool = True) -> None:
24
+ """Prints the first argument as an orange warning message and prints the other arguments in regular text, all on the same line.
25
+
26
+ Args:
27
+ *args: First argument is printed as an orange warning, other arguments are printed normally.
28
+
29
+ """
30
+ if args:
31
+ if prefix:
32
+ # Make sure the first argument is printed with the 'WARNING:' prefix and in orange
33
+ message = f"WARNING: {args[0]}"
34
+ else:
35
+ message = args[0]
36
+
37
+ # Check if we're on Windows or a Unix-like system for color support
38
+ if sys.platform.startswith("win"):
39
+ try:
40
+ init() # Initialize colorama
41
+ print(f"{Fore.YELLOW}{message}{Style.RESET_ALL}", end=" ") # Yellow color for warning
42
+ print(*args[1:])
43
+ except ImportError:
44
+ # If colorama is not available, print without color
45
+ print(message, end=" ")
46
+ print(*args[1:])
47
+ else:
48
+ # ANSI escape code for orange (for Unix-like systems)
49
+ print(f"\033[38;5;214m{message}\033[0m", end=" ") # Orange ANSI escape code
50
+ print(*args[1:])
51
+
52
+ def _print_success(*args, prefix: bool = True) -> None:
53
+ """Prints the first argument as a green success message and prints the other arguments in regular text, all on the same line.
54
+
55
+ Args:
56
+ *args: First argument is printed as a green success message, other arguments are printed normally.
57
+
58
+ """
59
+ if args:
60
+ if prefix:
61
+ # Make sure the first argument is printed with the 'SUCCESS:' prefix and in green
62
+ message = f"SUCCESS: {args[0]}"
63
+ else:
64
+ message = args[0]
65
+
66
+ # Check if we're on Windows or a Unix-like system for color support
67
+ if sys.platform.startswith("win"):
68
+ try:
69
+ init() # Initialize colorama
70
+ print(f"{Fore.GREEN}{message}{Style.RESET_ALL}", end=" ") # Green color for success
71
+ print(*args[1:])
72
+ except ImportError:
73
+ # If colorama is not available, print without color
74
+ print(message, end=" ")
75
+ print(*args[1:])
76
+ else:
77
+ # ANSI escape code for green (for Unix-like systems)
78
+ print(f"\033[32m{message}\033[0m", end=" ") # Green ANSI escape code
79
+ print(*args[1:])
80
+
81
+ class _Platform:
82
+ def __init__(self) -> None:
83
+ self.OS = platform.system()
84
+ self.PROCESSOR = platform.processor()
85
+ self.MACHINE = platform.machine()
86
+ self.PYTHON_VERSION = platform.python_version()
87
+ self.IDE = self.get_developer_mode()
88
+ self.CPU_COUNT = cpu_count()
89
+ self.ARCHITECTURE = platform.architecture()[0]
90
+ self.PYTHON_IMPLEMENTATION = platform.python_implementation()
91
+ self.RESOLUTION, self.GUI_ENABLED = self.get_resolution()
92
+ # self.NUM_MONITORS = get_monitors().__len__()
93
+
94
+ def __repr__(self) -> str:
95
+ return (
96
+ f"Operating System: {self.OS}\n"
97
+ f"Architecture: {self.ARCHITECTURE}\n"
98
+ f"Processor: {self.PROCESSOR}\n"
99
+ f"Machine Type: {self.MACHINE}\n"
100
+ f"CPU Count: {self.CPU_COUNT}\n"
101
+ f"Python Version: {self.PYTHON_VERSION}\n"
102
+ f"IDE: {self.IDE}\n"
103
+ f"Python Implementation: {self.PYTHON_IMPLEMENTATION}\n"
104
+ f"Resolution (disp0): {self.RESOLUTION}\n"
105
+ f"GUI Enabled: {self.GUI_ENABLED}"
106
+ )
107
+
108
+ def __str__(self) -> str:
109
+ return self.__repr__()
110
+
111
+ @staticmethod
112
+ def get_developer_mode() -> str:
113
+ if "ipykernel" in sys.modules and not sys.stdin.isatty():
114
+ return "jupyter"
115
+ return "terminal" if hasattr(sys, "ps1") else "script"
116
+
117
+ @staticmethod
118
+ def get_resolution():
119
+ """Detects screen resolution and potential headless operation."""
120
+ try:
121
+ monitor = get_monitors()[0]
122
+ return (monitor.width, monitor.height), True
123
+ except Exception:
124
+ _print_warning("Detected headless operation. Disabling GUI...")
125
+ return (1920, 1080), False
126
+
127
+ class _EmptyContextManager:
128
+ __slots__ = ()
129
+ def __init__(self, *args, **kwargs) -> None:
130
+ pass
131
+ def __enter__(self):
132
+ pass
133
+ def __exit__(self, _exc_type, _exc_value, _traceback):
134
+ pass