harnice 0.3.0__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 (41) hide show
  1. harnice/__init__.py +0 -0
  2. harnice/__main__.py +4 -0
  3. harnice/cli.py +234 -0
  4. harnice/fileio.py +295 -0
  5. harnice/gui/launcher.py +426 -0
  6. harnice/lists/channel_map.py +182 -0
  7. harnice/lists/circuits_list.py +302 -0
  8. harnice/lists/disconnect_map.py +237 -0
  9. harnice/lists/formboard_graph.py +63 -0
  10. harnice/lists/instances_list.py +280 -0
  11. harnice/lists/library_history.py +40 -0
  12. harnice/lists/manifest.py +93 -0
  13. harnice/lists/post_harness_instances_list.py +66 -0
  14. harnice/lists/rev_history.py +325 -0
  15. harnice/lists/signals_list.py +135 -0
  16. harnice/products/__init__.py +1 -0
  17. harnice/products/cable.py +152 -0
  18. harnice/products/chtype.py +80 -0
  19. harnice/products/device.py +844 -0
  20. harnice/products/disconnect.py +225 -0
  21. harnice/products/flagnote.py +139 -0
  22. harnice/products/harness.py +522 -0
  23. harnice/products/macro.py +10 -0
  24. harnice/products/part.py +640 -0
  25. harnice/products/system.py +125 -0
  26. harnice/products/tblock.py +270 -0
  27. harnice/state.py +57 -0
  28. harnice/utils/appearance.py +51 -0
  29. harnice/utils/circuit_utils.py +326 -0
  30. harnice/utils/feature_tree_utils.py +183 -0
  31. harnice/utils/formboard_utils.py +973 -0
  32. harnice/utils/library_utils.py +333 -0
  33. harnice/utils/note_utils.py +417 -0
  34. harnice/utils/svg_utils.py +819 -0
  35. harnice/utils/system_utils.py +563 -0
  36. harnice-0.3.0.dist-info/METADATA +32 -0
  37. harnice-0.3.0.dist-info/RECORD +41 -0
  38. harnice-0.3.0.dist-info/WHEEL +5 -0
  39. harnice-0.3.0.dist-info/entry_points.txt +3 -0
  40. harnice-0.3.0.dist-info/licenses/LICENSE +19 -0
  41. harnice-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,426 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from PySide6.QtWidgets import (
7
+ QApplication,
8
+ QWidget,
9
+ QPushButton,
10
+ QFileDialog,
11
+ QMenu,
12
+ )
13
+ from PySide6.QtCore import Qt, QThread, Signal, QObject
14
+ from PySide6.QtGui import QPainter, QPen
15
+
16
+
17
+ def layout_config_path():
18
+ """
19
+ Save layout JSON into the root of the harnice project directory.
20
+ """
21
+ return Path(__file__).resolve().parents[2] / "gui_layout.json"
22
+
23
+
24
+ def run_harnice_render(cwd, lightweight=False):
25
+ """
26
+ Safely run harnice.cli.main() without sys.exit closing the GUI.
27
+ """
28
+ import harnice.cli
29
+
30
+ old_cwd = os.getcwd()
31
+ os.chdir(cwd)
32
+
33
+ try:
34
+ sys.argv = ["harnice", "-l" if lightweight else "-r"]
35
+ try:
36
+ harnice.cli.main()
37
+ except SystemExit:
38
+ pass
39
+ finally:
40
+ os.chdir(old_cwd)
41
+
42
+
43
+ class RenderWorker(QObject):
44
+ finished = Signal(bool, str) # True = success, False = error, error_message
45
+
46
+ def __init__(self, cwd, lightweight):
47
+ super().__init__()
48
+ self.cwd = cwd
49
+ self.lightweight = lightweight
50
+
51
+ def run(self):
52
+ try:
53
+ run_harnice_render(self.cwd, self.lightweight)
54
+ self.finished.emit(True, "")
55
+ except Exception as e:
56
+ import traceback
57
+
58
+ error_msg = f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
59
+ self.finished.emit(False, error_msg)
60
+
61
+
62
+ class GridWidget(QWidget):
63
+ BUTTON_WIDTH = 180
64
+ BUTTON_HEIGHT = 40
65
+ BUTTON_WIDTH_MARGIN = 20
66
+ BUTTON_HEIGHT_MARGIN = 20
67
+
68
+ def __init__(self, parent=None):
69
+ super().__init__(parent)
70
+ self.grid_buttons = {}
71
+ self.setStyleSheet("background-color: white;")
72
+ self.setAutoFillBackground(True)
73
+
74
+ @property
75
+ def GRID_SPACING_X(self):
76
+ return self.BUTTON_WIDTH + self.BUTTON_WIDTH_MARGIN
77
+
78
+ @property
79
+ def GRID_SPACING_Y(self):
80
+ return self.BUTTON_HEIGHT + self.BUTTON_HEIGHT_MARGIN
81
+
82
+ @property
83
+ def OFFSET_X(self):
84
+ return self.GRID_SPACING_X / 2
85
+
86
+ @property
87
+ def OFFSET_Y(self):
88
+ return self.GRID_SPACING_Y / 2
89
+
90
+ def paintEvent(self, event):
91
+ painter = QPainter(self)
92
+ painter.setRenderHint(QPainter.Antialiasing)
93
+ pen = QPen(Qt.GlobalColor.gray, 1, Qt.PenStyle.DotLine)
94
+ painter.setPen(pen)
95
+
96
+ width = self.width()
97
+ height = self.height()
98
+
99
+ x = self.OFFSET_X
100
+ while x < width:
101
+ painter.drawLine(x, 0, x, height)
102
+ x += self.GRID_SPACING_X
103
+
104
+ y = self.OFFSET_Y
105
+ while y < height:
106
+ painter.drawLine(0, y, width, y)
107
+ y += self.GRID_SPACING_Y
108
+
109
+ def grid_to_screen(self, grid_x, grid_y):
110
+ return (
111
+ grid_x * self.GRID_SPACING_X + self.OFFSET_X,
112
+ grid_y * self.GRID_SPACING_Y + self.OFFSET_Y,
113
+ )
114
+
115
+ def screen_to_grid(self, screen_x, screen_y):
116
+ return (
117
+ int((screen_x - self.OFFSET_X) / self.GRID_SPACING_X),
118
+ int((screen_y - self.OFFSET_Y) / self.GRID_SPACING_Y),
119
+ )
120
+
121
+ def is_grid_occupied(self, grid_x, grid_y, exclude=None):
122
+ btn = self.grid_buttons.get((grid_x, grid_y))
123
+ return btn is not None and btn is not exclude
124
+
125
+
126
+ class PartButton(QPushButton):
127
+ def __init__(self, parent, label, path, grid_x, grid_y, main_window=None):
128
+ super().__init__(label, parent)
129
+ self.parent_grid = parent
130
+ self.path = path
131
+ self.grid_x = grid_x
132
+ self.grid_y = grid_y
133
+ self.main_window = main_window
134
+
135
+ # ✅ Store the intended "default / unclicked" theme
136
+ self.default_style = """
137
+ QPushButton {
138
+ background-color: #e6e6e6;
139
+ border: 1px solid #666;
140
+ border-radius: 4px;
141
+ }
142
+ QPushButton:hover {
143
+ background-color: #f2f2f2;
144
+ }
145
+ QPushButton:pressed {
146
+ background-color: #d0d0d0;
147
+ }
148
+ """
149
+ self.setStyleSheet(self.default_style)
150
+
151
+ self.setFixedSize(parent.BUTTON_WIDTH, parent.BUTTON_HEIGHT)
152
+ self.dragStartPosition = None
153
+ self.is_dragging = False
154
+ self.show()
155
+ self.update_position()
156
+
157
+ def update_position(self):
158
+ x, y = self.parent_grid.grid_to_screen(self.grid_x, self.grid_y)
159
+ self.move(x - self.width() // 2, y - self.height() // 2)
160
+ self.raise_()
161
+
162
+ def mousePressEvent(self, event):
163
+ if event.button() == Qt.MouseButton.LeftButton:
164
+ self.dragStartPosition = event.position().toPoint()
165
+ self.is_dragging = False
166
+ super().mousePressEvent(event)
167
+
168
+ def mouseMoveEvent(self, event):
169
+ if (
170
+ not (event.buttons() & Qt.MouseButton.LeftButton)
171
+ or not self.dragStartPosition
172
+ ):
173
+ return
174
+
175
+ if (event.position().toPoint() - self.dragStartPosition).manhattanLength() < 8:
176
+ return
177
+
178
+ self.is_dragging = True
179
+ old_pos = (self.grid_x, self.grid_y)
180
+
181
+ global_pos = self.mapToGlobal(event.position().toPoint())
182
+ local_pos = self.parent_grid.mapFromGlobal(global_pos)
183
+ new_x, new_y = self.parent_grid.screen_to_grid(local_pos.x(), local_pos.y())
184
+
185
+ if (new_x, new_y) == old_pos:
186
+ return
187
+
188
+ if self.parent_grid.is_grid_occupied(new_x, new_y, exclude=self):
189
+ return
190
+
191
+ self.grid_x, self.grid_y = new_x, new_y
192
+ self.update_position()
193
+
194
+ self.parent_grid.grid_buttons.pop(old_pos, None)
195
+ self.parent_grid.grid_buttons[(new_x, new_y)] = self
196
+
197
+ def mouseReleaseEvent(self, event):
198
+ if self.is_dragging:
199
+ if self.main_window:
200
+ self.main_window.save_layout()
201
+ self.is_dragging = False
202
+ event.accept()
203
+ return
204
+ super().mouseReleaseEvent(event)
205
+
206
+ def contextMenuEvent(self, event):
207
+ if not self.main_window:
208
+ return
209
+
210
+ menu = QMenu(self)
211
+ remove = menu.addAction("Remove button")
212
+ remove.triggered.connect(lambda: self.main_window.remove_button(self))
213
+
214
+ newrev = menu.addAction("Create new revision")
215
+ newrev.triggered.connect(lambda: self.main_window.new_rev(self))
216
+
217
+ menu.exec(event.globalPos())
218
+
219
+
220
+ class HarniceGUI(QWidget):
221
+ def __init__(self):
222
+ super().__init__()
223
+ self.setWindowTitle("Harnice")
224
+ self.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Window)
225
+
226
+ self.grid = GridWidget(self)
227
+
228
+ default_width = self.grid.GRID_SPACING_X * 6
229
+ default_height = self.grid.GRID_SPACING_Y * 2
230
+ self.resize(default_width, default_height)
231
+ self.grid.setGeometry(0, 0, default_width, default_height)
232
+
233
+ self.load_button = QPushButton("Load part for render...", self.grid)
234
+ self.load_button.setFixedSize(self.grid.BUTTON_WIDTH, self.grid.BUTTON_HEIGHT)
235
+ self.load_button.clicked.connect(self.pick_folder)
236
+
237
+ x, y = self.grid.grid_to_screen(0, 0)
238
+ self.load_button.move(
239
+ x - self.load_button.width() // 2, y - self.load_button.height() // 2
240
+ )
241
+ self.grid.grid_buttons[(0, 0)] = self.load_button
242
+
243
+ self._is_initializing = True
244
+ self.load_layout()
245
+
246
+ # Set window size after loading layout (if any)
247
+ self.apply_window_size()
248
+ self._is_initializing = False
249
+
250
+ def resizeEvent(self, event):
251
+ self.grid.setGeometry(0, 0, self.width(), self.height())
252
+ super().resizeEvent(event)
253
+ # Save layout when window is manually resized
254
+ if hasattr(self, "load_button") and not getattr(
255
+ self, "_is_initializing", False
256
+ ):
257
+ self.save_layout()
258
+
259
+ def pick_folder(self):
260
+ folder = QFileDialog.getExistingDirectory(self, "Select revision folder")
261
+ if not folder:
262
+ return
263
+
264
+ gx, gy = self.find_next_grid_position()
265
+ label = os.path.basename(folder)
266
+
267
+ btn = PartButton(self.grid, label, folder, gx, gy, main_window=self)
268
+ btn.clicked.connect(lambda checked=False, p=folder: self.run_render(p))
269
+ self.grid.grid_buttons[(gx, gy)] = btn
270
+ self.save_layout()
271
+
272
+ def find_next_grid_position(self):
273
+ for y in range(200):
274
+ for x in range(200):
275
+ if not self.grid.is_grid_occupied(x, y):
276
+ return (x, y)
277
+
278
+ def run_render(self, cwd):
279
+ btn = next(
280
+ (
281
+ b
282
+ for b in self.grid.grid_buttons.values()
283
+ if isinstance(b, PartButton) and b.path == cwd
284
+ ),
285
+ None,
286
+ )
287
+
288
+ if btn:
289
+ btn.setStyleSheet("background-color: #b1ffb1;") # Green while running
290
+
291
+ self.thread = QThread()
292
+ self.worker = RenderWorker(cwd, False)
293
+ self.worker.moveToThread(self.thread)
294
+
295
+ self.thread.started.connect(self.worker.run)
296
+ self.worker.finished.connect(
297
+ lambda success, error_msg: self.on_render_finished(btn, success, error_msg)
298
+ )
299
+ self.worker.finished.connect(self.thread.quit)
300
+ self.worker.finished.connect(self.worker.deleteLater)
301
+ self.thread.finished.connect(self.thread.deleteLater)
302
+ self.thread.start()
303
+
304
+ def on_render_finished(self, btn, success, error_msg):
305
+ if not btn:
306
+ return
307
+
308
+ if success:
309
+ # ✅ Restore original intended appearance
310
+ btn.setStyleSheet(btn.default_style)
311
+ btn.update()
312
+ else:
313
+ btn.setStyleSheet("background-color: #ffb1b1;") # Error red
314
+ # Print error message and traceback to console in color
315
+ if error_msg:
316
+ # ANSI color codes
317
+ RED = "\033[91m"
318
+ BOLD = "\033[1m"
319
+ RESET = "\033[0m"
320
+ YELLOW = "\033[93m"
321
+
322
+ print(f"\n{RED}{BOLD}{'=' * 80}{RESET}")
323
+ print(f"{RED}{BOLD}ERROR in {YELLOW}{btn.path}{RESET}{RED}:{RESET}")
324
+ print(f"{RED}{BOLD}{'=' * 80}{RESET}")
325
+ print(f"{RED}{error_msg}{RESET}")
326
+ print(f"{RED}{BOLD}{'=' * 80}{RESET}\n")
327
+
328
+ def remove_button(self, button):
329
+ self.grid.grid_buttons.pop((button.grid_x, button.grid_y), None)
330
+ button.deleteLater()
331
+ self.save_layout()
332
+
333
+ def new_rev(self, button):
334
+ """
335
+ Run harnice --newrev from the button's directory.
336
+ """
337
+ import harnice.cli
338
+
339
+ old_cwd = os.getcwd()
340
+ os.chdir(button.path)
341
+
342
+ try:
343
+ sys.argv = ["harnice", "--newrev"]
344
+ try:
345
+ harnice.cli.main()
346
+ except SystemExit:
347
+ pass
348
+ finally:
349
+ os.chdir(old_cwd)
350
+
351
+ def save_layout(self):
352
+ data = {
353
+ "window": {
354
+ "width": self.width(),
355
+ "height": self.height(),
356
+ },
357
+ "buttons": [
358
+ {
359
+ "label": b.text(),
360
+ "path": b.path,
361
+ "grid_x": b.grid_x,
362
+ "grid_y": b.grid_y,
363
+ }
364
+ for (gx, gy), b in self.grid.grid_buttons.items()
365
+ if isinstance(b, PartButton)
366
+ ],
367
+ }
368
+
369
+ with open(layout_config_path(), "w", encoding="utf-8") as f:
370
+ json.dump(data, f, indent=2)
371
+
372
+ def load_layout(self):
373
+ cfg = layout_config_path()
374
+ if not cfg.exists():
375
+ return
376
+
377
+ try:
378
+ data = json.loads(cfg.read_text(encoding="utf-8"))
379
+ except Exception:
380
+ return
381
+
382
+ # Handle old format (array of buttons) vs new format (dict with buttons and window)
383
+ if isinstance(data, list):
384
+ items = data
385
+ self.saved_window_size = None
386
+ else:
387
+ items = data.get("buttons", [])
388
+ window_info = data.get("window")
389
+ if window_info:
390
+ self.saved_window_size = (
391
+ window_info.get("width"),
392
+ window_info.get("height"),
393
+ )
394
+ else:
395
+ self.saved_window_size = None
396
+
397
+ for item in items:
398
+ btn = PartButton(
399
+ self.grid,
400
+ item["label"],
401
+ item["path"],
402
+ item["grid_x"],
403
+ item["grid_y"],
404
+ main_window=self,
405
+ )
406
+ btn.clicked.connect(
407
+ lambda checked=False, p=item["path"]: self.run_render(p)
408
+ )
409
+ self.grid.grid_buttons[(item["grid_x"], item["grid_y"])] = btn
410
+
411
+ def apply_window_size(self):
412
+ if hasattr(self, "saved_window_size") and self.saved_window_size:
413
+ width, height = self.saved_window_size
414
+ self.resize(width, height)
415
+ self.grid.setGeometry(0, 0, width, height)
416
+
417
+
418
+ def main():
419
+ app = QApplication([])
420
+ gui = HarniceGUI()
421
+ gui.show()
422
+ app.exec()
423
+
424
+
425
+ if __name__ == "__main__":
426
+ main()
@@ -0,0 +1,182 @@
1
+ import csv
2
+ import os
3
+ from harnice import fileio
4
+
5
+ COLUMNS = [
6
+ "merged_net", #documentation needed
7
+ "from_device_refdes", #documentation needed
8
+ "from_device_channel_id", #documentation needed
9
+ "from_channel_type", #documentation needed
10
+ "to_device_refdes", #documentation needed
11
+ "to_device_channel_id", #documentation needed
12
+ "to_channel_type", #documentation needed
13
+ "multi_ch_junction_id", #documentation needed
14
+ "disconnect_refdes_requirement", #documentation needed
15
+ "chain_of_connectors", #documentation needed
16
+ "chain_of_nets", #documentation needed
17
+ "manual_map_channel_python_equiv", #documentation needed
18
+ ]
19
+
20
+
21
+ def new():
22
+ """
23
+ Makes a new blank channel map. Overwrites existing channel map.
24
+
25
+ Args: none
26
+
27
+ Returns: none
28
+ """
29
+ channel_map = []
30
+
31
+ for connector in fileio.read_tsv("system connector list"):
32
+ device_refdes = connector.get("device_refdes")
33
+
34
+ if connector.get("disconnect") == "TRUE":
35
+ continue
36
+
37
+ device_signals_list_path = os.path.join(
38
+ fileio.dirpath("instance_data"),
39
+ "device",
40
+ device_refdes,
41
+ f"{device_refdes}-signals_list.tsv",
42
+ )
43
+
44
+ for signal in fileio.read_tsv(device_signals_list_path):
45
+ sig_channel = signal.get("channel_id")
46
+
47
+ already = any(
48
+ row.get("from_device_refdes") == device_refdes
49
+ and row.get("from_device_channel_id") == sig_channel
50
+ for row in channel_map
51
+ )
52
+ if already:
53
+ continue
54
+
55
+ if not signal.get("connector_name") == connector.get("connector"):
56
+ continue
57
+
58
+ channel_map_row = {
59
+ "merged_net": connector.get("merged_net", ""),
60
+ "from_channel_type": signal.get("channel_type", ""),
61
+ "from_device_refdes": device_refdes,
62
+ "from_device_channel_id": sig_channel,
63
+ }
64
+ channel_map.append(channel_map_row)
65
+
66
+ # write channel map TSV
67
+ with open(fileio.path("channel map"), "w", newline="", encoding="utf-8") as f:
68
+ writer = csv.DictWriter(f, fieldnames=COLUMNS, delimiter="\t")
69
+ writer.writeheader()
70
+ writer.writerows(channel_map)
71
+
72
+ # initialize mapped channels set TSV (empty, single column)
73
+ with open(
74
+ fileio.path("mapped channels set"), "w", newline="", encoding="utf-8"
75
+ ) as f:
76
+ pass
77
+
78
+ return channel_map
79
+
80
+
81
+ def map(from_key, to_key=None, multi_ch_junction_key=""):
82
+ if from_key in already_mapped_set():
83
+ raise ValueError(f"from_key {from_key} already mapped")
84
+ if to_key and to_key in already_mapped_set():
85
+ raise ValueError(f"to_key {to_key} already mapped")
86
+
87
+ channels = fileio.read_tsv("channel map")
88
+
89
+ to_channel = None
90
+ for channel in channels:
91
+ if (
92
+ channel.get("from_device_refdes") == to_key[0]
93
+ and channel.get("from_device_channel_id") == to_key[1]
94
+ ):
95
+ to_channel = channel
96
+ break
97
+
98
+ from_channel = None
99
+ for channel in channels:
100
+ if (
101
+ channel.get("from_device_refdes") == from_key[0]
102
+ and channel.get("from_device_channel_id") == from_key[1]
103
+ ):
104
+ from_channel = channel
105
+ break
106
+
107
+ if not to_channel and multi_ch_junction_key == "":
108
+ raise ValueError(f"to_key {to_key} not found in channel map")
109
+ else:
110
+ require_to = bool(to_key[0] or to_key[1])
111
+
112
+ updated_channels, found_from, found_to = [], False, False
113
+
114
+ for from_channel in channels:
115
+ if (
116
+ from_channel.get("from_device_refdes") == from_key[0]
117
+ and from_channel.get("from_device_channel_id") == from_key[1]
118
+ ):
119
+ from_channel["to_device_refdes"] = to_key[0]
120
+ from_channel["to_device_channel_id"] = to_key[1]
121
+ from_channel["to_channel_type"] = to_channel.get("from_channel_type")
122
+ if multi_ch_junction_key:
123
+ from_channel["multi_ch_junction_id"] = multi_ch_junction_key
124
+ found_from = True
125
+
126
+ if require_to:
127
+ from_channel["manual_map_channel_python_equiv"] = (
128
+ f"channel_map.map({from_key}, {to_key})"
129
+ )
130
+ elif multi_ch_junction_key:
131
+ from_channel["manual_map_channel_python_equiv"] = (
132
+ f"channel_map.map({from_key}, multi_ch_junction_key={multi_ch_junction_key})"
133
+ )
134
+ elif (
135
+ require_to
136
+ and from_channel.get("from_device_refdes") == to_key[0]
137
+ and from_channel.get("from_device_channel_id") == to_key[1]
138
+ ):
139
+ found_to = True
140
+ continue
141
+ updated_channels.append(from_channel)
142
+
143
+ if not found_from:
144
+ raise ValueError(f"from_key {from_key} not found in channel map")
145
+ if require_to and not found_to:
146
+ raise ValueError(f"to_key {to_key} not found in channel map")
147
+
148
+ already_mapped_set_append(from_key)
149
+ already_mapped_set_append(to_key)
150
+
151
+ with open(fileio.path("channel map"), "w", newline="", encoding="utf-8") as f:
152
+ writer = csv.DictWriter(f, fieldnames=COLUMNS, delimiter="\t")
153
+ writer.writeheader()
154
+ writer.writerows(updated_channels)
155
+
156
+
157
+ def already_mapped_set_append(key):
158
+ items = already_mapped_set()
159
+ if str(key) in items:
160
+ raise ValueError(f"key {key} already mapped")
161
+ items.add(str(key))
162
+ with open(
163
+ fileio.path("mapped channels set"), "w", newline="", encoding="utf-8"
164
+ ) as f:
165
+ writer = csv.writer(f, delimiter="\t")
166
+ for item in sorted(items):
167
+ writer.writerow([item])
168
+
169
+
170
+ def already_mapped_set():
171
+ if not os.path.exists(fileio.path("mapped channels set")):
172
+ return set()
173
+ with open(fileio.path("mapped channels set"), newline="", encoding="utf-8") as f:
174
+ reader = csv.reader(f, delimiter="\t")
175
+ return set(row[0] for row in reader if row)
176
+
177
+
178
+ def already_mapped(key):
179
+ if str(key) in already_mapped_set():
180
+ return True
181
+ else:
182
+ return False