rocket-welder-sdk 1.1.0__py3-none-any.whl → 1.1.26__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,105 @@
1
+ """External Controls event contracts for RocketWelder SDK (legacy - for backward compatibility)."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from uuid import UUID, uuid4
6
+
7
+ from rocket_welder_sdk.ui.value_types import ControlType
8
+
9
+
10
+ class ArrowDirection(Enum):
11
+ """Arrow directions for ArrowGrid control."""
12
+
13
+ UP = "Up"
14
+ DOWN = "Down"
15
+ LEFT = "Left"
16
+ RIGHT = "Right"
17
+
18
+
19
+ # Container → UI Commands (Stream: ExternalCommands-{SessionId})
20
+
21
+
22
+ @dataclass
23
+ class DefineControl:
24
+ """Command to define a new control in the UI."""
25
+
26
+ control_id: str
27
+ type: ControlType
28
+ properties: dict[str, str]
29
+ region_name: str
30
+ id: UUID = field(default_factory=uuid4)
31
+
32
+ def to_dict(self) -> dict[str, object]:
33
+ """Convert to dictionary for EventStore."""
34
+ return {
35
+ "Id": str(self.id),
36
+ "ControlId": self.control_id,
37
+ "Type": self.type.value,
38
+ "Properties": self.properties,
39
+ "RegionName": self.region_name,
40
+ }
41
+
42
+
43
+ @dataclass
44
+ class DeleteControl:
45
+ """Command to delete a control from the UI."""
46
+
47
+ control_id: str
48
+ id: UUID = field(default_factory=uuid4)
49
+
50
+ def to_dict(self) -> dict[str, str]:
51
+ """Convert to dictionary for EventStore."""
52
+ return {"Id": str(self.id), "ControlId": self.control_id}
53
+
54
+
55
+ @dataclass
56
+ class ChangeControls:
57
+ """Command to update properties of multiple controls."""
58
+
59
+ updates: dict[str, dict[str, str]] # ControlId -> { PropertyId -> Value }
60
+ id: UUID = field(default_factory=uuid4)
61
+
62
+ def to_dict(self) -> dict[str, object]:
63
+ """Convert to dictionary for EventStore."""
64
+ return {"Id": str(self.id), "Updates": self.updates}
65
+
66
+
67
+ # UI → Container Events (Stream: ExternalEvents-{SessionId})
68
+
69
+
70
+ @dataclass
71
+ class ButtonDown:
72
+ """Event when button is pressed."""
73
+
74
+ control_id: str
75
+ id: UUID = field(default_factory=uuid4)
76
+
77
+ def to_dict(self) -> dict[str, str]:
78
+ """Convert to dictionary for EventStore."""
79
+ return {"Id": str(self.id), "ControlId": self.control_id}
80
+
81
+ @classmethod
82
+ def from_dict(cls, data: dict[str, object]) -> "ButtonDown":
83
+ """Create from EventStore data."""
84
+ return cls(
85
+ control_id=str(data["ControlId"]), id=UUID(str(data["Id"])) if "Id" in data else uuid4()
86
+ )
87
+
88
+
89
+ @dataclass
90
+ class ButtonUp:
91
+ """Event when button is released."""
92
+
93
+ control_id: str
94
+ id: UUID = field(default_factory=uuid4)
95
+
96
+ def to_dict(self) -> dict[str, str]:
97
+ """Convert to dictionary for EventStore."""
98
+ return {"Id": str(self.id), "ControlId": self.control_id}
99
+
100
+ @classmethod
101
+ def from_dict(cls, data: dict[str, object]) -> "ButtonUp":
102
+ """Create from EventStore data."""
103
+ return cls(
104
+ control_id=str(data["ControlId"]), id=UUID(str(data["Id"])) if "Id" in data else uuid4()
105
+ )
@@ -6,9 +6,13 @@ Matches C# GstCaps and GstMetadata functionality.
6
6
  from __future__ import annotations
7
7
 
8
8
  import json
9
- from dataclasses import dataclass, field
9
+ import re
10
+ from dataclasses import dataclass
10
11
  from typing import Any
11
12
 
13
+ import numpy as np
14
+ import numpy.typing as npt
15
+
12
16
 
13
17
  @dataclass
14
18
  class GstCaps:
@@ -16,172 +20,312 @@ class GstCaps:
16
20
  GStreamer capabilities representation.
17
21
 
18
22
  Represents video format capabilities including format, dimensions, framerate, etc.
23
+ Matches the C# GstCaps implementation with proper parsing and numpy integration.
19
24
  """
20
25
 
21
- format: str | None = None
22
- width: int | None = None
23
- height: int | None = None
24
- framerate: str | None = None # e.g., "30/1" or "25/1"
25
- pixel_aspect_ratio: str | None = None # e.g., "1/1"
26
- interlace_mode: str | None = None # e.g., "progressive"
26
+ width: int
27
+ height: int
28
+ format: str
29
+ depth_type: type[np.uint8] | type[np.uint16]
30
+ channels: int
31
+ bytes_per_pixel: int
32
+ framerate_num: int | None = None
33
+ framerate_den: int | None = None
34
+ interlace_mode: str | None = None
27
35
  colorimetry: str | None = None
28
- chroma_site: str | None = None
36
+ caps_string: str | None = None
37
+
38
+ @property
39
+ def frame_size(self) -> int:
40
+ """Calculate the expected frame size in bytes."""
41
+ return self.width * self.height * self.bytes_per_pixel
29
42
 
30
- # Additional fields can be stored here
31
- extra_fields: dict[str, Any] = field(default_factory=dict)
43
+ @property
44
+ def framerate(self) -> float | None:
45
+ """Get framerate as double (FPS)."""
46
+ if (
47
+ self.framerate_num is not None
48
+ and self.framerate_den is not None
49
+ and self.framerate_den > 0
50
+ ):
51
+ return self.framerate_num / self.framerate_den
52
+ return None
32
53
 
33
54
  @classmethod
34
- def from_dict(cls, data: dict[str, Any]) -> GstCaps:
55
+ def parse(cls, caps_string: str) -> GstCaps:
35
56
  """
36
- Create GstCaps from dictionary.
57
+ Parse GStreamer caps string.
58
+ Example: "video/x-raw, format=(string)RGB, width=(int)640, height=(int)480, framerate=(fraction)30/1"
37
59
 
38
60
  Args:
39
- data: Dictionary containing caps data
61
+ caps_string: GStreamer caps string
40
62
 
41
63
  Returns:
42
64
  GstCaps instance
65
+
66
+ Raises:
67
+ ValueError: If caps string is invalid
43
68
  """
44
- # Extract known fields
45
- caps = cls(
46
- format=data.get("format"),
47
- width=data.get("width"),
48
- height=data.get("height"),
49
- framerate=data.get("framerate"),
50
- pixel_aspect_ratio=data.get("pixel-aspect-ratio"),
51
- interlace_mode=data.get("interlace-mode"),
52
- colorimetry=data.get("colorimetry"),
53
- chroma_site=data.get("chroma-site"),
54
- )
69
+ if not caps_string or not caps_string.strip():
70
+ raise ValueError("Empty caps string")
55
71
 
56
- # Store any extra fields
57
- known_fields = {
58
- "format",
59
- "width",
60
- "height",
61
- "framerate",
62
- "pixel-aspect-ratio",
63
- "interlace-mode",
64
- "colorimetry",
65
- "chroma-site",
66
- }
72
+ caps_string = caps_string.strip()
67
73
 
68
- for key, value in data.items():
69
- if key not in known_fields:
70
- caps.extra_fields[key] = value
74
+ # Check if it's a video caps
75
+ if not caps_string.startswith("video/x-raw"):
76
+ raise ValueError(f"Not a video/x-raw caps string: {caps_string}")
71
77
 
72
- return caps
78
+ try:
79
+ # Parse width
80
+ width_match = re.search(r"width=\(int\)(\d+)", caps_string)
81
+ if not width_match:
82
+ raise ValueError("Missing width in caps string")
83
+ width = int(width_match.group(1))
84
+
85
+ # Parse height
86
+ height_match = re.search(r"height=\(int\)(\d+)", caps_string)
87
+ if not height_match:
88
+ raise ValueError("Missing height in caps string")
89
+ height = int(height_match.group(1))
90
+
91
+ # Parse format
92
+ format_match = re.search(r"format=\(string\)(\w+)", caps_string)
93
+ format_str = format_match.group(1) if format_match else "RGB"
94
+
95
+ # Parse framerate (optional)
96
+ framerate_num = None
97
+ framerate_den = None
98
+ framerate_match = re.search(r"framerate=\(fraction\)(\d+)/(\d+)", caps_string)
99
+ if framerate_match:
100
+ framerate_num = int(framerate_match.group(1))
101
+ framerate_den = int(framerate_match.group(2))
102
+
103
+ # Parse interlace mode (optional)
104
+ interlace_mode = None
105
+ interlace_match = re.search(r"interlace-mode=\(string\)(\w+)", caps_string)
106
+ if interlace_match:
107
+ interlace_mode = interlace_match.group(1)
108
+
109
+ # Parse colorimetry (optional)
110
+ colorimetry = None
111
+ colorimetry_match = re.search(r"colorimetry=\(string\)([\w:]+)", caps_string)
112
+ if colorimetry_match:
113
+ colorimetry = colorimetry_match.group(1)
114
+
115
+ # Map format to numpy dtype and get channel info
116
+ depth_type, channels, bytes_per_pixel = cls._map_gstreamer_format_to_numpy(format_str)
117
+
118
+ return cls(
119
+ width=width,
120
+ height=height,
121
+ format=format_str,
122
+ depth_type=depth_type,
123
+ channels=channels,
124
+ bytes_per_pixel=bytes_per_pixel,
125
+ framerate_num=framerate_num,
126
+ framerate_den=framerate_den,
127
+ interlace_mode=interlace_mode,
128
+ colorimetry=colorimetry,
129
+ caps_string=caps_string,
130
+ )
131
+ except Exception as e:
132
+ raise ValueError(f"Failed to parse caps string: {caps_string}") from e
73
133
 
74
134
  @classmethod
75
- def from_string(cls, caps_string: str) -> GstCaps:
135
+ def from_simple(cls, width: int, height: int, format: str = "RGB") -> GstCaps:
76
136
  """
77
- Parse GStreamer caps string.
137
+ Create GstCaps from simple parameters.
78
138
 
79
139
  Args:
80
- caps_string: GStreamer caps string (e.g., "video/x-raw,format=RGB,width=640,height=480")
140
+ width: Frame width
141
+ height: Frame height
142
+ format: Pixel format (default: "RGB")
81
143
 
82
144
  Returns:
83
145
  GstCaps instance
84
146
  """
85
- if not caps_string:
86
- return cls()
147
+ depth_type, channels, bytes_per_pixel = cls._map_gstreamer_format_to_numpy(format)
148
+ return cls(
149
+ width=width,
150
+ height=height,
151
+ format=format,
152
+ depth_type=depth_type,
153
+ channels=channels,
154
+ bytes_per_pixel=bytes_per_pixel,
155
+ )
87
156
 
88
- # Remove media type prefix if present
89
- if "/" in caps_string and "," in caps_string:
90
- # Has media type prefix (e.g., "video/x-raw,format=RGB")
91
- _, params = caps_string.split(",", 1)
92
- else:
93
- # No media type prefix (e.g., "format=RGB,width=320")
94
- params = caps_string
95
-
96
- # Parse parameters
97
- data: dict[str, Any] = {}
98
- for param in params.split(","):
99
- if "=" in param:
100
- key, value = param.split("=", 1)
101
- key = key.strip()
102
- value_str = value.strip()
103
-
104
- # Try to parse numeric values
105
- if value_str.isdigit():
106
- data[key] = int(value_str)
107
- elif key in ["width", "height"] and value_str.startswith("(int)"):
108
- data[key] = int(value_str[5:].strip())
109
- else:
110
- data[key] = value_str
111
-
112
- return cls.from_dict(data)
157
+ @staticmethod
158
+ def _map_gstreamer_format_to_numpy(
159
+ format: str,
160
+ ) -> tuple[type[np.uint8] | type[np.uint16], int, int]:
161
+ """
162
+ Map GStreamer format strings to numpy dtype.
163
+ Reference: https://gstreamer.freedesktop.org/documentation/video/video-format.html
113
164
 
114
- def to_dict(self) -> dict[str, Any]:
115
- """Convert to dictionary representation."""
116
- result: dict[str, Any] = {}
117
-
118
- if self.format is not None:
119
- result["format"] = self.format
120
- if self.width is not None:
121
- result["width"] = self.width
122
- if self.height is not None:
123
- result["height"] = self.height
124
- if self.framerate is not None:
125
- result["framerate"] = self.framerate
126
- if self.pixel_aspect_ratio is not None:
127
- result["pixel-aspect-ratio"] = self.pixel_aspect_ratio
128
- if self.interlace_mode is not None:
129
- result["interlace-mode"] = self.interlace_mode
130
- if self.colorimetry is not None:
131
- result["colorimetry"] = self.colorimetry
132
- if self.chroma_site is not None:
133
- result["chroma-site"] = self.chroma_site
165
+ Args:
166
+ format: GStreamer format string
134
167
 
135
- # Add extra fields
136
- result.update(self.extra_fields)
168
+ Returns:
169
+ Tuple of (numpy dtype, channels, bytes_per_pixel)
170
+ """
171
+ format_upper = format.upper() if format else "RGB"
172
+
173
+ format_map = {
174
+ # RGB formats
175
+ "RGB": (np.uint8, 3, 3),
176
+ "BGR": (np.uint8, 3, 3),
177
+ "RGBA": (np.uint8, 4, 4),
178
+ "BGRA": (np.uint8, 4, 4),
179
+ "ARGB": (np.uint8, 4, 4),
180
+ "ABGR": (np.uint8, 4, 4),
181
+ "RGBX": (np.uint8, 4, 4), # RGB with padding
182
+ "BGRX": (np.uint8, 4, 4), # BGR with padding
183
+ "XRGB": (np.uint8, 4, 4), # RGB with padding
184
+ "XBGR": (np.uint8, 4, 4), # BGR with padding
185
+ # 16-bit RGB formats
186
+ "RGB16": (np.uint16, 3, 6),
187
+ "BGR16": (np.uint16, 3, 6),
188
+ # Grayscale formats
189
+ "GRAY8": (np.uint8, 1, 1),
190
+ "GRAY16_LE": (np.uint16, 1, 2),
191
+ "GRAY16_BE": (np.uint16, 1, 2),
192
+ # YUV planar formats (Y plane only for simplicity)
193
+ "I420": (np.uint8, 1, 1),
194
+ "YV12": (np.uint8, 1, 1),
195
+ "NV12": (np.uint8, 1, 1),
196
+ "NV21": (np.uint8, 1, 1),
197
+ # YUV packed formats
198
+ "YUY2": (np.uint8, 2, 2),
199
+ "UYVY": (np.uint8, 2, 2),
200
+ "YVYU": (np.uint8, 2, 2),
201
+ # Bayer formats (raw sensor data)
202
+ "BGGR": (np.uint8, 1, 1),
203
+ "RGGB": (np.uint8, 1, 1),
204
+ "GRBG": (np.uint8, 1, 1),
205
+ "GBRG": (np.uint8, 1, 1),
206
+ }
137
207
 
138
- return result
208
+ # Default to RGB if unknown
209
+ return format_map.get(format_upper, (np.uint8, 3, 3))
139
210
 
140
- def to_string(self) -> str:
141
- """Convert to GStreamer caps string format."""
142
- params = []
143
-
144
- if self.format:
145
- params.append(f"format={self.format}")
146
- if self.width is not None:
147
- params.append(f"width={self.width}")
148
- if self.height is not None:
149
- params.append(f"height={self.height}")
150
- if self.framerate:
151
- params.append(f"framerate={self.framerate}")
152
- if self.pixel_aspect_ratio:
153
- params.append(f"pixel-aspect-ratio={self.pixel_aspect_ratio}")
154
- if self.interlace_mode:
155
- params.append(f"interlace-mode={self.interlace_mode}")
211
+ def create_array(
212
+ self, data: bytes | memoryview | npt.NDArray[np.uint8] | npt.NDArray[np.uint16]
213
+ ) -> npt.NDArray[np.uint8] | npt.NDArray[np.uint16]:
214
+ """
215
+ Create numpy array with proper format from data.
216
+
217
+ Args:
218
+ data: Frame data as bytes, memoryview, or existing numpy array
219
+
220
+ Returns:
221
+ Numpy array with proper shape and dtype
222
+
223
+ Raises:
224
+ ValueError: If data size doesn't match expected frame size
225
+ """
226
+ # Convert memoryview to bytes if needed
227
+ if isinstance(data, memoryview):
228
+ data = bytes(data)
229
+
230
+ # If it's already a numpy array, check size and reshape if needed
231
+ if isinstance(data, np.ndarray):
232
+ if data.size * data.itemsize != self.frame_size:
233
+ raise ValueError(
234
+ f"Data size mismatch. Expected {self.frame_size} bytes for "
235
+ f"{self.width}x{self.height} {self.format}, got {data.size * data.itemsize}"
236
+ )
237
+ # Reshape if needed
238
+ if self.channels == 1:
239
+ return data.reshape((self.height, self.width))
240
+ else:
241
+ return data.reshape((self.height, self.width, self.channels))
242
+
243
+ # Check data size
244
+ if len(data) != self.frame_size:
245
+ raise ValueError(
246
+ f"Data size mismatch. Expected {self.frame_size} bytes for "
247
+ f"{self.width}x{self.height} {self.format}, got {len(data)}"
248
+ )
249
+
250
+ # Create array from bytes
251
+ arr = np.frombuffer(data, dtype=self.depth_type)
252
+
253
+ # Reshape based on channels
254
+ if self.channels == 1:
255
+ return arr.reshape((self.height, self.width))
256
+ else:
257
+ # For multi-channel images, reshape to (height, width, channels)
258
+ total_pixels = self.width * self.height * self.channels
259
+ if self.depth_type == np.uint16:
260
+ # For 16-bit formats, we need to account for the item size
261
+ arr = arr[:total_pixels]
262
+ return arr.reshape((self.height, self.width, self.channels))
263
+
264
+ def create_array_from_pointer(
265
+ self, ptr: int, copy: bool = False
266
+ ) -> npt.NDArray[np.uint8] | npt.NDArray[np.uint16]:
267
+ """
268
+ Create numpy array from memory pointer (zero-copy by default).
156
269
 
157
- # Add extra fields
158
- for key, value in self.extra_fields.items():
159
- params.append(f"{key}={value}")
270
+ Args:
271
+ ptr: Memory pointer as integer
272
+ copy: If True, make a copy of the data; if False, create a view
160
273
 
161
- if params:
162
- return "video/x-raw," + ",".join(params)
274
+ Returns:
275
+ Numpy array with proper shape and dtype
276
+ """
277
+ # Calculate total elements based on depth type
278
+ if self.depth_type == np.uint16:
279
+ total_elements = self.width * self.height * self.channels
163
280
  else:
164
- return "video/x-raw"
281
+ total_elements = self.frame_size
165
282
 
166
- @property
167
- def framerate_tuple(self) -> tuple[int, int] | None:
168
- """Get framerate as a tuple of (numerator, denominator)."""
169
- if not self.framerate or "/" not in self.framerate:
170
- return None
283
+ # Create array from pointer using ctypes
284
+ import ctypes
171
285
 
172
- try:
173
- num, denom = self.framerate.split("/")
174
- return (int(num), int(denom))
175
- except (ValueError, AttributeError):
176
- return None
286
+ # Create a buffer from the pointer
287
+ buffer_size = total_elements * self.depth_type.itemsize
288
+ c_buffer = (ctypes.c_byte * buffer_size).from_address(ptr)
289
+ arr = np.frombuffer(c_buffer, dtype=self.depth_type)
177
290
 
178
- @property
179
- def fps(self) -> float | None:
180
- """Get framerate as floating-point FPS value."""
181
- fr = self.framerate_tuple
182
- if fr and fr[1] != 0:
183
- return fr[0] / fr[1]
184
- return None
291
+ # Reshape based on channels
292
+ if self.channels == 1:
293
+ shaped = arr.reshape((self.height, self.width))
294
+ else:
295
+ shaped = arr.reshape((self.height, self.width, self.channels))
296
+
297
+ return shaped.copy() if copy else shaped
298
+
299
+ def __str__(self) -> str:
300
+ """String representation."""
301
+ # If we have the original caps string, return it for perfect round-tripping
302
+ if self.caps_string:
303
+ return self.caps_string
304
+
305
+ # Otherwise build a simple display string
306
+ fps = f" @ {self.framerate:.2f}fps" if self.framerate else ""
307
+ return f"{self.width}x{self.height} {self.format}{fps}"
308
+
309
+ def to_dict(self) -> dict[str, Any]:
310
+ """Convert to dictionary representation."""
311
+ result = {
312
+ "width": self.width,
313
+ "height": self.height,
314
+ "format": self.format,
315
+ "channels": self.channels,
316
+ "bytes_per_pixel": self.bytes_per_pixel,
317
+ }
318
+
319
+ if self.framerate_num is not None:
320
+ result["framerate_num"] = self.framerate_num
321
+ if self.framerate_den is not None:
322
+ result["framerate_den"] = self.framerate_den
323
+ if self.interlace_mode:
324
+ result["interlace_mode"] = self.interlace_mode
325
+ if self.colorimetry:
326
+ result["colorimetry"] = self.colorimetry
327
+
328
+ return result
185
329
 
186
330
 
187
331
  @dataclass
@@ -190,6 +334,7 @@ class GstMetadata:
190
334
  GStreamer metadata structure.
191
335
 
192
336
  Matches the JSON structure written by GStreamer plugins.
337
+ Compatible with C# GstMetadata record.
193
338
  """
194
339
 
195
340
  type: str
@@ -207,24 +352,46 @@ class GstMetadata:
207
352
 
208
353
  Returns:
209
354
  GstMetadata instance
355
+
356
+ Raises:
357
+ ValueError: If JSON is invalid or missing required fields
210
358
  """
211
- data = json.loads(json_data) if isinstance(json_data, (str, bytes)) else json_data
359
+ # Parse JSON if needed
360
+ if isinstance(json_data, (str, bytes)):
361
+ if isinstance(json_data, bytes):
362
+ json_data = json_data.decode("utf-8")
363
+ try:
364
+ data = json.loads(json_data)
365
+ except json.JSONDecodeError as e:
366
+ raise ValueError(f"Invalid JSON: {e}") from e
367
+ else:
368
+ data = json_data
369
+
370
+ # Validate required fields
371
+ if not isinstance(data, dict):
372
+ raise ValueError("JSON must be an object/dictionary")
373
+
374
+ # Get required fields
375
+ type_str = data.get("type", "")
376
+ version = data.get("version", "")
377
+ element_name = data.get("element_name", "")
212
378
 
213
- # Parse caps
214
- caps_data = data.get("caps", {})
379
+ # Parse caps - it's a STRING in the JSON!
380
+ caps_data = data.get("caps")
215
381
  if isinstance(caps_data, str):
216
- caps = GstCaps.from_string(caps_data)
382
+ # This is the normal case - caps is a string that needs parsing
383
+ caps = GstCaps.parse(caps_data)
217
384
  elif isinstance(caps_data, dict):
218
- caps = GstCaps.from_dict(caps_data)
385
+ # Fallback for dict format (shouldn't happen with real GStreamer)
386
+ # Create a simple caps from dict
387
+ width = caps_data.get("width", 640)
388
+ height = caps_data.get("height", 480)
389
+ format_str = caps_data.get("format", "RGB")
390
+ caps = GstCaps.from_simple(width, height, format_str)
219
391
  else:
220
- caps = GstCaps()
392
+ raise ValueError(f"Invalid caps data type: {type(caps_data)}")
221
393
 
222
- return cls(
223
- type=data.get("type", ""),
224
- version=data.get("version", ""),
225
- caps=caps,
226
- element_name=data.get("element_name", ""),
227
- )
394
+ return cls(type=type_str, version=version, caps=caps, element_name=element_name)
228
395
 
229
396
  def to_json(self) -> str:
230
397
  """Convert to JSON string."""
@@ -235,6 +402,10 @@ class GstMetadata:
235
402
  return {
236
403
  "type": self.type,
237
404
  "version": self.version,
238
- "caps": self.caps.to_dict(),
405
+ "caps": str(self.caps), # Caps as string for C# compatibility
239
406
  "element_name": self.element_name,
240
407
  }
408
+
409
+ def __str__(self) -> str:
410
+ """String representation."""
411
+ return f"GstMetadata(type={self.type}, element={self.element_name}, caps={self.caps})"