kivar 0.2.0__tar.gz

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.
kivar-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.1
2
+ Name: kivar
3
+ Version: 0.2.0
4
+ Summary: PCB Assembly Variants for KiCad
5
+ Home-page: https://github.com/markh-de/KiVar
6
+ Author: Mark Hämmerling
7
+ Author-email: dev@markh.de
8
+ License: MIT
9
+ Platform: UNKNOWN
10
+ Description-Content-Type: text/markdown
11
+
12
+ # KiVar − PCB Assembly Variants for KiCad
13
+
14
+ ## Introduction
15
+
16
+ KiVar is a tool for **KiCad PCB Assembly Variant selection**, provided as platform-independent
17
+
18
+ * **Command Line Application** (this package) and
19
+ * **KiCad Action Plugin** (available in KiCad PCM).
20
+
21
+ PCB component variation rules are defined in component (i.e. symbol or footprint) fields. This allows for the complete variant configuration to be contained in the schematic and board files without requiring external data from outside the native KiCad design files.
22
+
23
+ The name _KiVar_ (for _KiCad Variants_, obviously) can also be read as an acronym for _**Ki**Cad **V**ariation **a**ssignment **r**ules_.
24
+
25
+ ## Features
26
+
27
+ KiVar assigns PCB component **values**, **field content** and **attributes** (such as _Do not populate_, _Not in position files_, _Not in BoM_) according to variation rules specified in footprint fields. When applying those rules, components are modified _in place_, allowing for immediate update of the PCB design as well as the 3D view and enabling compatibility with _any_ exporter.
28
+
29
+ Back-propagation of modified component data from the PCB to the schematic can be done in an extra step.
30
+
31
+ ## What to Expect
32
+
33
+ Example usage of the **KiVar Command Line Interface app**:
34
+
35
+ ```
36
+ $ kivar list --selection kivar-demo.kicad_pcb
37
+ BOOT_SRC: [EMMC] JP NAND SD
38
+ EEPROM_ADDR: 0x54 [0x55]
39
+ I_LED_MA: 10 20 30 40 50 60 70 80 90 [100] 110 120 130 140 150 JP
40
+ IOEXP_TYPE/ADDR: 9535/0x20 [9535/0x24] 9539/0x74
41
+ ISL91127: [IRAZ] IRNZ
42
+ UVLO_LO/HI: 2.41V/3.40V [3.15V/3.57V]
43
+
44
+ $ kivar set --assign 'I_LED_MA=60' --assign 'BOOT_SRC=NAND' --verbose kivar-demo.kicad_pcb
45
+ Changes (16):
46
+ Change R9 'Do not populate' from 'false' to 'true' (BOOT_SRC=NAND).
47
+ Change R9 'Exclude from bill of materials' from 'false' to 'true' (BOOT_SRC=NAND).
48
+ Change R9 'Exclude from position files' from 'false' to 'true' (BOOT_SRC=NAND).
49
+ Change R9 field 'ChoiceText' from 'SoM eMMC' to 'SoM NAND' (BOOT_SRC=NAND).
50
+ Change R10 'Do not populate' from 'true' to 'false' (BOOT_SRC=NAND).
51
+ Change R10 'Exclude from bill of materials' from 'true' to 'false' (BOOT_SRC=NAND).
52
+ Change R10 'Exclude from position files' from 'true' to 'false' (BOOT_SRC=NAND).
53
+ Change R11 'Do not populate' from 'false' to 'true' (BOOT_SRC=NAND).
54
+ Change R11 'Exclude from bill of materials' from 'false' to 'true' (BOOT_SRC=NAND).
55
+ Change R11 'Exclude from position files' from 'false' to 'true' (BOOT_SRC=NAND).
56
+ Change R21 'Do not populate' from 'false' to 'true' (I_LED_MA=60).
57
+ Change R21 'Exclude from bill of materials' from 'false' to 'true' (I_LED_MA=60).
58
+ Change R21 'Exclude from position files' from 'false' to 'true' (I_LED_MA=60).
59
+ Change R22 'Do not populate' from 'true' to 'false' (I_LED_MA=60).
60
+ Change R22 'Exclude from bill of materials' from 'true' to 'false' (I_LED_MA=60).
61
+ Change R22 'Exclude from position files' from 'true' to 'false' (I_LED_MA=60).
62
+ Board saved to file "kivar-demo.kicad_pcb".
63
+
64
+ $ kivar list --selection kivar-demo.kicad_pcb
65
+ BOOT_SRC: EMMC JP [NAND] SD
66
+ EEPROM_ADDR: 0x54 [0x55]
67
+ I_LED_MA: 10 20 30 40 50 [60] 70 80 90 100 110 120 130 140 150 JP
68
+ IOEXP_TYPE/ADDR: 9535/0x20 [9535/0x24] 9539/0x74
69
+ ISL91127: [IRAZ] IRNZ
70
+ UVLO_LO/HI: 2.41V/3.40V [3.15V/3.57V]
71
+ ```
72
+
73
+ ## Concepts
74
+
75
+ Key concepts of KiVar are:
76
+
77
+ * Designs may contain **multiple** independent variation **aspects** (i.e. dimensions or degrees of freedom).
78
+ * Variation rules are **fully contained** in component fields of native design files (no external configuration files) and **portable** (i.e. copying components to another design keeps their variation specification intact).
79
+ * Component values and attributes are modified **in place**, enabling compatibility with all exporters that work on the actual component data.
80
+ * **No external state information** is stored; currently matching variation choices are detected automatically.
81
+
82
+ ## Project Page
83
+
84
+ For the usage manual and a demo project check out the [KiVar Project Page](https://github.com/markh-de/KiVar).
85
+
86
+
kivar-0.2.0/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # KiVar − PCB Assembly Variants for KiCad
2
+
3
+ ## Introduction
4
+
5
+ KiVar is a tool for **KiCad PCB Assembly Variant selection**, provided as platform-independent
6
+
7
+ * **Command Line Application** (this package) and
8
+ * **KiCad Action Plugin** (available in KiCad PCM).
9
+
10
+ PCB component variation rules are defined in component (i.e. symbol or footprint) fields. This allows for the complete variant configuration to be contained in the schematic and board files without requiring external data from outside the native KiCad design files.
11
+
12
+ The name _KiVar_ (for _KiCad Variants_, obviously) can also be read as an acronym for _**Ki**Cad **V**ariation **a**ssignment **r**ules_.
13
+
14
+ ## Features
15
+
16
+ KiVar assigns PCB component **values**, **field content** and **attributes** (such as _Do not populate_, _Not in position files_, _Not in BoM_) according to variation rules specified in footprint fields. When applying those rules, components are modified _in place_, allowing for immediate update of the PCB design as well as the 3D view and enabling compatibility with _any_ exporter.
17
+
18
+ Back-propagation of modified component data from the PCB to the schematic can be done in an extra step.
19
+
20
+ ## What to Expect
21
+
22
+ Example usage of the **KiVar Command Line Interface app**:
23
+
24
+ ```
25
+ $ kivar list --selection kivar-demo.kicad_pcb
26
+ BOOT_SRC: [EMMC] JP NAND SD
27
+ EEPROM_ADDR: 0x54 [0x55]
28
+ I_LED_MA: 10 20 30 40 50 60 70 80 90 [100] 110 120 130 140 150 JP
29
+ IOEXP_TYPE/ADDR: 9535/0x20 [9535/0x24] 9539/0x74
30
+ ISL91127: [IRAZ] IRNZ
31
+ UVLO_LO/HI: 2.41V/3.40V [3.15V/3.57V]
32
+
33
+ $ kivar set --assign 'I_LED_MA=60' --assign 'BOOT_SRC=NAND' --verbose kivar-demo.kicad_pcb
34
+ Changes (16):
35
+ Change R9 'Do not populate' from 'false' to 'true' (BOOT_SRC=NAND).
36
+ Change R9 'Exclude from bill of materials' from 'false' to 'true' (BOOT_SRC=NAND).
37
+ Change R9 'Exclude from position files' from 'false' to 'true' (BOOT_SRC=NAND).
38
+ Change R9 field 'ChoiceText' from 'SoM eMMC' to 'SoM NAND' (BOOT_SRC=NAND).
39
+ Change R10 'Do not populate' from 'true' to 'false' (BOOT_SRC=NAND).
40
+ Change R10 'Exclude from bill of materials' from 'true' to 'false' (BOOT_SRC=NAND).
41
+ Change R10 'Exclude from position files' from 'true' to 'false' (BOOT_SRC=NAND).
42
+ Change R11 'Do not populate' from 'false' to 'true' (BOOT_SRC=NAND).
43
+ Change R11 'Exclude from bill of materials' from 'false' to 'true' (BOOT_SRC=NAND).
44
+ Change R11 'Exclude from position files' from 'false' to 'true' (BOOT_SRC=NAND).
45
+ Change R21 'Do not populate' from 'false' to 'true' (I_LED_MA=60).
46
+ Change R21 'Exclude from bill of materials' from 'false' to 'true' (I_LED_MA=60).
47
+ Change R21 'Exclude from position files' from 'false' to 'true' (I_LED_MA=60).
48
+ Change R22 'Do not populate' from 'true' to 'false' (I_LED_MA=60).
49
+ Change R22 'Exclude from bill of materials' from 'true' to 'false' (I_LED_MA=60).
50
+ Change R22 'Exclude from position files' from 'true' to 'false' (I_LED_MA=60).
51
+ Board saved to file "kivar-demo.kicad_pcb".
52
+
53
+ $ kivar list --selection kivar-demo.kicad_pcb
54
+ BOOT_SRC: EMMC JP [NAND] SD
55
+ EEPROM_ADDR: 0x54 [0x55]
56
+ I_LED_MA: 10 20 30 40 50 [60] 70 80 90 100 110 120 130 140 150 JP
57
+ IOEXP_TYPE/ADDR: 9535/0x20 [9535/0x24] 9539/0x74
58
+ ISL91127: [IRAZ] IRNZ
59
+ UVLO_LO/HI: 2.41V/3.40V [3.15V/3.57V]
60
+ ```
61
+
62
+ ## Concepts
63
+
64
+ Key concepts of KiVar are:
65
+
66
+ * Designs may contain **multiple** independent variation **aspects** (i.e. dimensions or degrees of freedom).
67
+ * Variation rules are **fully contained** in component fields of native design files (no external configuration files) and **portable** (i.e. copying components to another design keeps their variation specification intact).
68
+ * Component values and attributes are modified **in place**, enabling compatibility with all exporters that work on the actual component data.
69
+ * **No external state information** is stored; currently matching variation choices are detected automatically.
70
+
71
+ ## Project Page
72
+
73
+ For the usage manual and a demo project check out the [KiVar Project Page](https://github.com/markh-de/KiVar).
@@ -0,0 +1 @@
1
+ from .kivar_cli import main
@@ -0,0 +1,712 @@
1
+ import pcbnew
2
+ from copy import deepcopy
3
+
4
+ # Note about field case-sensitivity:
5
+ # As long as KiCad can easily be tricked* into having multiple fields whose names only differ in casing, we
6
+ # will not allow case-insensitive field parsing/assignment.
7
+ # * Even though the symbol editor does not allow having "Var" and "VAR" at the same time, you can rename a field
8
+ # to "VAR" and will still get the field name template "Var" presented, which you can fill with a value and KiCad
9
+ # will even save that field to your file.
10
+
11
+ # TODO pre-sort errors and changes before returning them, ready to be used by caller. then remove sorting and
12
+ # requirements (e.g. Key class import) in callers.
13
+
14
+ # TODO clarify rules for Aspect name (forbidden characters: "*" ".")
15
+
16
+ # TODO filter forbidden field names (reference, value, footprint (???)) for set AND get!!
17
+
18
+ # TODO in aux field parser, accept only target fields which are no KiVar fields themselves (avoid recursion!)
19
+
20
+ # TODO wrap the backend with a class.
21
+
22
+ # TODO more testing!
23
+
24
+ # TODO KiCad 8 has different change reporting style, it seems. use this?
25
+ # Remove R29 'Do not place' fabrication attribute.
26
+ # Remove R29 'exclude from BOM' fabrication attribute.
27
+ # Add R22 'exclude from BOM' fabrication attribute.
28
+ # Add R22 'Do not place' fabrication attribute.
29
+ # Update R2 fields.
30
+ # ^^^ this "update fields" message is too generic.
31
+
32
+ def version():
33
+ return '0.2.0'
34
+
35
+ def pcbnew_compatibility_error():
36
+ ver = pcbnew.GetMajorMinorPatchVersion()
37
+ schema = ver.split('.')
38
+ num = int(schema[0]) * 100 + int(schema[1])
39
+ return None if num >= 799 else f'This version of KiVar requires KiCad pcbnew version 7.99 or later.\nYou are using pcbnew version {ver}.'
40
+
41
+ def fp_to_uuid(fp):
42
+ return fp.m_Uuid.AsString()
43
+
44
+ def uuid_to_fp(board, uuid):
45
+ return board.GetItem(pcbnew.KIID(uuid)).Cast()
46
+
47
+ def set_fp_field(fp, field, value):
48
+ if not field.lower() in ['value', 'reference', 'footprint']: fp.SetField(field, value)
49
+
50
+ def legacy_expressions_found(fpdict):
51
+ found = 0
52
+ for uuid in fpdict:
53
+ for field in fpdict[uuid][Key.FIELDS]:
54
+ if field == 'KiVar.Rule': found += 1
55
+ return found
56
+
57
+ def build_fpdict(board):
58
+ fpdict = {}
59
+ for fp in board.GetFootprints():
60
+ uuid = fp_to_uuid(fp)
61
+ # TODO check if UUID exists in dict!
62
+ fpdict[uuid] = {}
63
+ fpdict[uuid][Key.REF] = fp.GetReferenceAsString()
64
+ fpdict[uuid][Key.FIELDS] = fp.GetFieldsText()
65
+ # TODO clean up, we currently store the value twice!
66
+ # the format is the same as for choice branches
67
+ fpdict[uuid][Key.VALUE] = fp.GetValue()
68
+ fpdict[uuid][Key.PROPS] = {}
69
+ fpdict[uuid][Key.PROPS][PropCode.BOM] = not fp.IsExcludedFromBOM()
70
+ fpdict[uuid][Key.PROPS][PropCode.POS] = not fp.IsExcludedFromPosFiles()
71
+ fpdict[uuid][Key.PROPS][PropCode.FIT] = not fp.IsDNP()
72
+ return fpdict
73
+
74
+ def store_fpdict(board, fpdict):
75
+ for uuid in fpdict:
76
+ fp = uuid_to_fp(board, uuid)
77
+ old_fp_value = fp.GetValue()
78
+ new_fp_value = fpdict[uuid][Key.VALUE]
79
+ if old_fp_value != new_fp_value:
80
+ fp.SetValue(new_fp_value)
81
+ for prop_code in fpdict[uuid][Key.PROPS]:
82
+ new_prop_value = fpdict[uuid][Key.PROPS][prop_code]
83
+ if new_prop_value is not None:
84
+ old_prop_value = None
85
+ if prop_code == PropCode.BOM: old_prop_value = not fp.IsExcludedFromBOM()
86
+ elif prop_code == PropCode.POS: old_prop_value = not fp.IsExcludedFromPosFiles()
87
+ elif prop_code == PropCode.FIT: old_prop_value = not fp.IsDNP()
88
+ if old_prop_value is not None:
89
+ if old_prop_value != new_prop_value:
90
+ if prop_code == PropCode.BOM: fp.SetExcludedFromBOM(not new_prop_value)
91
+ elif prop_code == PropCode.POS: fp.SetExcludedFromPosFiles(not new_prop_value)
92
+ elif prop_code == PropCode.FIT: fp.SetDNP(not new_prop_value)
93
+ old_fp_field_values = fp.GetFieldsText()
94
+ for field in fpdict[uuid][Key.FIELDS]:
95
+ old_fp_field_value = old_fp_field_values[field]
96
+ new_fp_field_value = fpdict[uuid][Key.FIELDS][field]
97
+ if old_fp_field_value != new_fp_field_value:
98
+ set_fp_field(fp, field, new_fp_field_value)
99
+ return fpdict
100
+
101
+ # TODO still required if we adopt KiCad 8 reporting style?
102
+ def bool_as_text(value):
103
+ return 'true' if value == True else 'false'
104
+
105
+ def natural_sort_key(string):
106
+ key = []
107
+ part = ''
108
+ for c in string:
109
+ if c.isdigit(): part += c
110
+ else:
111
+ if part:
112
+ key.append((0, int(part), ''))
113
+ part = ''
114
+ key.append((1, 0, c.lower()))
115
+ if part: key.append((0, int(part), ''))
116
+ return key
117
+
118
+ def escape_str(string):
119
+ result = ''
120
+ for c in string:
121
+ if c == '\\' or c == "'" or c == '"': result += '\\'
122
+ result += c
123
+ return result
124
+
125
+ def quote_str(string):
126
+ # we prefer single-quotes for output
127
+ if string == '': result = "''"
128
+ else:
129
+ if any(c in string for c in ', -\\()="\''):
130
+ q = '"' if string.count("'") > string.count('"') else "'"
131
+ result = q
132
+ for c in string:
133
+ if c == '\\' or c == q: result += '\\'
134
+ result += c
135
+ result += q
136
+ else: result = string
137
+ return result
138
+
139
+ # TODO use a cleaner way for keys
140
+ class Key:
141
+ DEFAULT = '*' # relevant for user interface, rest of keys only used internally
142
+ ASPECT = 'a'
143
+ BASE = 'b'
144
+ AUX = 'x'
145
+ VALUE = 'v'
146
+ PROPS = 'p'
147
+ REF = 'R'
148
+ FIELDS = 'F'
149
+
150
+ class PropCode: # all of these must be uppercase
151
+ FIT = 'F'
152
+ BOM = 'B'
153
+ POS = 'P'
154
+
155
+ class FieldID: # case-sensitive
156
+ BASE = 'Var'
157
+ ASPECT = 'Aspect'
158
+ class PropGroup:
159
+ ALL = '!'
160
+
161
+ def base_prop_codes(): return PropCode.FIT + PropCode.BOM + PropCode.POS
162
+
163
+ def supported_prop_codes(): return base_prop_codes() + PropGroup.ALL
164
+
165
+ def prop_state(props, prop):
166
+ return props[prop] if prop in props else None
167
+
168
+ def prop_attrib_name(prop_code):
169
+ if prop_code == PropCode.BOM: name = 'Exclude from bill of materials'
170
+ elif prop_code == PropCode.POS: name = 'Exclude from position files'
171
+ elif prop_code == PropCode.FIT: name = 'Do not populate'
172
+ else: name = '(unknown)'
173
+ return name
174
+
175
+ def prop_abbrev(prop_code):
176
+ if prop_code == PropCode.BOM: name = 'BoM'
177
+ elif prop_code == PropCode.POS: name = 'Pos'
178
+ elif prop_code == PropCode.FIT: name = 'Fit'
179
+ else: name = '(unknown)'
180
+ return name
181
+
182
+ def mismatches_fp_choice(fpdict_branch, vardict_choice_branch):
183
+ # TODO in returned mismatches, add mismatching fp and choice states
184
+ mismatches = []
185
+ choice_value = vardict_choice_branch[Key.VALUE]
186
+ fp_value = fpdict_branch[Key.VALUE]
187
+ if choice_value is not None and fp_value != choice_value:
188
+ mismatches.append('value')
189
+ for prop_code in base_prop_codes():
190
+ choice_prop = prop_state(vardict_choice_branch[Key.PROPS], prop_code)
191
+ fp_prop = prop_state(fpdict_branch[Key.PROPS], prop_code)
192
+ if choice_prop is not None and fp_prop is not None and choice_prop != fp_prop:
193
+ mismatches.append(f"prop '{prop_code}'")
194
+ return mismatches
195
+
196
+ def mismatches_fp_choice_aux(fp_fields, vardict_aux_branch, choice):
197
+ # TODO in returned mismatches, add mismatching fp and choice states
198
+ mismatches = []
199
+ for field in vardict_aux_branch:
200
+ choice_field_value = vardict_aux_branch[field][choice][Key.VALUE]
201
+ if choice_field_value is not None and choice_field_value != fp_fields[field]:
202
+ mismatches.append(f"field '{field}' for choice '{choice}'")
203
+ return mismatches
204
+
205
+ def detect_current_choices(fpdict, vardict):
206
+ # We start with the usual Choice dict filled with all possible Choices per Aspect and then
207
+ # eliminate all Choices whose values do not exactly match the actual FP values, fields or attributes.
208
+ # If exactly one Choice per Aspect remains, then we add this choice to the selection dict.
209
+ choices = get_choice_dict(vardict)
210
+ # Eliminate Choices not matching the actual FP values.
211
+ for uuid in vardict:
212
+ fp_ref = fpdict[uuid][Key.REF]
213
+ aspect = vardict[uuid][Key.ASPECT]
214
+ eliminate_choices = []
215
+ for choice in choices[aspect]:
216
+ eliminate = False
217
+ mismatches = mismatches_fp_choice(fpdict[uuid], vardict[uuid][Key.BASE][choice])
218
+ if mismatches: eliminate = True
219
+ mismatches = mismatches_fp_choice_aux(fpdict[uuid][Key.FIELDS], vardict[uuid][Key.AUX], choice)
220
+ if mismatches: eliminate = True
221
+ # defer elimination until after iteration
222
+ if eliminate: eliminate_choices.append(choice)
223
+ for choice in eliminate_choices: choices[aspect].remove(choice)
224
+ # Create a dict with candidate Choices. Report Choices only if they are unambiguous.
225
+ selection = {}
226
+ for aspect in choices:
227
+ if len(choices[aspect]) == 1: selection[aspect] = choices[aspect][0]
228
+ else: selection[aspect] = None
229
+ return selection
230
+
231
+ def apply_selection(fpdict, vardict, selection, dry_run = False):
232
+ changes = []
233
+ for uuid in vardict:
234
+ ref = fpdict[uuid][Key.REF]
235
+ aspect = vardict[uuid][Key.ASPECT]
236
+ if not aspect in selection: continue
237
+ selected_choice = selection[aspect]
238
+ if selected_choice is None: continue
239
+ choice_text = f'{quote_str(aspect)}={quote_str(selected_choice)}'
240
+ new_value = vardict[uuid][Key.BASE][selected_choice][Key.VALUE]
241
+ if new_value is not None:
242
+ old_value = fpdict[uuid][Key.VALUE]
243
+ if old_value != new_value:
244
+ changes.append([uuid, ref, f"Change {ref} value from '{escape_str(old_value)}' to '{escape_str(new_value)}' ({choice_text})."])
245
+ if not dry_run: fpdict[uuid][Key.VALUE] = new_value
246
+ for prop_code in base_prop_codes():
247
+ new_prop = vardict[uuid][Key.BASE][selected_choice][Key.PROPS][prop_code]
248
+ if new_prop is not None:
249
+ old_prop = fpdict[uuid][Key.PROPS][prop_code]
250
+ if old_prop is not None and old_prop != new_prop:
251
+ changes.append([uuid, ref, f"Change {ref} '{prop_attrib_name(prop_code)}' from '{bool_as_text(not old_prop)}' to '{bool_as_text(not new_prop)}' ({choice_text})."])
252
+ if not dry_run: fpdict[uuid][Key.PROPS][prop_code] = new_prop
253
+ for field in vardict[uuid][Key.AUX]:
254
+ new_field_value = vardict[uuid][Key.AUX][field][selected_choice][Key.VALUE]
255
+ if new_field_value is not None:
256
+ old_field_value = fpdict[uuid][Key.FIELDS][field]
257
+ if old_field_value != new_field_value:
258
+ changes.append([uuid, ref, f"Change {ref} field '{escape_str(field)}' from '{escape_str(old_field_value)}' to '{escape_str(new_field_value)}' ({choice_text})."])
259
+ if not dry_run: fpdict[uuid][Key.FIELDS][field] = new_field_value
260
+ return changes
261
+
262
+ def parse_prop_str(prop_str, old_prop_set={}):
263
+ prop_set = old_prop_set.copy() # TODO as a dict, this is pass-by-reference, so we could simply overwrite the old prop_set and return only errors
264
+ state = None
265
+ expect_prop = False
266
+ for prop_code in prop_str.upper():
267
+ if prop_code in '+-':
268
+ if expect_prop: raise ValueError(f"Property modifier must be followed by property identifier")
269
+ expect_prop = True
270
+ state = prop_code == '+'
271
+ else:
272
+ expect_prop = False
273
+ if state is None: raise ValueError(f"Undefined property modifier for identifier '{prop_code}'") # should never happen
274
+ if not prop_code in supported_prop_codes(): raise ValueError(f"Unsupported property identifier '{prop_code}'")
275
+ # TODO add a '?' symbol, which can be defined by the user
276
+ if prop_code == PropGroup.ALL:
277
+ for c in base_prop_codes(): prop_set[c] = state
278
+ else:
279
+ prop_set[prop_code] = state
280
+ if expect_prop: raise ValueError(f"Property specifier must not end with a modifier, but with an identifier")
281
+ return prop_set
282
+
283
+ def add_choice(vardict, uuid, raw_choice_name, raw_choice_def, field=None, all_aspect_choices=None):
284
+ """ Adds a choice set (base or aux rule definition) to the vardict. """
285
+ # TODO add unique error codes (for aux rules, add an offset), for unit testing (do not compare error strings).
286
+ # If field is passed, this handles aux rules, else base rules.
287
+ is_aux = field is not None
288
+ try:
289
+ raw_names = split_raw_str(raw_choice_name, ',', False)
290
+ except Exception as e:
291
+ return [f"Choice identifiers splitter error for identifier list '{raw_choice_name}': {str(e)}"] # TODO cook name?
292
+ try:
293
+ raw_args = split_raw_str(raw_choice_def, ' ', True)
294
+ except Exception as e:
295
+ return [f"Choice arguments splitter error for argument list '{raw_choice_def}': {str(e)}"]
296
+ choices = []
297
+ for choice_name in raw_names:
298
+ cooked_name = cook_raw_string(choice_name)
299
+ if cooked_name == '':
300
+ return ["Empty choice identifier"]
301
+ choices.append(cooked_name)
302
+ errors = []
303
+ values = []
304
+ prop_set = {}
305
+ for raw_arg in raw_args:
306
+ arg = cook_raw_string(raw_arg)
307
+ if raw_arg[0] in '-+': # not supposed to match if arg starts with \-, \+, '+' or '-'
308
+ if is_aux:
309
+ errors.append(f"No property specifier allowed in aux expression")
310
+ continue
311
+ try:
312
+ prop_set = parse_prop_str(arg, prop_set)
313
+ except Exception as error:
314
+ errors.append(f"Property specifier parser error: {str(error)}")
315
+ continue
316
+ else:
317
+ values.append(arg)
318
+ for choice in choices:
319
+ if is_aux:
320
+ if choice != Key.DEFAULT and not choice in all_aspect_choices:
321
+ errors.append(f"Undeclared choice identifier '{choice}'") # TODO print aspect in caller.### (in aspect {quote_str(aspect)})")
322
+ continue
323
+ if not field in vardict[uuid][Key.AUX]: vardict[uuid][Key.AUX][field] = {}
324
+ vardict_branch = vardict[uuid][Key.AUX][field]
325
+ else:
326
+ vardict_branch = vardict[uuid][Key.BASE]
327
+ if not choice in vardict_branch:
328
+ vardict_branch[choice] = {}
329
+ vardict_branch[choice][Key.VALUE] = None
330
+ vardict_branch[choice][Key.PROPS] = {}
331
+ if values:
332
+ value = ' '.join(values)
333
+ if vardict_branch[choice][Key.VALUE] is None:
334
+ vardict_branch[choice][Key.VALUE] = value
335
+ else:
336
+ errors.append(f"Illegal additional content '{value}' assignment for choice '{choice}'")
337
+ for prop_code in prop_set:
338
+ if not prop_code in vardict_branch[choice][Key.PROPS]:
339
+ vardict_branch[choice][Key.PROPS][prop_code] = None
340
+ if vardict_branch[choice][Key.PROPS][prop_code] is None:
341
+ vardict_branch[choice][Key.PROPS][prop_code] = prop_set[prop_code]
342
+ else:
343
+ errors.append(f"Illegal additional '{prop_abbrev(prop_code)}' property assignment for choice '{choice}'")
344
+ return errors
345
+
346
+ def finalize_vardict_branch(vardict_choice_branch, all_aspect_choices, is_aux = False):
347
+ """ Finalizes (flattens) a branch of the vardict (either base rules or aux rules). """
348
+ errors = []
349
+ # Flatten values
350
+ # TODO instead of counting, append (quoted) name of choice to two lists,
351
+ # then print their content (joined with comma) in the error messages!
352
+ choices_with_value_defined = 0
353
+ for choice in all_aspect_choices:
354
+ if not choice in vardict_choice_branch:
355
+ vardict_choice_branch[choice] = {}
356
+ vardict_choice_branch[choice][Key.VALUE] = None
357
+ vardict_choice_branch[choice][Key.PROPS] = {}
358
+ if Key.DEFAULT in vardict_choice_branch:
359
+ if vardict_choice_branch[choice][Key.VALUE] is None:
360
+ vardict_choice_branch[choice][Key.VALUE] = vardict_choice_branch[Key.DEFAULT][Key.VALUE]
361
+ if vardict_choice_branch[choice][Key.VALUE] is not None:
362
+ choices_with_value_defined += 1
363
+ if not (choices_with_value_defined == 0 or choices_with_value_defined == len(all_aspect_choices)):
364
+ errors.append(f"Mixed choices with defined ({choices_with_value_defined}x) and undefined ({len(all_aspect_choices) - choices_with_value_defined}x) content (either all or none must be defined)")
365
+ if not is_aux:
366
+ # Flatten properties
367
+ for prop_code in base_prop_codes():
368
+ default_prop_value = None
369
+ if Key.DEFAULT in vardict_choice_branch and prop_code in vardict_choice_branch[Key.DEFAULT][Key.PROPS] and vardict_choice_branch[Key.DEFAULT][Key.PROPS][prop_code] is not None:
370
+ # defined default prop value available, use it
371
+ default_prop_value = vardict_choice_branch[Key.DEFAULT][Key.PROPS][prop_code]
372
+ else:
373
+ # try to determine an implicit default value
374
+ choices_with_true = 0
375
+ choices_with_false = 0
376
+ for choice in all_aspect_choices:
377
+ if prop_code in vardict_choice_branch[choice][Key.PROPS] and vardict_choice_branch[choice][Key.PROPS][prop_code] is not None:
378
+ if vardict_choice_branch[choice][Key.PROPS][prop_code]: choices_with_true += 1
379
+ else: choices_with_false += 1
380
+ # if only set x-or clear is used in (specific) choices, the implicit default is the opposite.
381
+ if choices_with_false and not choices_with_true: default_prop_value = True
382
+ elif not choices_with_false and choices_with_true: default_prop_value = False
383
+ choices_with_prop_defined = 0
384
+ for choice in all_aspect_choices:
385
+ if not prop_code in vardict_choice_branch[choice][Key.PROPS] or vardict_choice_branch[choice][Key.PROPS][prop_code] is None:
386
+ vardict_choice_branch[choice][Key.PROPS][prop_code] = default_prop_value
387
+ if vardict_choice_branch[choice][Key.PROPS][prop_code] is not None:
388
+ choices_with_prop_defined += 1
389
+ if not (choices_with_prop_defined == 0 or choices_with_prop_defined == len(all_aspect_choices)):
390
+ errors.append(f"Mixed choices with defined ({choices_with_prop_defined}x) and undefined ({len(all_aspect_choices) - choices_with_prop_defined}x) {prop_abbrev(prop_code)} property ('{prop_code}') state (either all or none must be defined)")
391
+ # Remove default choice entries from branch
392
+ vardict_choice_branch.pop(Key.DEFAULT, None)
393
+ return errors
394
+
395
+ def parse_rule_str(rule_str):
396
+ errors = []
397
+ aspects = []
398
+ choice_sets = []
399
+ if rule_str is not None:
400
+ try:
401
+ rule_sections = split_raw_str(rule_str, ' ', True)
402
+ except Exception as error:
403
+ return [f'Combined format splitter: {str(error)}'], None, None
404
+ for section in rule_sections:
405
+ try:
406
+ name_list, content = split_parens(section)
407
+ except Exception as error:
408
+ errors.append(f'Choice expression splitter: {str(error)}')
409
+ continue
410
+ if content is None: # None means: no parens
411
+ # this is an aspect name. cook and store.
412
+ try:
413
+ cooked_name = cook_raw_string(name_list)
414
+ except Exception as error:
415
+ errors.append(f'Aspect identifier parser: {str(error)}')
416
+ continue
417
+ if cooked_name is None or cooked_name == '':
418
+ errors.append('Aspect identifier must not be empty')
419
+ continue
420
+ aspects.append(cooked_name)
421
+ else:
422
+ # this is a choice definition. leave name and content raw and store.
423
+ if name_list is None or name_list == '':
424
+ errors.append('Choice identifier list must not be empty')
425
+ continue
426
+ choice_sets.append([name_list, content])
427
+ return errors, aspects, choice_sets
428
+
429
+ def parse_rule_fields(fpdict_uuid_branch):
430
+ errors = []
431
+ aspect = None
432
+ base_rule_string = None
433
+ base_choice_sets = []
434
+ aux_rule_strings = []
435
+ aux_choice_sets = []
436
+ for fp_field in fpdict_uuid_branch[Key.FIELDS]:
437
+ value = fpdict_uuid_branch[Key.FIELDS][fp_field]
438
+ # field names that are not properly formatted are ignored and do not cause
439
+ # an error message. we don't want to misinterpret user's fields.
440
+ try: parts = split_raw_str(fp_field, '.', False)
441
+ except: continue
442
+ if len(parts) == 2 and parts[0] == FieldID.BASE and parts[1] == FieldID.ASPECT:
443
+ aspect = value
444
+ elif len(parts) == 1 and parts[0] == FieldID.BASE:
445
+ base_rule_string = value
446
+ elif len(parts) > 1 and parts[-1] == FieldID.BASE:
447
+ target_field = '.'.join(parts[0:-1])
448
+ if target_field in fpdict_uuid_branch[Key.FIELDS]:
449
+ aux_rule_strings.append([target_field, value])
450
+ else:
451
+ errors.append(f"Target field '{target_field}' does not exist for combined aux expression") # TODO escape field name?
452
+ continue
453
+ else:
454
+ try: prefix, name_list = split_parens(parts[-1])
455
+ except: continue
456
+ if prefix == FieldID.BASE:
457
+ try: parts_in_parens = split_raw_str(name_list, ' ', True)
458
+ except: continue
459
+ if len(parts_in_parens) > 1:
460
+ errors.append(f"Choice identifier list '{name_list}' contains illegal space character")
461
+ continue
462
+ if len(parts) == 1:
463
+ base_choice_sets.append([name_list, value])
464
+ else:
465
+ target_field = '.'.join(parts[0:-1])
466
+ if target_field in fpdict_uuid_branch[Key.FIELDS]:
467
+ aux_choice_sets.append([target_field, name_list, value])
468
+ else:
469
+ errors.append(f"Target field '{target_field}' does not exist for simple aux expression") # TODO escape field name?
470
+ continue
471
+ return errors, aspect, base_rule_string, base_choice_sets, aux_rule_strings, aux_choice_sets
472
+
473
+ def build_vardict(fpdict):
474
+ vardict = {}
475
+ errors = []
476
+ auxdict = {}
477
+ # Handle base rule
478
+ for uuid in fpdict:
479
+ ref = fpdict[uuid][Key.REF]
480
+ parse_errors, aspect, base_rule_string, base_choice_sets, aux_rule_strings, aux_choice_sets = parse_rule_fields(fpdict[uuid])
481
+ if parse_errors:
482
+ for parse_error in parse_errors: errors.append([uuid, ref, f"{ref}: Field parser: {parse_error}."])
483
+ continue
484
+ parse_errors, aspects, choice_sets = parse_rule_str(base_rule_string)
485
+ if parse_errors:
486
+ for parse_error in parse_errors: errors.append([uuid, ref, f"{ref}: Base scope expression parser: {parse_error}."])
487
+ continue
488
+ choice_sets.extend(base_choice_sets)
489
+ # TODO decide: shall we really use uncooked aspect name? we have the whole field content only for the pure value.
490
+ if len(aspects) > 1:
491
+ errors.append([uuid, ref, f"{ref}: Multiple aspect identifiers specified."])
492
+ continue
493
+ elif len(aspects) == 1:
494
+ # about to use the aspect name from the base rule ...
495
+ if aspect is not None and aspect != '':
496
+ # ... but there is already an aspect set via the aspect field
497
+ errors.append([uuid, ref, f"{ref}: Conflicting aspect identifier specification styles (combined base expression vs. aspect field)."])
498
+ continue
499
+ aspect = aspects[0]
500
+ if aspect is None or aspect == '':
501
+ if choice_sets:
502
+ errors.append([uuid, ref, f"{ref}: Missing aspect identifier for existing choice expressions."])
503
+ continue
504
+ if uuid in vardict:
505
+ errors.append([uuid, ref, f"{ref}: Multiple footprints with same UUID containing base expressions."])
506
+ continue
507
+ vardict[uuid] = {}
508
+ vardict[uuid][Key.ASPECT] = aspect
509
+ vardict[uuid][Key.BASE] = {}
510
+ vardict[uuid][Key.AUX] = {}
511
+ auxdict[uuid] = [aux_rule_strings, aux_choice_sets] # save for aux loop
512
+ for choice_name, choice_content in choice_sets:
513
+ add_errors = add_choice(vardict, uuid, choice_name, choice_content)
514
+ if add_errors:
515
+ for error in add_errors: errors.append([uuid, ref, f"{ref}: When adding aspect '{aspect}' choice list '{choice_name}' in base expression: {error}."])
516
+ break
517
+ # Handle aux rules
518
+ all_choices = get_choice_dict(vardict)
519
+ for uuid in auxdict:
520
+ aux_rule_strings, aux_choice_sets = auxdict[uuid]
521
+ ref = fpdict[uuid][Key.REF]
522
+ aspect = vardict[uuid][Key.ASPECT]
523
+ if aux_rule_strings and aspect is None:
524
+ errors.append([uuid, ref, f"{ref}: Combined aux expression(s) found, but missing base expression(s)."])
525
+ continue
526
+ valid = False
527
+ for field, rule_str in aux_rule_strings:
528
+ if rule_str is None or rule_str == '': continue
529
+ parse_errors, aspects, choice_sets = parse_rule_str(rule_str)
530
+ if parse_errors:
531
+ for parse_error in parse_errors: errors.append([uuid, ref, f"{ref}: Combined aux expression parser for target field '{field}': {parse_error}."])
532
+ continue
533
+ if aspects:
534
+ errors.append([uuid, ref, f"{ref}: Combined aux expression for target field '{field}' contains what looks like an aspect identifier (only allowed in combined base expressions)."])
535
+ continue
536
+ if field in vardict[uuid][Key.AUX]:
537
+ errors.append([uuid, ref, f"{ref}: Multiple aux expressions for target field '{field}'."]) # TODO wording
538
+ continue
539
+ for choice_name, choice_content in choice_sets:
540
+ add_errors = add_choice(vardict, uuid, choice_name, choice_content, field, all_choices[aspect])
541
+ if add_errors:
542
+ for error in add_errors: errors.append([uuid, ref, f"{ref}: Combined aux expression for aspect '{aspect}' choice list '{choice_name}' with target field '{field}': {error}."])
543
+ break
544
+ else:
545
+ valid = True
546
+ if not valid: continue
547
+ if aux_choice_sets and aspect is None:
548
+ errors.append([uuid, ref, f"{ref}: Simple aux expression(s) found, but missing base expression(s)."])
549
+ continue
550
+ valid = False
551
+ for field, choice_name, choice_content in aux_choice_sets:
552
+ add_errors = add_choice(vardict, uuid, choice_name, choice_content, field, all_choices[aspect])
553
+ if add_errors:
554
+ for error in add_errors: errors.append([uuid, ref, f"{ref}: Simple aux expression for aspect '{aspect}' choice list '{choice_name}' with target field '{field}': {error}."])
555
+ break
556
+ else:
557
+ valid = True
558
+ if not valid: continue
559
+ fin_errors = finalize_vardict_branch(vardict[uuid][Key.BASE], all_choices[aspect], is_aux=False)
560
+ if fin_errors:
561
+ # TODO cook and quote names in error message, refine wording
562
+ for e in fin_errors: errors.append([uuid, ref, f"{ref}: In base expression: {e}."])
563
+ continue
564
+ for field in vardict[uuid][Key.AUX]:
565
+ fin_errors = finalize_vardict_branch(vardict[uuid][Key.AUX][field], all_choices[aspect], is_aux=True)
566
+ if fin_errors:
567
+ # TODO cook and quote names in error message, refine wording
568
+ for e in fin_errors: errors.append([uuid, ref, f"{ref}: In aux expression for target field '{field}': {e}."])
569
+ continue
570
+ if not errors:
571
+ # Check for ambiguous choices (only if data is valid so far)
572
+ for aspect in sorted(all_choices, key=natural_sort_key):
573
+ choices = sorted(all_choices[aspect], key=natural_sort_key)
574
+ for choice_a in choices:
575
+ fpdict_a = deepcopy(fpdict)
576
+ apply_selection(fpdict_a, vardict, {aspect: choice_a})
577
+ ambiguous = []
578
+ for choice_b in choices:
579
+ if choice_a == choice_b:
580
+ if ambiguous: break
581
+ else: continue
582
+ fpdict_b = deepcopy(fpdict)
583
+ apply_selection(fpdict_b, vardict, {aspect: choice_b})
584
+ if fpdict_a == fpdict_b: ambiguous.append(f"'{escape_str(choice_b)}'")
585
+ if ambiguous:
586
+ errors.append([None, '0', f"Illegal ambiguity: Aspect '{escape_str(aspect)}' choice '{escape_str(choice_a)}' is identical with choice(s) {', '.join(ambiguous)}."])
587
+ break
588
+ if errors: vardict = {} # make sure an incomplete vardict cannot be used by the caller
589
+ return vardict, errors
590
+
591
+ def get_choice_dict(vardict):
592
+ choices = {}
593
+ for uuid in vardict:
594
+ aspect = vardict[uuid][Key.ASPECT]
595
+ if not aspect in choices: choices[aspect] = []
596
+ for choice in vardict[uuid][Key.BASE]:
597
+ # In case the input dict still contains temporary data (such as default data), ignore it.
598
+ if choice != Key.DEFAULT and not choice in choices[aspect]: choices[aspect].append(choice)
599
+ return choices
600
+
601
+ def split_parens(string):
602
+ item = []
603
+ outside = None
604
+ inside = None
605
+ escaped = False
606
+ quoted_s = False
607
+ quoted_d = False
608
+ parens = 0
609
+ end_expected = False
610
+ for c in string:
611
+ if end_expected: raise ValueError('String extends beyond closing parenthesis')
612
+ elif escaped:
613
+ escaped = False
614
+ item.append(c)
615
+ elif c == '\\':
616
+ escaped = True
617
+ item.append(c)
618
+ elif c == "'" and not quoted_d:
619
+ quoted_s = not quoted_s
620
+ item.append(c)
621
+ elif c == '"' and not quoted_s:
622
+ quoted_d = not quoted_d
623
+ item.append(c)
624
+ elif c == '(' and not (quoted_s or quoted_d):
625
+ parens += 1
626
+ if parens == 1:
627
+ outside = ''.join(item)
628
+ inside = '' # inside: no parens -> None, empty parens -> ''
629
+ item = []
630
+ else:
631
+ item.append(c)
632
+ elif c == ')' and not (quoted_s or quoted_d):
633
+ if parens > 0:
634
+ parens -= 1
635
+ if parens == 0:
636
+ inside = ''.join(item)
637
+ item = []
638
+ end_expected = True
639
+ else:
640
+ item.append(c)
641
+ else: raise ValueError('Unmatched closing parenthesis')
642
+ else:
643
+ item.append(c)
644
+ if parens: raise ValueError('Unmatched opening parenthesis')
645
+ if escaped: raise ValueError('Unterminated escape sequence (\\) at end of string')
646
+ if quoted_s: raise ValueError("Unmatched single-quote (') character in string")
647
+ if quoted_d: raise ValueError('Unmatched double-quote (") character in string')
648
+ if item: outside = ''.join(item)
649
+ return outside, inside
650
+
651
+ def split_raw_str(str, sep, multisep):
652
+ result = []
653
+ item = []
654
+ escaped = False
655
+ quoted_s = False
656
+ quoted_d = False
657
+ parens = 0
658
+ for c in str:
659
+ if escaped:
660
+ escaped = False
661
+ item.append(c)
662
+ elif c == '\\':
663
+ escaped = True
664
+ item.append(c)
665
+ elif c == "'" and not quoted_d:
666
+ quoted_s = not quoted_s
667
+ item.append(c)
668
+ elif c == '"' and not quoted_s:
669
+ quoted_d = not quoted_d
670
+ item.append(c)
671
+ elif c == '(' and not (quoted_s or quoted_d):
672
+ parens += 1
673
+ item.append(c)
674
+ elif c == ')' and not (quoted_s or quoted_d):
675
+ if parens > 0:
676
+ parens -= 1
677
+ else: raise ValueError('Unmatched closing parenthesis')
678
+ item.append(c)
679
+ elif c == sep and not (quoted_s or quoted_d) and parens == 0:
680
+ if not multisep or item:
681
+ result.append(''.join(item))
682
+ item = []
683
+ else:
684
+ item.append(c)
685
+ if parens: raise ValueError('Unmatched opening parenthesis')
686
+ if escaped: raise ValueError('Unterminated escape sequence (\\) at end of string')
687
+ if quoted_s: raise ValueError("Unmatched single-quote (') character in string")
688
+ if quoted_d: raise ValueError('Unmatched double-quote (") character in string')
689
+ if not multisep or item: result.append(''.join(item))
690
+ return result
691
+
692
+ def cook_raw_string(string):
693
+ result = []
694
+ escaped = False
695
+ quoted_s = False
696
+ quoted_d = False
697
+ for c in string:
698
+ if escaped:
699
+ result.append(c)
700
+ escaped = False
701
+ elif c == '\\':
702
+ escaped = True
703
+ elif c == "'" and not quoted_d:
704
+ quoted_s = not quoted_s
705
+ elif c == '"' and not quoted_s:
706
+ quoted_d = not quoted_d
707
+ else:
708
+ result.append(c)
709
+ if escaped: raise ValueError('Unterminated escape sequence (\\) at end of string')
710
+ if quoted_s: raise ValueError("Unmatched single-quote (') character in string")
711
+ if quoted_d: raise ValueError('Unmatched double-quote (") character in string')
712
+ return ''.join(result)
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import os
4
+ import sys
5
+ import argparse
6
+ import pcbnew
7
+ try:
8
+ from kivar_backend import Key, version, build_vardict, natural_sort_key, build_fpdict, store_fpdict, detect_current_choices, quote_str, prop_abbrev, base_prop_codes, cook_raw_string, escape_str, get_choice_dict, split_raw_str, apply_selection, pcbnew_compatibility_error
9
+ except ModuleNotFoundError:
10
+ from .kivar_backend import Key, version, build_vardict, natural_sort_key, build_fpdict, store_fpdict, detect_current_choices, quote_str, prop_abbrev, base_prop_codes, cook_raw_string, escape_str, get_choice_dict, split_raw_str, apply_selection, pcbnew_compatibility_error
11
+
12
+ # TODO for list, allow another structure: aspect -> component -> choice (in addition to current aspect -> choice -> component)
13
+ # TODO make use of verbose options, where applicable
14
+ # TODO add option to save to a different file (create a copy)
15
+ # TODO if output to terminal, use colors for highlighting aspects, choices, matches etc.
16
+ # (provide --no-color/-C option (or a different usual option name))
17
+ # also check all TODO notes buried in the code below!
18
+
19
+ # TODO have common help URL for both plugin and CLI? place in module?
20
+ def doc_vcs_ref():
21
+ return f'v{version()}'
22
+
23
+ def doc_base_url():
24
+ return f'https://doc.kivar.markh.de/{doc_vcs_ref()}/README.md'
25
+
26
+ def print_err(*args, file=sys.stderr, **kwargs):
27
+ print(*args, file=file, **kwargs)
28
+
29
+ def load_board(board_file):
30
+ if not os.path.isfile(board_file) or not os.access(board_file, os.R_OK):
31
+ print_err(f'Error: "{board_file}" is not a readable file.')
32
+ return None
33
+ return pcbnew.LoadBoard(board_file)
34
+
35
+ def save_board(board_file, board):
36
+ return pcbnew.SaveBoard(board_file, board)
37
+
38
+ def build_vardict_wrapper(fpdict):
39
+ vardict, errors = build_vardict(fpdict)
40
+ if len(errors) > 0:
41
+ print_err(f'Errors ({len(errors)}):')
42
+ for uuid, order, error in sorted(errors, key=lambda x: natural_sort_key(x[1])):
43
+ print_err(' ' + error)
44
+ return None
45
+ if len(vardict) == 0:
46
+ print_err(f'Error: No rule definitions found.')
47
+ print_err(f' Read {doc_base_url()}#usage for usage instructions.')
48
+ return None
49
+ return vardict
50
+
51
+ def list_command(board=None, long=False, prop_codes=False, detailed=False, selected=False, verbose=False):
52
+ b = load_board(board)
53
+ if b is None: return False
54
+ fpdict = build_fpdict(b)
55
+ vardict = build_vardict_wrapper(fpdict)
56
+ if vardict is None: return False
57
+ if selected: sel = detect_current_choices(fpdict, vardict)
58
+ ndict = {}
59
+ for uuid in vardict:
60
+ aspect = vardict[uuid][Key.ASPECT]
61
+ if not aspect in ndict:
62
+ ndict[aspect] = {}
63
+ for choice in vardict[uuid][Key.BASE]:
64
+ if not choice in ndict[aspect]:
65
+ ndict[aspect][choice] = {}
66
+ if detailed:
67
+ cmp_info = []
68
+ cmp_value = vardict[uuid][Key.BASE][choice][Key.VALUE]
69
+ if cmp_value is not None:
70
+ cmp_info.append(quote_str(cmp_value))
71
+ cmp_props = vardict[uuid][Key.BASE][choice][Key.PROPS]
72
+ for prop_code in base_prop_codes():
73
+ if prop_code in cmp_props and cmp_props[prop_code] is not None:
74
+ # TODO use colored output and use green and red here!
75
+ if prop_codes:
76
+ state_text = '+' if cmp_props[prop_code] else '-'
77
+ cmp_info.append(f'{state_text}{prop_code}')
78
+ else:
79
+ state_text = 'Yes' if cmp_props[prop_code] else 'No'
80
+ cmp_info.append(f'<{prop_abbrev(prop_code)}:{state_text}>')
81
+ ndict[aspect][choice][uuid] = ' '.join(cmp_info)
82
+ for aspect in sorted(ndict, key=natural_sort_key):
83
+ p_aspect = quote_str(aspect)
84
+ if long:
85
+ print(p_aspect)
86
+ else:
87
+ print(f'{p_aspect}:', end='')
88
+ for choice in sorted(ndict[aspect], key=natural_sort_key):
89
+ p_choice = quote_str(choice)
90
+ if selected and sel[aspect] == choice:
91
+ if long:
92
+ print(f' + {p_choice}')
93
+ else:
94
+ print(f' [{p_choice}]', end='')
95
+ else:
96
+ if long:
97
+ print(f' {p_choice}')
98
+ else:
99
+ print(f' {p_choice}', end='')
100
+ if detailed:
101
+ for uuid in sorted(ndict[aspect][choice], key=lambda x: natural_sort_key(fpdict[x][Key.REF])):
102
+ ref = fpdict[uuid][Key.REF]
103
+ print(f' {ref}: {ndict[aspect][choice][uuid]}')
104
+ for field in sorted(vardict[uuid][Key.AUX], key=natural_sort_key):
105
+ f = quote_str(field)
106
+ v = quote_str(vardict[uuid][Key.AUX][field][choice][Key.VALUE])
107
+ print(f" {f}: {v}")
108
+ # Future note: When options for aux rules are allowed, print them here
109
+ print()
110
+ return True
111
+
112
+ def state_command(board=None, all=False, query_aspect=None, verbose=False):
113
+ b = load_board(board)
114
+ if b is None: return False
115
+ fpdict = build_fpdict(b)
116
+ vardict = build_vardict_wrapper(fpdict)
117
+ if vardict is None: return False
118
+ sel = detect_current_choices(fpdict, vardict)
119
+ if query_aspect is not None:
120
+ if all:
121
+ print_err('Error: Options "--all" and "--query" are mutually exclusive.')
122
+ return False
123
+ if len(query_aspect) > 1:
124
+ print_err(f'Error: Only one query allowed.')
125
+ return False
126
+ q = cook_raw_string(query_aspect[0])
127
+ if q in sel:
128
+ p_choice = quote_str(sel[q])
129
+ print(p_choice)
130
+ else:
131
+ print_err(f"Error: No such aspect '{escape_str(q)}'.")
132
+ return False
133
+ else:
134
+ for aspect in sorted(sel, key=natural_sort_key):
135
+ choice = sel[aspect]
136
+ if all or choice is not None:
137
+ p_aspect = quote_str(aspect)
138
+ p_c = '' if choice is None else quote_str(choice)
139
+ print(f'{p_aspect}={p_c}')
140
+ return True
141
+
142
+ def check_command(board=None, verbose=False):
143
+ b = load_board(board)
144
+ if b is None: return False
145
+ fpdict = build_fpdict(b)
146
+ vardict = build_vardict_wrapper(fpdict)
147
+ if vardict is None: return False
148
+ sel = detect_current_choices(fpdict, vardict)
149
+ failed = []
150
+ for aspect in sorted(sel, key=natural_sort_key):
151
+ choice = sel[aspect]
152
+ # TODO if verbose: print result of each check here!
153
+ if choice is None:
154
+ failed.append(aspect)
155
+ if len(failed) > 0:
156
+ print_err(f'Check failed. No matching choice found for {len(failed)} (of {len(sel)}) aspect(s):')
157
+ for aspect in failed:
158
+ p_aspect = quote_str(aspect)
159
+ print_err(f' {p_aspect}')
160
+ return False
161
+ else:
162
+ print_err(f'Check passed. All {len(sel)} aspect(s) have a matching choice.')
163
+ return True
164
+
165
+ def set_command(board=None, assign=None, dry_run=False, verbose=False):
166
+ # TODO how to handle errors? ignore failing assignments, or abort?
167
+ # collect errors, then print & return?
168
+ if assign is None:
169
+ print('Error: No assignments passed.')
170
+ return False
171
+ b = load_board(board)
172
+ if b is None: return False
173
+ fpdict = build_fpdict(b)
174
+ vardict = build_vardict_wrapper(fpdict)
175
+ if vardict is None: return False
176
+ sel = detect_current_choices(fpdict, vardict)
177
+ choice_dict = get_choice_dict(vardict)
178
+ for asmt in assign:
179
+ l = split_raw_str(asmt, '=', False) # TODO: test split
180
+ if len(l) == 2:
181
+ aspect = cook_raw_string(l[0])
182
+ choice = cook_raw_string(l[1])
183
+ p_aspect = escape_str(aspect)
184
+ p_choice = escape_str(choice)
185
+ p_asmt = f'{p_aspect}={p_choice}'
186
+ if aspect in sel:
187
+ if choice in choice_dict[aspect]:
188
+ sel[aspect] = choice
189
+ else:
190
+ print(f"Error: Assignment '{p_asmt}' failed: No such choice '{p_choice}' for aspect '{p_aspect}'.")
191
+ else:
192
+ print(f"Error: Assignment '{p_asmt}' failed: No such aspect '{p_aspect}'.")
193
+ else:
194
+ print(f"Error: Assignment '{asmt}' failed: Format error: Wrong number of '=' separators.")
195
+ changes = apply_selection(fpdict, vardict, sel, dry_run=False)
196
+ if len(changes) > 0:
197
+ if dry_run or verbose:
198
+ print_err(f'Changes ({len(changes)}):')
199
+ for uuid, order, change in sorted(changes, key=lambda x: natural_sort_key(x[1])):
200
+ print(' ' + change)
201
+ if not dry_run:
202
+ store_fpdict(b, fpdict)
203
+ if save_board(board, b):
204
+ if verbose:
205
+ print(f'Board saved to file "{board}".')
206
+ else:
207
+ print_err(f'Error: Failed to save board to file "{board}".')
208
+ return False
209
+ else:
210
+ if verbose or dry_run:
211
+ print('No changes.')
212
+ return True
213
+
214
+ def main():
215
+ parser = argparse.ArgumentParser(description=f"KiVar Command Line Interface ({version()})")
216
+ # parser.add_argument("--verbose", "-v", action="store_true", help="long output style (effect depends on command)")
217
+ parser.add_argument("-V", "--version", action="store_true", help="print version information and exit")
218
+ subparsers = parser.add_subparsers(dest="command")
219
+
220
+ list_parser = subparsers.add_parser("list", help="list all available aspects and choices")
221
+ list_parser.add_argument("-s", "--selection", action="store_true", help="mark currently matching choices")
222
+ list_parser.add_argument("-l", "--long", action="store_true", help="long output style")
223
+ list_parser.add_argument("-d", "--detailed", action="store_true", help="show component assignments (implies --long)")
224
+ list_parser.add_argument("-c", "--prop-codes", action="store_true", help="display property codes (implies --detailed)")
225
+ # list_parser.add_argument("-v", "--verbose", action="store_true", help="verbose output")
226
+ list_parser.add_argument("board", help="KiCad PCB file name")
227
+
228
+ state_parser = subparsers.add_parser("state", help="show currently matching choice for each aspect")
229
+ state_parser.add_argument("-Q", "--query", action="append", help="query aspect for matching choice", metavar="aspect")
230
+ state_parser.add_argument("-a", "--all", action="store_true", help="list all aspects (default: list only aspects with matching choice)")
231
+ # state_parser.add_argument("-v", "--verbose", action="store_true", help="verbose output")
232
+ state_parser.add_argument("board", help="KiCad PCB file name")
233
+
234
+ check_parser = subparsers.add_parser("check", help="check all aspects for matching choices, exit with error if check fails")
235
+ # check_parser.add_argument("--verbose", "-v", action="store_true", help="verbose output")
236
+ # TODO add option to check only for dedicated list of aspects
237
+ check_parser.add_argument("board", help="KiCad PCB file name")
238
+
239
+ set_parser = subparsers.add_parser("set", help="assign choices to aspects")
240
+ set_parser.add_argument("-A", "--assign", action="append", help="assign choice to aspect ('str' format: \"aspect=choice\")", metavar="str")
241
+ set_parser.add_argument("-D", "--dry-run", action="store_true", help="only print assignments, do not really perform/save them")
242
+ set_parser.add_argument("-v", "--verbose", action="store_true", help="verbose output")
243
+ set_parser.add_argument("board", help="KiCad PCB file name")
244
+
245
+ args = parser.parse_args()
246
+ exitcode = 0
247
+
248
+ if args.version:
249
+ print(f"KiVar {version()}")
250
+ exitcode = 0
251
+ else:
252
+ # TODO do this check only prior to executing corresponding commands?
253
+ compatibility_problem = pcbnew_compatibility_error()
254
+ if compatibility_problem is not None:
255
+ print(f'Compatibility error:\n{compatibility_problem}')
256
+ exitcode = 3
257
+ else:
258
+ cmd = args.command
259
+ if cmd == "list":
260
+ if args.prop_codes: args.detailed = True
261
+ if args.detailed: args.long = True
262
+ if not list_command(board=args.board, long=args.long, prop_codes=args.prop_codes, detailed=args.detailed, selected=args.selection): exitcode = 1
263
+ elif cmd == "state":
264
+ if not state_command(board=args.board, all=args.all, query_aspect=args.query): exitcode = 1
265
+ elif cmd == "check":
266
+ if not check_command(board=args.board): exitcode = 1
267
+ elif cmd == "set":
268
+ if not set_command(board=args.board, assign=args.assign, dry_run=args.dry_run, verbose=args.verbose): exitcode = 1
269
+ else:
270
+ print_err(f'This is the KiVar CLI, version {version()}.')
271
+ parser.print_usage()
272
+ exitcode = 2
273
+
274
+ return exitcode
275
+
276
+ if __name__ == "__main__":
277
+ sys.exit(main())
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.1
2
+ Name: kivar
3
+ Version: 0.2.0
4
+ Summary: PCB Assembly Variants for KiCad
5
+ Home-page: https://github.com/markh-de/KiVar
6
+ Author: Mark Hämmerling
7
+ Author-email: dev@markh.de
8
+ License: MIT
9
+ Platform: UNKNOWN
10
+ Description-Content-Type: text/markdown
11
+
12
+ # KiVar − PCB Assembly Variants for KiCad
13
+
14
+ ## Introduction
15
+
16
+ KiVar is a tool for **KiCad PCB Assembly Variant selection**, provided as platform-independent
17
+
18
+ * **Command Line Application** (this package) and
19
+ * **KiCad Action Plugin** (available in KiCad PCM).
20
+
21
+ PCB component variation rules are defined in component (i.e. symbol or footprint) fields. This allows for the complete variant configuration to be contained in the schematic and board files without requiring external data from outside the native KiCad design files.
22
+
23
+ The name _KiVar_ (for _KiCad Variants_, obviously) can also be read as an acronym for _**Ki**Cad **V**ariation **a**ssignment **r**ules_.
24
+
25
+ ## Features
26
+
27
+ KiVar assigns PCB component **values**, **field content** and **attributes** (such as _Do not populate_, _Not in position files_, _Not in BoM_) according to variation rules specified in footprint fields. When applying those rules, components are modified _in place_, allowing for immediate update of the PCB design as well as the 3D view and enabling compatibility with _any_ exporter.
28
+
29
+ Back-propagation of modified component data from the PCB to the schematic can be done in an extra step.
30
+
31
+ ## What to Expect
32
+
33
+ Example usage of the **KiVar Command Line Interface app**:
34
+
35
+ ```
36
+ $ kivar list --selection kivar-demo.kicad_pcb
37
+ BOOT_SRC: [EMMC] JP NAND SD
38
+ EEPROM_ADDR: 0x54 [0x55]
39
+ I_LED_MA: 10 20 30 40 50 60 70 80 90 [100] 110 120 130 140 150 JP
40
+ IOEXP_TYPE/ADDR: 9535/0x20 [9535/0x24] 9539/0x74
41
+ ISL91127: [IRAZ] IRNZ
42
+ UVLO_LO/HI: 2.41V/3.40V [3.15V/3.57V]
43
+
44
+ $ kivar set --assign 'I_LED_MA=60' --assign 'BOOT_SRC=NAND' --verbose kivar-demo.kicad_pcb
45
+ Changes (16):
46
+ Change R9 'Do not populate' from 'false' to 'true' (BOOT_SRC=NAND).
47
+ Change R9 'Exclude from bill of materials' from 'false' to 'true' (BOOT_SRC=NAND).
48
+ Change R9 'Exclude from position files' from 'false' to 'true' (BOOT_SRC=NAND).
49
+ Change R9 field 'ChoiceText' from 'SoM eMMC' to 'SoM NAND' (BOOT_SRC=NAND).
50
+ Change R10 'Do not populate' from 'true' to 'false' (BOOT_SRC=NAND).
51
+ Change R10 'Exclude from bill of materials' from 'true' to 'false' (BOOT_SRC=NAND).
52
+ Change R10 'Exclude from position files' from 'true' to 'false' (BOOT_SRC=NAND).
53
+ Change R11 'Do not populate' from 'false' to 'true' (BOOT_SRC=NAND).
54
+ Change R11 'Exclude from bill of materials' from 'false' to 'true' (BOOT_SRC=NAND).
55
+ Change R11 'Exclude from position files' from 'false' to 'true' (BOOT_SRC=NAND).
56
+ Change R21 'Do not populate' from 'false' to 'true' (I_LED_MA=60).
57
+ Change R21 'Exclude from bill of materials' from 'false' to 'true' (I_LED_MA=60).
58
+ Change R21 'Exclude from position files' from 'false' to 'true' (I_LED_MA=60).
59
+ Change R22 'Do not populate' from 'true' to 'false' (I_LED_MA=60).
60
+ Change R22 'Exclude from bill of materials' from 'true' to 'false' (I_LED_MA=60).
61
+ Change R22 'Exclude from position files' from 'true' to 'false' (I_LED_MA=60).
62
+ Board saved to file "kivar-demo.kicad_pcb".
63
+
64
+ $ kivar list --selection kivar-demo.kicad_pcb
65
+ BOOT_SRC: EMMC JP [NAND] SD
66
+ EEPROM_ADDR: 0x54 [0x55]
67
+ I_LED_MA: 10 20 30 40 50 [60] 70 80 90 100 110 120 130 140 150 JP
68
+ IOEXP_TYPE/ADDR: 9535/0x20 [9535/0x24] 9539/0x74
69
+ ISL91127: [IRAZ] IRNZ
70
+ UVLO_LO/HI: 2.41V/3.40V [3.15V/3.57V]
71
+ ```
72
+
73
+ ## Concepts
74
+
75
+ Key concepts of KiVar are:
76
+
77
+ * Designs may contain **multiple** independent variation **aspects** (i.e. dimensions or degrees of freedom).
78
+ * Variation rules are **fully contained** in component fields of native design files (no external configuration files) and **portable** (i.e. copying components to another design keeps their variation specification intact).
79
+ * Component values and attributes are modified **in place**, enabling compatibility with all exporters that work on the actual component data.
80
+ * **No external state information** is stored; currently matching variation choices are detected automatically.
81
+
82
+ ## Project Page
83
+
84
+ For the usage manual and a demo project check out the [KiVar Project Page](https://github.com/markh-de/KiVar).
85
+
86
+
@@ -0,0 +1,10 @@
1
+ README.md
2
+ setup.py
3
+ kivar/__init__.py
4
+ kivar/kivar_backend.py
5
+ kivar/kivar_cli.py
6
+ kivar.egg-info/PKG-INFO
7
+ kivar.egg-info/SOURCES.txt
8
+ kivar.egg-info/dependency_links.txt
9
+ kivar.egg-info/entry_points.txt
10
+ kivar.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ kivar = kivar:main
3
+
@@ -0,0 +1 @@
1
+ kivar
kivar-0.2.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
kivar-0.2.0/setup.py ADDED
@@ -0,0 +1,24 @@
1
+ from setuptools import setup
2
+ import os
3
+
4
+ with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'README.md'), 'r') as f:
5
+ long_descr = f.read()
6
+
7
+ setup(
8
+ name='kivar',
9
+ description='PCB Assembly Variants for KiCad',
10
+ long_description=long_descr,
11
+ long_description_content_type='text/markdown',
12
+ license='MIT',
13
+ url='https://github.com/markh-de/KiVar',
14
+ author='Mark Hämmerling',
15
+ author_email='dev@markh.de',
16
+ version="0.2.0",
17
+ packages=["kivar"],
18
+ install_requires=[],
19
+ entry_points={
20
+ "console_scripts": [
21
+ "kivar = kivar:main"
22
+ ]
23
+ }
24
+ )