kicad-sch-api 0.3.0__py3-none-any.whl → 0.5.1__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 (112) hide show
  1. kicad_sch_api/__init__.py +68 -3
  2. kicad_sch_api/cli/__init__.py +45 -0
  3. kicad_sch_api/cli/base.py +302 -0
  4. kicad_sch_api/cli/bom.py +164 -0
  5. kicad_sch_api/cli/erc.py +229 -0
  6. kicad_sch_api/cli/export_docs.py +289 -0
  7. kicad_sch_api/cli/kicad_to_python.py +169 -0
  8. kicad_sch_api/cli/netlist.py +94 -0
  9. kicad_sch_api/cli/types.py +43 -0
  10. kicad_sch_api/collections/__init__.py +36 -0
  11. kicad_sch_api/collections/base.py +604 -0
  12. kicad_sch_api/collections/components.py +1623 -0
  13. kicad_sch_api/collections/junctions.py +206 -0
  14. kicad_sch_api/collections/labels.py +508 -0
  15. kicad_sch_api/collections/wires.py +292 -0
  16. kicad_sch_api/core/__init__.py +37 -2
  17. kicad_sch_api/core/collections/__init__.py +5 -0
  18. kicad_sch_api/core/collections/base.py +248 -0
  19. kicad_sch_api/core/component_bounds.py +34 -7
  20. kicad_sch_api/core/components.py +213 -52
  21. kicad_sch_api/core/config.py +110 -15
  22. kicad_sch_api/core/connectivity.py +692 -0
  23. kicad_sch_api/core/exceptions.py +175 -0
  24. kicad_sch_api/core/factories/__init__.py +5 -0
  25. kicad_sch_api/core/factories/element_factory.py +278 -0
  26. kicad_sch_api/core/formatter.py +60 -9
  27. kicad_sch_api/core/geometry.py +94 -5
  28. kicad_sch_api/core/junctions.py +26 -75
  29. kicad_sch_api/core/labels.py +324 -0
  30. kicad_sch_api/core/managers/__init__.py +30 -0
  31. kicad_sch_api/core/managers/base.py +76 -0
  32. kicad_sch_api/core/managers/file_io.py +246 -0
  33. kicad_sch_api/core/managers/format_sync.py +502 -0
  34. kicad_sch_api/core/managers/graphics.py +580 -0
  35. kicad_sch_api/core/managers/hierarchy.py +661 -0
  36. kicad_sch_api/core/managers/metadata.py +271 -0
  37. kicad_sch_api/core/managers/sheet.py +492 -0
  38. kicad_sch_api/core/managers/text_elements.py +537 -0
  39. kicad_sch_api/core/managers/validation.py +476 -0
  40. kicad_sch_api/core/managers/wire.py +410 -0
  41. kicad_sch_api/core/nets.py +305 -0
  42. kicad_sch_api/core/no_connects.py +252 -0
  43. kicad_sch_api/core/parser.py +194 -970
  44. kicad_sch_api/core/parsing_utils.py +63 -0
  45. kicad_sch_api/core/pin_utils.py +103 -9
  46. kicad_sch_api/core/schematic.py +1328 -1079
  47. kicad_sch_api/core/texts.py +316 -0
  48. kicad_sch_api/core/types.py +159 -23
  49. kicad_sch_api/core/wires.py +27 -75
  50. kicad_sch_api/exporters/__init__.py +10 -0
  51. kicad_sch_api/exporters/python_generator.py +610 -0
  52. kicad_sch_api/exporters/templates/default.py.jinja2 +65 -0
  53. kicad_sch_api/geometry/__init__.py +38 -0
  54. kicad_sch_api/geometry/font_metrics.py +22 -0
  55. kicad_sch_api/geometry/routing.py +211 -0
  56. kicad_sch_api/geometry/symbol_bbox.py +608 -0
  57. kicad_sch_api/interfaces/__init__.py +17 -0
  58. kicad_sch_api/interfaces/parser.py +76 -0
  59. kicad_sch_api/interfaces/repository.py +70 -0
  60. kicad_sch_api/interfaces/resolver.py +117 -0
  61. kicad_sch_api/parsers/__init__.py +14 -0
  62. kicad_sch_api/parsers/base.py +145 -0
  63. kicad_sch_api/parsers/elements/__init__.py +22 -0
  64. kicad_sch_api/parsers/elements/graphics_parser.py +564 -0
  65. kicad_sch_api/parsers/elements/label_parser.py +216 -0
  66. kicad_sch_api/parsers/elements/library_parser.py +165 -0
  67. kicad_sch_api/parsers/elements/metadata_parser.py +58 -0
  68. kicad_sch_api/parsers/elements/sheet_parser.py +352 -0
  69. kicad_sch_api/parsers/elements/symbol_parser.py +485 -0
  70. kicad_sch_api/parsers/elements/text_parser.py +250 -0
  71. kicad_sch_api/parsers/elements/wire_parser.py +242 -0
  72. kicad_sch_api/parsers/registry.py +155 -0
  73. kicad_sch_api/parsers/utils.py +80 -0
  74. kicad_sch_api/symbols/__init__.py +18 -0
  75. kicad_sch_api/symbols/cache.py +467 -0
  76. kicad_sch_api/symbols/resolver.py +361 -0
  77. kicad_sch_api/symbols/validators.py +504 -0
  78. kicad_sch_api/utils/logging.py +555 -0
  79. kicad_sch_api/utils/logging_decorators.py +587 -0
  80. kicad_sch_api/utils/validation.py +16 -22
  81. kicad_sch_api/validation/__init__.py +25 -0
  82. kicad_sch_api/validation/erc.py +171 -0
  83. kicad_sch_api/validation/erc_models.py +203 -0
  84. kicad_sch_api/validation/pin_matrix.py +243 -0
  85. kicad_sch_api/validation/validators.py +391 -0
  86. kicad_sch_api/wrappers/__init__.py +14 -0
  87. kicad_sch_api/wrappers/base.py +89 -0
  88. kicad_sch_api/wrappers/wire.py +198 -0
  89. kicad_sch_api-0.5.1.dist-info/METADATA +540 -0
  90. kicad_sch_api-0.5.1.dist-info/RECORD +114 -0
  91. kicad_sch_api-0.5.1.dist-info/entry_points.txt +4 -0
  92. {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/top_level.txt +1 -0
  93. mcp_server/__init__.py +34 -0
  94. mcp_server/example_logging_integration.py +506 -0
  95. mcp_server/models.py +252 -0
  96. mcp_server/server.py +357 -0
  97. mcp_server/tools/__init__.py +32 -0
  98. mcp_server/tools/component_tools.py +516 -0
  99. mcp_server/tools/connectivity_tools.py +532 -0
  100. mcp_server/tools/consolidated_tools.py +1216 -0
  101. mcp_server/tools/pin_discovery.py +333 -0
  102. mcp_server/utils/__init__.py +38 -0
  103. mcp_server/utils/logging.py +127 -0
  104. mcp_server/utils.py +36 -0
  105. kicad_sch_api/core/manhattan_routing.py +0 -430
  106. kicad_sch_api/core/simple_manhattan.py +0 -228
  107. kicad_sch_api/core/wire_routing.py +0 -380
  108. kicad_sch_api-0.3.0.dist-info/METADATA +0 -483
  109. kicad_sch_api-0.3.0.dist-info/RECORD +0 -31
  110. kicad_sch_api-0.3.0.dist-info/entry_points.txt +0 -2
  111. {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/WHEEL +0 -0
  112. {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,206 @@
1
+ """
2
+ Enhanced junction management with IndexRegistry integration.
3
+
4
+ Provides JunctionCollection using BaseCollection infrastructure with
5
+ position-based queries and validation.
6
+ """
7
+
8
+ import logging
9
+ import uuid as uuid_module
10
+ from typing import Any, Dict, List, Optional, Tuple, Union
11
+
12
+ from ..core.types import Junction, Point
13
+ from .base import BaseCollection, IndexSpec, ValidationLevel
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class JunctionCollection(BaseCollection[Junction]):
19
+ """
20
+ Junction collection with position-based queries.
21
+
22
+ Inherits from BaseCollection for UUID indexing and adds junction-specific
23
+ position-based search capabilities.
24
+
25
+ Features:
26
+ - Fast UUID lookup via IndexRegistry
27
+ - Position-based junction queries
28
+ - Lazy index rebuilding
29
+ - Batch mode support
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ junctions: Optional[List[Junction]] = None,
35
+ validation_level: ValidationLevel = ValidationLevel.NORMAL,
36
+ ):
37
+ """
38
+ Initialize junction collection.
39
+
40
+ Args:
41
+ junctions: Initial list of junctions
42
+ validation_level: Validation level for operations
43
+ """
44
+ super().__init__(validation_level=validation_level)
45
+
46
+ # Add initial junctions
47
+ if junctions:
48
+ with self.batch_mode():
49
+ for junction in junctions:
50
+ super().add(junction)
51
+
52
+ logger.debug(f"JunctionCollection initialized with {len(self)} junctions")
53
+
54
+ # BaseCollection abstract method implementations
55
+ def _get_item_uuid(self, item: Junction) -> str:
56
+ """Extract UUID from junction."""
57
+ return item.uuid
58
+
59
+ def _create_item(self, **kwargs) -> Junction:
60
+ """Create a new junction (not typically used directly)."""
61
+ raise NotImplementedError("Use add() method to create junctions")
62
+
63
+ def _get_index_specs(self) -> List[IndexSpec]:
64
+ """Get index specifications for junction collection."""
65
+ return [
66
+ IndexSpec(
67
+ name="uuid",
68
+ key_func=lambda j: j.uuid,
69
+ unique=True,
70
+ description="UUID index for fast lookups",
71
+ ),
72
+ ]
73
+
74
+ # Junction-specific add method
75
+ def add(
76
+ self,
77
+ position: Union[Point, Tuple[float, float]],
78
+ diameter: float = 0,
79
+ color: Tuple[int, int, int, int] = (0, 0, 0, 0),
80
+ uuid: Optional[str] = None,
81
+ ) -> str:
82
+ """
83
+ Add a junction to the collection.
84
+
85
+ Args:
86
+ position: Junction position
87
+ diameter: Junction diameter (0 is KiCAD default)
88
+ color: RGBA color tuple (0,0,0,0 is default)
89
+ uuid: Optional UUID (auto-generated if not provided)
90
+
91
+ Returns:
92
+ UUID of the created junction
93
+
94
+ Raises:
95
+ ValueError: If UUID already exists
96
+ """
97
+ # Generate UUID if not provided
98
+ if uuid is None:
99
+ uuid = str(uuid_module.uuid4())
100
+ else:
101
+ # Check for duplicate
102
+ self._ensure_indexes_current()
103
+ if self._index_registry.has_key("uuid", uuid):
104
+ raise ValueError(f"Junction with UUID '{uuid}' already exists")
105
+
106
+ # Convert position
107
+ if isinstance(position, tuple):
108
+ position = Point(position[0], position[1])
109
+
110
+ # Create junction
111
+ junction = Junction(uuid=uuid, position=position, diameter=diameter, color=color)
112
+
113
+ # Add to collection
114
+ super().add(junction)
115
+
116
+ logger.debug(f"Added junction at {position}, UUID={uuid}")
117
+ return uuid
118
+
119
+ # Position-based queries
120
+ def get_at_position(
121
+ self, position: Union[Point, Tuple[float, float]], tolerance: float = 0.01
122
+ ) -> Optional[Junction]:
123
+ """
124
+ Find junction at or near a specific position.
125
+
126
+ Args:
127
+ position: Position to search
128
+ tolerance: Distance tolerance for matching
129
+
130
+ Returns:
131
+ Junction if found, None otherwise
132
+ """
133
+ if isinstance(position, tuple):
134
+ position = Point(position[0], position[1])
135
+
136
+ for junction in self._items:
137
+ if junction.position.distance_to(position) <= tolerance:
138
+ return junction
139
+
140
+ return None
141
+
142
+ def get_by_point(
143
+ self, point: Union[Point, Tuple[float, float]], tolerance: float = 0.01
144
+ ) -> List[Junction]:
145
+ """
146
+ Find all junctions near a point.
147
+
148
+ Args:
149
+ point: Point to search near
150
+ tolerance: Distance tolerance
151
+
152
+ Returns:
153
+ List of junctions near the point
154
+ """
155
+ if isinstance(point, tuple):
156
+ point = Point(point[0], point[1])
157
+
158
+ matching_junctions = []
159
+ for junction in self._items:
160
+ if junction.position.distance_to(point) <= tolerance:
161
+ matching_junctions.append(junction)
162
+
163
+ return matching_junctions
164
+
165
+ # Statistics
166
+ def get_statistics(self) -> Dict[str, Any]:
167
+ """
168
+ Get junction collection statistics.
169
+
170
+ Returns:
171
+ Dictionary with junction statistics
172
+ """
173
+ if not self._items:
174
+ base_stats = super().get_statistics()
175
+ base_stats.update({
176
+ "total_junctions": 0,
177
+ "avg_diameter": 0,
178
+ "positions": [],
179
+ "unique_diameters": 0,
180
+ "unique_colors": 0,
181
+ })
182
+ return base_stats
183
+
184
+ avg_diameter = sum(j.diameter for j in self._items) / len(self._items)
185
+ positions = [(j.position.x, j.position.y) for j in self._items]
186
+
187
+ base_stats = super().get_statistics()
188
+ base_stats.update({
189
+ "total_junctions": len(self._items),
190
+ "avg_diameter": avg_diameter,
191
+ "positions": positions,
192
+ "unique_diameters": len(set(j.diameter for j in self._items)),
193
+ "unique_colors": len(set(j.color for j in self._items)),
194
+ })
195
+
196
+ return base_stats
197
+
198
+ # Compatibility methods
199
+ @property
200
+ def modified(self) -> bool:
201
+ """Check if collection has been modified (compatibility)."""
202
+ return self.is_modified
203
+
204
+ def mark_saved(self) -> None:
205
+ """Mark collection as saved (reset modified flag)."""
206
+ self.mark_clean()
@@ -0,0 +1,508 @@
1
+ """
2
+ Enhanced label management with IndexRegistry integration.
3
+
4
+ Provides LabelElement wrapper and LabelCollection using BaseCollection
5
+ infrastructure with text indexing and position-based queries.
6
+ """
7
+
8
+ import logging
9
+ import uuid as uuid_module
10
+ from typing import Any, Dict, List, Optional, Tuple, Union
11
+
12
+ from ..core.types import Label, Point
13
+ from ..utils.validation import SchematicValidator, ValidationError, ValidationIssue
14
+ from .base import BaseCollection, IndexSpec, ValidationLevel
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class LabelElement:
20
+ """
21
+ Enhanced wrapper for schematic label elements.
22
+
23
+ Provides intuitive access to label properties and operations
24
+ while maintaining exact format preservation. All property
25
+ modifications automatically notify the parent collection.
26
+ """
27
+
28
+ def __init__(self, label_data: Label, parent_collection: "LabelCollection"):
29
+ """
30
+ Initialize label element wrapper.
31
+
32
+ Args:
33
+ label_data: Underlying label data
34
+ parent_collection: Parent collection for modification tracking
35
+ """
36
+ self._data = label_data
37
+ self._collection = parent_collection
38
+ self._validator = SchematicValidator()
39
+
40
+ # Core properties with validation
41
+ @property
42
+ def uuid(self) -> str:
43
+ """Label element UUID (read-only)."""
44
+ return self._data.uuid
45
+
46
+ @property
47
+ def text(self) -> str:
48
+ """Label text (net name)."""
49
+ return self._data.text
50
+
51
+ @text.setter
52
+ def text(self, value: str):
53
+ """
54
+ Set label text with validation.
55
+
56
+ Args:
57
+ value: New label text
58
+
59
+ Raises:
60
+ ValidationError: If text is empty
61
+ """
62
+ if not isinstance(value, str) or not value.strip():
63
+ raise ValidationError("Label text cannot be empty")
64
+
65
+ old_text = self._data.text
66
+ self._data.text = value.strip()
67
+ self._collection._update_text_index(old_text, self)
68
+ self._collection._mark_modified()
69
+ logger.debug(f"Updated label text: '{old_text}' -> '{value}'")
70
+
71
+ @property
72
+ def position(self) -> Point:
73
+ """Label position in schematic."""
74
+ return self._data.position
75
+
76
+ @position.setter
77
+ def position(self, value: Union[Point, Tuple[float, float]]):
78
+ """Set label position."""
79
+ if isinstance(value, tuple):
80
+ value = Point(value[0], value[1])
81
+ elif not isinstance(value, Point):
82
+ raise ValidationError(f"Position must be Point or tuple, got {type(value)}")
83
+
84
+ self._data.position = value
85
+ self._collection._mark_modified()
86
+
87
+ @property
88
+ def rotation(self) -> float:
89
+ """Label rotation in degrees."""
90
+ return self._data.rotation
91
+
92
+ @rotation.setter
93
+ def rotation(self, value: float):
94
+ """Set label rotation."""
95
+ self._data.rotation = float(value)
96
+ self._collection._mark_modified()
97
+
98
+ @property
99
+ def size(self) -> float:
100
+ """Label text size."""
101
+ return self._data.size
102
+
103
+ @size.setter
104
+ def size(self, value: float):
105
+ """
106
+ Set label size with validation.
107
+
108
+ Args:
109
+ value: New text size
110
+
111
+ Raises:
112
+ ValidationError: If size is not positive
113
+ """
114
+ if value <= 0:
115
+ raise ValidationError(f"Label size must be positive, got {value}")
116
+
117
+ self._data.size = float(value)
118
+ self._collection._mark_modified()
119
+
120
+ # Utility methods
121
+ def move(self, x: float, y: float):
122
+ """Move label to absolute position."""
123
+ self.position = Point(x, y)
124
+
125
+ def translate(self, dx: float, dy: float):
126
+ """Translate label by offset."""
127
+ current = self.position
128
+ self.position = Point(current.x + dx, current.y + dy)
129
+
130
+ def rotate_by(self, angle: float):
131
+ """Rotate label by angle (cumulative)."""
132
+ self.rotation = (self.rotation + angle) % 360
133
+
134
+ def validate(self) -> List[ValidationIssue]:
135
+ """
136
+ Validate this label element.
137
+
138
+ Returns:
139
+ List of validation issues (empty if valid)
140
+ """
141
+ issues = []
142
+
143
+ # Validate text is not empty
144
+ if not self.text or not self.text.strip():
145
+ issues.append(
146
+ ValidationIssue(
147
+ category="label",
148
+ message="Label text is empty",
149
+ level="error"
150
+ )
151
+ )
152
+
153
+ # Validate size is positive
154
+ if self.size <= 0:
155
+ issues.append(
156
+ ValidationIssue(
157
+ category="label",
158
+ message=f"Label size must be positive, got {self.size}",
159
+ level="error"
160
+ )
161
+ )
162
+
163
+ return issues
164
+
165
+ def to_dict(self) -> Dict[str, Any]:
166
+ """
167
+ Convert label to dictionary representation.
168
+
169
+ Returns:
170
+ Dictionary with label data
171
+ """
172
+ return {
173
+ "text": self.text,
174
+ "position": {"x": self.position.x, "y": self.position.y},
175
+ "rotation": self.rotation,
176
+ "size": self.size,
177
+ }
178
+
179
+ def __str__(self) -> str:
180
+ """String representation for display."""
181
+ return f"<Label '{self.text}' @ {self.position}>"
182
+
183
+ def __repr__(self) -> str:
184
+ """Detailed representation for debugging."""
185
+ return f"LabelElement(text='{self.text}', pos={self.position}, rotation={self.rotation})"
186
+
187
+
188
+ class LabelCollection(BaseCollection[LabelElement]):
189
+ """
190
+ Label collection with text indexing and position queries.
191
+
192
+ Inherits from BaseCollection for UUID indexing and adds label-specific
193
+ functionality including text-based searches and filtering.
194
+
195
+ Features:
196
+ - Fast UUID lookup via IndexRegistry
197
+ - Text-based label indexing
198
+ - Position-based queries
199
+ - Lazy index rebuilding
200
+ - Batch mode support
201
+ """
202
+
203
+ def __init__(
204
+ self,
205
+ labels: Optional[List[Label]] = None,
206
+ validation_level: ValidationLevel = ValidationLevel.NORMAL,
207
+ ):
208
+ """
209
+ Initialize label collection.
210
+
211
+ Args:
212
+ labels: Initial list of label data
213
+ validation_level: Validation level for operations
214
+ """
215
+ super().__init__(validation_level=validation_level)
216
+
217
+ # Manual text index (non-unique - multiple labels can have same text)
218
+ self._text_index: Dict[str, List[LabelElement]] = {}
219
+
220
+ # Add initial labels
221
+ if labels:
222
+ with self.batch_mode():
223
+ for label_data in labels:
224
+ label_element = LabelElement(label_data, self)
225
+ super().add(label_element)
226
+ self._add_to_text_index(label_element)
227
+
228
+ logger.debug(f"LabelCollection initialized with {len(self)} labels")
229
+
230
+ # BaseCollection abstract method implementations
231
+ def _get_item_uuid(self, item: LabelElement) -> str:
232
+ """Extract UUID from label element."""
233
+ return item.uuid
234
+
235
+ def _create_item(self, **kwargs) -> LabelElement:
236
+ """Create a new label (not typically used directly)."""
237
+ raise NotImplementedError("Use add() method to create labels")
238
+
239
+ def _get_index_specs(self) -> List[IndexSpec]:
240
+ """Get index specifications for label collection."""
241
+ return [
242
+ IndexSpec(
243
+ name="uuid",
244
+ key_func=lambda l: l.uuid,
245
+ unique=True,
246
+ description="UUID index for fast lookups",
247
+ ),
248
+ ]
249
+
250
+ # Label-specific add method
251
+ def add(
252
+ self,
253
+ text: str,
254
+ position: Union[Point, Tuple[float, float]],
255
+ rotation: float = 0.0,
256
+ size: float = 1.27,
257
+ justify_h: str = "left",
258
+ justify_v: str = "bottom",
259
+ uuid: Optional[str] = None,
260
+ ) -> LabelElement:
261
+ """
262
+ Add a label to the collection.
263
+
264
+ Args:
265
+ text: Label text (net name)
266
+ position: Label position
267
+ rotation: Label rotation in degrees
268
+ size: Text size
269
+ justify_h: Horizontal justification ("left", "right", "center")
270
+ justify_v: Vertical justification ("top", "bottom", "center")
271
+ uuid: Optional UUID (auto-generated if not provided)
272
+
273
+ Returns:
274
+ LabelElement wrapper for the created label
275
+
276
+ Raises:
277
+ ValueError: If UUID already exists or text is empty
278
+ """
279
+ # Validate text
280
+ if not text or not text.strip():
281
+ raise ValueError("Label text cannot be empty")
282
+
283
+ # Generate UUID if not provided
284
+ if uuid is None:
285
+ uuid = str(uuid_module.uuid4())
286
+ else:
287
+ # Check for duplicate
288
+ self._ensure_indexes_current()
289
+ if self._index_registry.has_key("uuid", uuid):
290
+ raise ValueError(f"Label with UUID '{uuid}' already exists")
291
+
292
+ # Convert position
293
+ if isinstance(position, tuple):
294
+ position = Point(position[0], position[1])
295
+
296
+ # Create label data
297
+ label_data = Label(
298
+ uuid=uuid,
299
+ text=text.strip(),
300
+ position=position,
301
+ rotation=rotation,
302
+ size=size,
303
+ justify_h=justify_h,
304
+ justify_v=justify_v,
305
+ )
306
+
307
+ # Create label element wrapper
308
+ label_element = LabelElement(label_data, self)
309
+
310
+ # Add to collection
311
+ super().add(label_element)
312
+
313
+ # Add to text index
314
+ self._add_to_text_index(label_element)
315
+
316
+ logger.debug(f"Added label '{text}' at {position}, UUID={uuid}")
317
+ return label_element
318
+
319
+ # Remove operation (override to update text index)
320
+ def remove(self, uuid: str) -> bool:
321
+ """
322
+ Remove label by UUID.
323
+
324
+ Args:
325
+ uuid: Label UUID to remove
326
+
327
+ Returns:
328
+ True if label was removed, False if not found
329
+ """
330
+ # Get label before removing
331
+ label = self.get(uuid)
332
+ if not label:
333
+ return False
334
+
335
+ # Remove from text index
336
+ self._remove_from_text_index(label)
337
+
338
+ # Remove from base collection
339
+ result = super().remove(uuid)
340
+
341
+ if result:
342
+ logger.info(f"Removed label '{label.text}'")
343
+
344
+ return result
345
+
346
+ # Text-based queries
347
+ def get_by_text(self, text: str) -> List[LabelElement]:
348
+ """
349
+ Find all labels with specific text.
350
+
351
+ Args:
352
+ text: Text to search for
353
+
354
+ Returns:
355
+ List of labels with matching text
356
+ """
357
+ return self._text_index.get(text, [])
358
+
359
+ def filter_by_text_pattern(self, pattern: str) -> List[LabelElement]:
360
+ """
361
+ Find labels with text containing a pattern.
362
+
363
+ Args:
364
+ pattern: Text pattern to search for (case-insensitive)
365
+
366
+ Returns:
367
+ List of labels with matching text
368
+ """
369
+ pattern_lower = pattern.lower()
370
+ matching = []
371
+
372
+ for label in self._items:
373
+ if pattern_lower in label.text.lower():
374
+ matching.append(label)
375
+
376
+ return matching
377
+
378
+ # Position-based queries
379
+ def get_at_position(
380
+ self, position: Union[Point, Tuple[float, float]], tolerance: float = 0.01
381
+ ) -> Optional[LabelElement]:
382
+ """
383
+ Find label at or near a specific position.
384
+
385
+ Args:
386
+ position: Position to search
387
+ tolerance: Distance tolerance for matching
388
+
389
+ Returns:
390
+ Label if found, None otherwise
391
+ """
392
+ if isinstance(position, tuple):
393
+ position = Point(position[0], position[1])
394
+
395
+ for label in self._items:
396
+ if label.position.distance_to(position) <= tolerance:
397
+ return label
398
+
399
+ return None
400
+
401
+ def get_near_point(
402
+ self, point: Union[Point, Tuple[float, float]], radius: float
403
+ ) -> List[LabelElement]:
404
+ """
405
+ Find all labels within radius of a point.
406
+
407
+ Args:
408
+ point: Center point
409
+ radius: Search radius
410
+
411
+ Returns:
412
+ List of labels within radius
413
+ """
414
+ if isinstance(point, tuple):
415
+ point = Point(point[0], point[1])
416
+
417
+ matching = []
418
+ for label in self._items:
419
+ if label.position.distance_to(point) <= radius:
420
+ matching.append(label)
421
+
422
+ return matching
423
+
424
+ # Validation
425
+ def validate_all(self) -> List[ValidationIssue]:
426
+ """
427
+ Validate all labels in collection.
428
+
429
+ Returns:
430
+ List of validation issues found
431
+ """
432
+ all_issues = []
433
+
434
+ for label in self._items:
435
+ issues = label.validate()
436
+ all_issues.extend(issues)
437
+
438
+ return all_issues
439
+
440
+ # Statistics
441
+ def get_statistics(self) -> Dict[str, Any]:
442
+ """
443
+ Get label collection statistics.
444
+
445
+ Returns:
446
+ Dictionary with label statistics
447
+ """
448
+ if not self._items:
449
+ base_stats = super().get_statistics()
450
+ base_stats.update({
451
+ "total_labels": 0,
452
+ "unique_texts": 0,
453
+ "avg_size": 0,
454
+ })
455
+ return base_stats
456
+
457
+ unique_texts = len(self._text_index)
458
+ avg_size = sum(l.size for l in self._items) / len(self._items)
459
+
460
+ base_stats = super().get_statistics()
461
+ base_stats.update({
462
+ "total_labels": len(self._items),
463
+ "unique_texts": unique_texts,
464
+ "avg_size": avg_size,
465
+ "text_distribution": {text: len(labels) for text, labels in self._text_index.items()},
466
+ })
467
+
468
+ return base_stats
469
+
470
+ # Internal helper methods
471
+ def _add_to_text_index(self, label: LabelElement):
472
+ """Add label to text index."""
473
+ text = label.text
474
+ if text not in self._text_index:
475
+ self._text_index[text] = []
476
+ self._text_index[text].append(label)
477
+
478
+ def _remove_from_text_index(self, label: LabelElement):
479
+ """Remove label from text index."""
480
+ text = label.text
481
+ if text in self._text_index:
482
+ self._text_index[text].remove(label)
483
+ if not self._text_index[text]:
484
+ del self._text_index[text]
485
+
486
+ def _update_text_index(self, old_text: str, label: LabelElement):
487
+ """Update text index when label text changes."""
488
+ # Remove from old text
489
+ if old_text in self._text_index:
490
+ self._text_index[old_text].remove(label)
491
+ if not self._text_index[old_text]:
492
+ del self._text_index[old_text]
493
+
494
+ # Add to new text
495
+ new_text = label.text
496
+ if new_text not in self._text_index:
497
+ self._text_index[new_text] = []
498
+ self._text_index[new_text].append(label)
499
+
500
+ # Compatibility methods
501
+ @property
502
+ def modified(self) -> bool:
503
+ """Check if collection has been modified (compatibility)."""
504
+ return self.is_modified
505
+
506
+ def mark_saved(self) -> None:
507
+ """Mark collection as saved (reset modified flag)."""
508
+ self.mark_clean()