• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

INTI-CMNB / KiBot / 24566031350

17 Apr 2026 12:49PM UTC coverage: 88.451% (-0.001%) from 88.452%
24566031350

push

github

set-soft
[Download Datasheet][Added] Check for curl output

Curl is robust, but some sites detects it, so we must remove HTML or
empty files and try with requests.

13 of 13 new or added lines in 1 file covered. (100.0%)

80 existing lines in 3 files now uncovered.

32763 of 37041 relevant lines covered (88.45%)

8.23 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.47
/kibot/kiplot.py
1
# -*- coding: utf-8 -*-
2
# Copyright (c) 2020-2026 Salvador E. Tropea
3
# Copyright (c) 2020-2026 Instituto Nacional de TecnologĂ­a Industrial
4
# Copyright (c) 2018 John Beard
5
# License: AGPL-3.0
6
# Project: KiBot (formerly KiPlot)
7
# Adapted from: https://github.com/johnbeard/kiplot
8
"""
7✔
9
Main KiBot code
10
"""
11
from copy import deepcopy
16✔
12
from collections import OrderedDict
16✔
13
import gzip
16✔
14
import json
16✔
15
import os
16✔
16
import re
16✔
17
from sys import path as sys_path
16✔
18
from shutil import which, copy2
16✔
19
from subprocess import run, PIPE, STDOUT, Popen, CalledProcessError
16✔
20
from glob import glob
16✔
21
from importlib.util import spec_from_file_location, module_from_spec
16✔
22

23
from .bom.columnlist import ColumnList
16✔
24
from .gs import GS
16✔
25
from .registrable import RegOutput
16✔
26
from .misc import (PLOT_ERROR, CORRUPTED_PCB, EXIT_BAD_ARGS, CORRUPTED_SCH, version_str2tuple,
16✔
27
                   EXIT_BAD_CONFIG, WRONG_INSTALL, UI_SMD, UI_VIRTUAL, TRY_INSTALL_CHECK, MOD_SMD, MOD_THROUGH_HOLE,
28
                   MOD_VIRTUAL, W_PCBNOSCH, W_NONEEDSKIP, W_WRONGCHAR, name2make, W_TIMEOUT, W_KIAUTO, W_VARSCH,
29
                   NO_SCH_FILE, NO_PCB_FILE, W_VARPCB, NO_YAML_MODULE, WRONG_ARGUMENTS, FAILED_EXECUTE, W_VALMISMATCH,
30
                   MOD_EXCLUDE_FROM_POS_FILES, MOD_EXCLUDE_FROM_BOM, MOD_BOARD_ONLY, hide_stderr, W_MAXDEPTH, DONT_STOP,
31
                   W_BADREF, try_decode_utf8, MISSING_FILES, KICAD_VERSION_9_0_1, W_NOUUIDMAP, W_SILLY)
32
from .error import PlotError, KiPlotConfigurationError, config_error, KiPlotError
16✔
33
from .config_reader import CfgYamlReader
16✔
34
from .pre_base import BasePreFlight
16✔
35
from .dep_downloader import register_deps
16✔
36
import kibot.dep_downloader as dep_downloader
16✔
37
from .kicad.v5_sch import Schematic, SchFileError, SchError, SchematicField
16✔
38
from .kicad.v6_sch import SchematicV6, SchematicComponentV6
16✔
39
from .kicad.config import KiConfError, KiConf, expand_env
16✔
40
from . import log
16✔
41
INTERNAL_FIELDS = {'reference', 'value', 'footprint', 'datasheet', 'description'}
16✔
42

43
logger = log.get_logger()
16✔
44
# Cache to avoid running external many times to check their versions
45
script_versions = {}
16✔
46
actions_loaded = False
16✔
47
needed_imports = {}
16✔
48

49
try:
16✔
50
    import yaml
16✔
51
except ImportError:
×
52
    log.init()
×
53
    GS.exit_with_error(['No yaml module for Python, install python3-yaml', TRY_INSTALL_CHECK], NO_YAML_MODULE)
×
54

55

56
def cased_path(path):
16✔
57
    r = glob(re.sub(r'([^:/\\])(?=[/\\]|$)|\[', r'[\g<0>]', path))
16✔
58
    return r and r[0] or path
16✔
59

60

61
def try_register_deps(mod, name):
16✔
62
    if mod.__doc__:
16✔
63
        try:
16✔
64
            data = yaml.safe_load(mod.__doc__)
16✔
65
        except yaml.YAMLError as e:
×
66
            config_error([f'While loading plug-in `{name}`:', "Error loading YAML "+str(e)])
×
67
        register_deps(name, data)
16✔
68

69

70
def _import(name, path):
16✔
71
    # Python 3.4+ import mechanism
72
    spec = spec_from_file_location("kibot."+name, path)
16✔
73
    mod = module_from_spec(spec)
16✔
74
    try:
16✔
75
        spec.loader.exec_module(mod)
16✔
76
    except ImportError as e:
1✔
77
        GS.exit_with_error(('Unable to import plug-ins: '+str(e),
1✔
78
                            'Make sure you used `--no-compile` if you used pip for installation',
79
                            'Python path: '+str(sys_path)), WRONG_INSTALL)
80
    try_register_deps(mod, name)
16✔
81

82

83
def _load_actions(path, load_internals=False, progress=None):
16✔
84
    logger.debug("Importing from "+path)
16✔
85
    lst = glob(os.path.join(path, 'out_*.py')) + glob(os.path.join(path, 'pre_*.py'))
16✔
86
    lst += glob(os.path.join(path, 'var_*.py')) + glob(os.path.join(path, 'fil_*.py'))
16✔
87
    if load_internals:
16✔
88
        lst += [os.path.join(path, 'globals.py')]
16✔
89
    for p in sorted(lst):
16✔
90
        name = os.path.splitext(os.path.basename(p))[0]
16✔
91
        msg = "Importing "+name
16✔
92
        logger.debug('- '+msg)
16✔
93
        if progress:
16✔
94
            progress(msg)
1✔
95
        _import(name, p)
16✔
96

97

98
def load_actions(progress=None):
16✔
99
    """ Load all the available outputs and preflights """
100
    global actions_loaded
101
    if actions_loaded:
13✔
102
        return
5✔
103
    actions_loaded = True
13✔
104
    try_register_deps(dep_downloader, 'global')
13✔
105
    from kibot.mcpyrate import activate
13✔
106
    # activate.activate()
107
    _load_actions(os.path.abspath(os.path.dirname(__file__)), True, progress)
13✔
108
    home = os.environ.get('HOME')
13✔
109
    if home:
13✔
110
        dir = os.path.join(home, '.config', 'kiplot', 'plugins')
13✔
111
        if os.path.isdir(dir):
13✔
112
            _load_actions(dir)
4✔
113
        dir = os.path.join(home, '.config', 'kibot', 'plugins')
13✔
114
        if os.path.isdir(dir):
13✔
115
            _load_actions(dir)
4✔
116
    # de_activate in old mcpy
117
    if 'deactivate' in activate.__dict__:
13✔
118
        logger.debug('Deactivating macros')
13✔
119
        activate.deactivate()
13✔
120

121

122
def extract_errors(text):
13✔
123
    in_error = in_warning = False
13✔
124
    msg = ''
13✔
125
    for line in text.split('\n'):
13✔
126
        line += '\n'
13✔
127
        if line[0] == ' ' and (in_error or in_warning):
13✔
128
            msg += line
8✔
129
        else:
130
            if in_error:
13✔
131
                in_error = False
11✔
132
                logger.error(msg.rstrip())
11✔
133
            elif in_warning:
13✔
134
                in_warning = False
12✔
135
                logger.warning(W_KIAUTO+msg.rstrip())
12✔
136
        if line.startswith('ERROR:'):
13✔
137
            in_error = True
11✔
138
            msg = line[6:]
11✔
139
        elif line.startswith('WARNING:'):
13✔
140
            in_warning = True
12✔
141
            msg = line[8:]
12✔
142
    if in_error:
13✔
143
        in_error = False
144
        logger.error(msg.rstrip())
145
    elif in_warning:
13✔
146
        in_warning = False
147
        logger.warning(W_KIAUTO+msg.rstrip())
148

149

150
def debug_output(res):
13✔
151
    if res:
13✔
152
        logger.debug('- Output from command: '+res)
13✔
153

154

155
def _run_command(command, change_to):
13✔
156
    return run(command, check=True, stdout=PIPE, stderr=STDOUT, cwd=change_to)
13✔
157

158

159
def run_command(command, change_to=None, just_raise=False, use_x11=False, err_msg=None, err_lvl=FAILED_EXECUTE,
13✔
160
                force_en=False):
161
    logger.debug('- Executing: '+GS.pasteable_cmd(command))
13✔
162
    if change_to is not None:
13✔
163
        logger.debug('- CWD: '+change_to)
12✔
164
    old_lang = None
13✔
165
    if force_en:
13✔
166
        old_lang = os.environ.get('LANG')
12✔
167
        if old_lang:
12✔
168
            os.environ['LANG'] = 'en'
169
    try:
13✔
170
        if use_x11 and not GS.on_windows:
13✔
171
            logger.debug('Using Xvfb to run the command')
8✔
172
            from xvfbwrapper import Xvfb
8✔
173
            with Xvfb(width=640, height=480, colordepth=24):
8✔
174
                res = _run_command(command, change_to)
8✔
175
        else:
176
            res = _run_command(command, change_to)
13✔
177
    except CalledProcessError as e:
9✔
178
        if just_raise:
9✔
179
            raise
8✔
180
        if err_msg is not None:
5✔
181
            err_msg = err_msg.format(ret=e.returncode)
5✔
182
        GS.exit_with_error(err_msg, err_lvl, e)
5✔
183
    finally:
184
        if old_lang:
13✔
185
            os.environ['LANG'] = old_lang
5✔
186

187
    msg = try_decode_utf8(res.stdout, 'output from command', logger)
13✔
188
    debug_output(msg)
13✔
189
    return msg.rstrip()
13✔
190

191

192
def exec_with_retry(cmd, exit_with=None):
13✔
193
    cmd_str = GS.pasteable_cmd(cmd)
13✔
194
    logger.debug('Executing: '+cmd_str)
13✔
195
    if GS.debug_level > 2:
13✔
196
        logger.debug('Command line: '+str(cmd))
8✔
197
    retry = 2
13✔
198
    while retry:
13✔
199
        result = run(cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True)
13✔
200
        ret = result.returncode
13✔
201
        retry -= 1
13✔
202
        if ret != 16 and (ret > 0 and ret < 128 and retry):
13✔
203
            # 16 is KiCad crash
204
            logger.debug('Failed with error {}, retrying ...'.format(ret))
6✔
205
        else:
206
            extract_errors(result.stderr)
13✔
207
            err = '> '+result.stderr.replace('\n', '\n> ')
13✔
208
            logger.debug('Output from command:\n'+err)
13✔
209
            if 'Timed out' in err:
13✔
210
                logger.warning(W_TIMEOUT+'Time out detected, on slow machines or complex projects try:')
211
                logger.warning(W_TIMEOUT+'`kiauto_time_out_scale` and/or `kiauto_wait_start` global options')
212
            if exit_with is not None and ret:
13✔
213
                GS.exit_with_error(cmd[0]+' returned '+str(ret), exit_with)
3✔
214
            return ret
13✔
215

216

217
def load_board(pcb_file=None, forced=False):
13✔
218
    if GS.board is not None and not forced:
13✔
219
        # Already loaded
220
        return GS.board
13✔
221
    import pcbnew
13✔
222
    if not pcb_file:
13✔
223
        GS.check_pcb()
13✔
224
        pcb_file = GS.pcb_file
13✔
225
    try:
13✔
226
        with hide_stderr():
13✔
227
            board = pcbnew.LoadBoard(pcb_file)
13✔
228
        if board is None:
13✔
229
            # KiCad 9 doesn't stop and returns None
230
            GS.exit_with_error(f'Error loading PCB file ({pcb_file}). Corrupted?', CORRUPTED_PCB)
4✔
231
            raise OSError
232
        if GS.global_work_layer and board.GetLayerID(GS.global_work_layer) < 0:
13✔
233
            raise KiPlotConfigurationError(f"Unknown layer used for the global `work_layer` option"
234
                                           f" (`{GS.global_work_layer}`)")
235

236
        if (GS.global_invalidate_pcb_text_cache == 'yes' or GS.global_update_pcb_text_cache == 'yes') and GS.ki6:
13✔
237
            # Workaround for unexpected KiCad behavior:
238
            # https://gitlab.com/kicad/code/kicad/-/issues/14360
239
            logger.debug('Current PCB text variables cache: {}'.format(board.GetProperties().items()))
8✔
240

241
            props = pcbnew.MAP_STRING_STRING()
8✔
242

243
            if GS.global_update_pcb_text_cache == 'yes':
8✔
244
                for k, v in GS.load_pro_variables().items():
245
                    props[k] = v
246
                logger.debug('Updating cached text variables')
247
            else:
248
                logger.debug('Removing cached text variables')
8✔
249
            board.SetProperties(props)
8✔
250
            # Save the PCB, so external tools also gets the reset, i.e. panelize, see #652
251
            GS.save_pcb(pcb_file, board)
8✔
252
        if BasePreFlight.get_option('check_zone_fills'):
13✔
253
            GS.fill_zones(board)
8✔
254
        if GS.global_units and GS.ki6:
13✔
255
            # In KiCad 6 "dimensions" has units.
256
            # The default value is DIM_UNITS_MODE_AUTOMATIC.
257
            # But this has a meaning only in the GUI where you have default units.
258
            # So now we have global.units and here we patch the board.
259
            if GS.kicad_version_n < KICAD_VERSION_9_0_1:
4✔
260
                UNIT_NAME_TO_INDEX = {'millimeters': pcbnew.DIM_UNITS_MODE_MILLIMETRES,
2✔
261
                                      'inches': pcbnew.DIM_UNITS_MODE_INCHES,
262
                                      'mils': pcbnew.DIM_UNITS_MODE_MILS}
263
            else:
264
                UNIT_NAME_TO_INDEX = {'millimeters': pcbnew.DIM_UNITS_MODE_MM,
2✔
265
                                      'inches': pcbnew.DIM_UNITS_MODE_INCH,
266
                                      'mils': pcbnew.DIM_UNITS_MODE_MILS}
267
            forced_units = UNIT_NAME_TO_INDEX[GS.global_units]
4✔
268
            for dr in board.GetDrawings():
4✔
269
                if dr.GetClass().startswith('PCB_DIM_') and dr.GetUnitsMode() == pcbnew.DIM_UNITS_MODE_AUTOMATIC:
4✔
270
                    dr.SetUnitsMode(forced_units)
4✔
271
                    dr.Update()
4✔
272
        if GS.ki8 and not GS.ki9:
13✔
273
            # KiCad 8.0.2 crazyness: hidden text affects scaling, even when not plotted
274
            # So a PRL can affect the plot mechanism
275
            # https://gitlab.com/kicad/code/kicad/-/issues/17958
276
            # https://gitlab.com/kicad/code/kicad/-/commit/8184ed64e732ed0812831a13ebc04bd12e8d1d19
277
            board.SetElementVisibility(pcbnew.LAYER_HIDDEN_TEXT, False)
1✔
278
        GS.board = board
13✔
279
    except OSError as e:
8✔
280
        GS.exit_with_error(['Error loading PCB file. Corrupted?', str(e)], CORRUPTED_PCB)
4✔
281
    assert board is not None
13✔
282
    logger.debug("Board loaded")
13✔
283
    return board
13✔
284

285

286
def ki_conf_error(e):
13✔
287
    GS.exit_with_error(('At line {} of `{}`: {}'.format(e.line, e.file, e.msg),
288
                        'Line content: `{}`'.format(e.code.rstrip())), EXIT_BAD_CONFIG)
289

290

291
def load_any_sch(file, project, fatal=True, extra_msg=None):
13✔
292
    if file[-9:] == 'kicad_sch':
13✔
293
        sch = SchematicV6()
13✔
294
        load_libs = False
13✔
295
    else:
296
        sch = Schematic()
297
        load_libs = True
298
    # Schematic UUID mapping
299
    # Check if we have a project
300
    mapped_uuid = None
13✔
301
    if GS.pro_file:
13✔
302
        # Projects can override the SCH UUID
303
        file_base = os.path.basename(file)
13✔
304
        try:
13✔
305
            with open(GS.pro_file, 'rt') as f:
13✔
306
                data = json.load(f)
13✔
307
            for mentry in data["schematic"]["top_level_sheets"]:
13✔
308
                if mentry["filename"] == file_base:
3✔
309
                    mapped_uuid = mentry["uuid"]
3✔
310
                    logger.debug(f"Mapping schematic `{file_base}` to UUID `{mapped_uuid}` found in `{GS.pro_file}`")
3✔
311
                    break
3✔
312
        except Exception as e:
12✔
313
            # This is not fatal
314
            logger.debug(f"Failed to get the top level sheets: {e}")
12✔
315
        if mapped_uuid is None and GS.ki10:
13✔
316
            logger.warning(W_NOUUIDMAP+"Unable to map the SCH file to an UUID")
2✔
317
    # End of schematic UUID mapping
318
    try:
13✔
319
        sch.load(file, project, mapped_uuid=mapped_uuid)
13✔
320
        if load_libs:
13✔
321
            sch.load_libs(file)
322
        if GS.debug_level > 1:
13✔
323
            logger.debug('Schematic dependencies: '+str(sch.get_files()))
12✔
324
    except SchFileError as e:
4✔
325
        if extra_msg is not None:
326
            logger.error(extra_msg)
327
        GS.exit_with_error(('At line {} of `{}`: {}'.format(e.line, e.file, e.msg),
328
                            'Line content: `{}`'.format(e.code)), CORRUPTED_SCH if fatal else DONT_STOP)
329
    except SchError as e:
4✔
330
        if extra_msg is not None:
4✔
331
            logger.error(extra_msg)
332
        GS.exit_with_error(('While loading `{}`'.format(file), str(e)), CORRUPTED_SCH if fatal else DONT_STOP)
4✔
333
    except KiConfError as e:
334
        ki_conf_error(e)
335
    return sch
13✔
336

337

338
def load_sch(sch_file=None, forced=False):
13✔
339
    if GS.sch is not None and not forced:  # Already loaded
13✔
340
        return
13✔
341
    if not sch_file:
13✔
342
        GS.check_sch()
13✔
343
        sch_file = GS.sch_file
13✔
344
    GS.sch = load_any_sch(sch_file, os.path.splitext(os.path.basename(sch_file))[0])
13✔
345

346

347
def create_component_from_footprint(m, ref, env):
13✔
348
    c = SchematicComponentV6()
12✔
349
    c.f_ref = c.ref = ref
12✔
350
    c.name = m.GetValue()
12✔
351
    c.sheet_path_h = c.sheet_path = c.lib = ''
12✔
352
    c.project = GS.sch_basename
12✔
353
    c.id = m.m_Uuid.AsString() if hasattr(m, 'm_Uuid') else ''
12✔
354
    # Basic fields
355
    # Reference
356
    f = SchematicField()
12✔
357
    f.name = 'Reference'
12✔
358
    f.value = ref
12✔
359
    f.number = 0
12✔
360
    c.add_field(f)
12✔
361
    # Value
362
    f = SchematicField()
12✔
363
    f.name = 'Value'
12✔
364
    f.value = c.name
12✔
365
    f.number = 1
12✔
366
    c.add_field(f)
12✔
367
    # Footprint
368
    f = SchematicField()
12✔
369
    f.name = 'Footprint'
12✔
370
    lib = m.GetFPID()
12✔
371
    f.value = lib.GetUniStringLibId()
12✔
372
    f.number = 2
12✔
373
    c.add_field(f)
12✔
374
    # Datasheet
375
    f = SchematicField()
12✔
376
    f.name = 'Datasheet'
12✔
377
    f.value = '~'
12✔
378
    f.number = 3
12✔
379
    c.add_field(f)
12✔
380
    # Other fields
381
    copy_fields(c, GS.get_fields(m), env)
12✔
382
    c._solve_fields(None)
12✔
383
    try:
12✔
384
        c.split_ref()
12✔
385
    except SchError:
3✔
386
        # Unusable ref, discard it
387
        logger.warning(f'{W_BADREF}Not including component `{ref}` in filters because it has a malformed reference '
3✔
388
                       f'@ PCB {GS.module_position(m)}')
389
        c = None
3✔
390
    return c
12✔
391

392

393
class PadProperty(object):
13✔
394
    pass
13✔
395

396

397
def expand_one_footprint_field(v, env, extra_env):
13✔
398
    new_value = v
12✔
399
    depth = 1
12✔
400
    used_extra = [False]
12✔
401
    while depth < GS.MAXDEPTH:
12✔
402
        new_value = expand_env(new_value, env, extra_env, used_extra=used_extra)
12✔
403
        if not used_extra[0]:
12✔
404
            break
12✔
405
        depth += 1
406
        if depth == GS.MAXDEPTH:
407
            logger.warning(W_MAXDEPTH+f'Too much nested variables replacements, possible loop ({v})')
408
    # Remove extra spaces as we did with the schematic values
409
    return new_value.strip()
12✔
410

411

412
def create_extra_env(fields):
13✔
413
    return {k.upper() if k.lower() in INTERNAL_FIELDS else k: v for k, v in fields.items()}
13✔
414

415

416
def copy_fields(c, real_fields, env, extra_env=None):
13✔
417
    extra_env = extra_env or create_extra_env(real_fields)
13✔
418
    expanded_fields = {k: expand_one_footprint_field(v, env, extra_env) for k, v in real_fields.items()}
13✔
419
    for name, value in real_fields.items():
13✔
420
        if c.is_field(name.lower()):
10✔
421
            # Already there
422
            old = c.get_field_value(name)
10✔
423
            if value and old != value and old != expanded_fields[name]:
10✔
424
                logger.warning(f"{W_VALMISMATCH}{name} field mismatch for `{c.ref}` (SCH: `{old}` PCB: `{value}`)")
4✔
425
                c.set_field(name, value)
4✔
426
        else:
427
            # New one
428
            logger.debug(f'Adding {name} field to {c.ref} ({value})')
9✔
429
            c.set_field(name, value)
9✔
430

431

432
def get_all_components(collapse=True):
13✔
433
    load_sch()
12✔
434
    comps = GS.sch.get_components(collapse=collapse)
12✔
435
    get_board_comps_data(comps)
12✔
436
    return comps
12✔
437

438

439
def get_board_comps_data(comps, kicad_variant=None):
13✔
440
    """ Add information from the PCB to the list of components from the schematic.
441
        Note that we do it every time the function is called to reset transformation filters like rot_footprint. """
442
    if not GS.pcb_file:
16✔
443
        return
10✔
444
    load_board()
16✔
445
    if GS.ki6:
16✔
446
        comps_hash = {c.sheet_full_path: c for c in comps}
16✔
447
        comps_hash_ref = {c.ref: c for c in comps}
16✔
448
    else:
449
        comps_hash = {c.ref: c for c in comps}
×
450
    # Get the KiCad variables for fields
451
    KiConf.init(GS.sch_file)
16✔
452
    env = KiConf.kicad_env
16✔
453
    env.update(GS.load_pro_variables())
16✔
454
    if kicad_variant is not None:
16✔
455
        old_variant = GS.board.GetCurrentVariant()
1✔
456
        GS.board.SetCurrentVariant(kicad_variant)
1✔
457
    for m in GS.get_modules():
16✔
458
        ref = m.GetReference()
16✔
459
        # logger.error(f'{ref} {m.m_Uuid.AsString()} -> {m.GetPath().AsString()}')
460
        attrs = m.GetAttributes()
16✔
461
        if GS.ki6:
16✔
462
            # By full sheet path
463
            c = comps_hash.get(m.GetPath().AsString())
16✔
464
            if c is None:
16✔
465
                # Check if we have a component with the same reference in the schematic
466
                c = comps_hash_ref.get(ref)
6✔
467
                if c is not None:
6✔
468
                    logger.warning(W_PCBNOSCH+f"{ref} not linked to an existing schematic component, "
6✔
469
                                   f"but {ref} found in schematic, assuming this is the same")
1✔
470
        else:
471
            # By reference
472
            c = comps_hash.get(ref)
×
473
        if c is None:
16✔
474
            if not (attrs & MOD_BOARD_ONLY) and not ref.startswith('KiKit_'):
5✔
475
                logger.warning(W_PCBNOSCH+f'`{ref}` component in board, but not in schematic')
5✔
476
            if not GS.global_include_components_from_pcb:
5✔
477
                # v1.6.3 behavior
478
                logger.debugl(3, f"Not including {c.ref} ({m.m_Uuid.AsString()}) only found in PCB")
×
479
                continue
×
480
            # Create a component for this so we can include/exclude it using filters
481
            c = create_component_from_footprint(m, ref, env)
5✔
482
            if c is None:
5✔
483
                continue
4✔
484
            if GS.ki6:
5✔
485
                logger.debugl(3, f"Including {c.ref} ({m.m_Uuid.AsString()}) only found in PCB")
5✔
486
            comps.append(c)
5✔
487
        if c.has_pcb_info:
16✔
488
            if GS.ki6:
15✔
489
                if c.ref != ref:
15✔
490
                    # This is a "feature" in KiCad, you can get a PCB only component linked to an unrelated sch component
491
                    logger.debugl(3, f"Repeated PCB {ref} {m.m_Uuid.AsString()} SCH {c.ref} {m.GetPath().AsString()}"
15✔
492
                                  " unrelated, making a new one")
493
                    c = create_component_from_footprint(m, ref, env)
15✔
494
                    if c is None:
15✔
495
                        continue
×
496
                else:
497
                    logger.debugl(3, f"Repeated {c.ref}/{ref}")
3✔
498
                    continue
3✔
499
            else:
500
                # When using references they can be repeated
501
                # We already got this reference and filled the PCB info, this is another copy
502
                c = c.copy()
×
503
            comps.append(c)
15✔
504
        real_fields = GS.get_fields(m)
16✔
505
        extra_env = create_extra_env(real_fields)
16✔
506

507
        # Check the "Value", inform if different
508
        new_value = m.GetValue()
16✔
509
        if new_value != c.value:
16✔
510
            expanded_value = expand_one_footprint_field(c.value, env, extra_env)
10✔
511
            if new_value != expanded_value:
10✔
512
                logger.warning(f"{W_VALMISMATCH}Value field mismatch for `{ref}` (SCH: `{c.value}` "
10✔
513
                               f"(`{expanded_value}`) PCB: `{new_value}`)")
2✔
514
        if 'Value' in real_fields:
16✔
515
            # We already computed it
516
            del real_fields['Value']
10✔
517
        c.value = new_value
16✔
518

519
        c.bottom = m.IsFlipped()
16✔
520
        c.footprint_rot = m.GetOrientationDegrees()
16✔
521
        center = GS.get_center(m)
16✔
522
        c.footprint_x = center.x
16✔
523
        c.footprint_y = center.y
16✔
524
        (c.footprint_w, c.footprint_h) = GS.get_fp_size(m)
16✔
525
        c.pad_properties = {}
16✔
526
        if GS.global_use_pcb_fields:
16✔
527
            copy_fields(c, real_fields, env, extra_env)
16✔
528
        c.has_pcb_info = True
16✔
529
        # Net
530
        net_name = set()
16✔
531
        net_class = set()
16✔
532
        for pad in m.Pads():
16✔
533
            net_name.add(pad.GetNetname())
16✔
534
            net_class.add(pad.GetNetClassName())
16✔
535
        c.net_name = ','.join(net_name)
16✔
536
        c.net_class = ','.join(net_class)
16✔
537
        if GS.ki5:
16✔
538
            # KiCad 5
539
            if attrs == UI_SMD:
×
540
                c.smd = True
×
541
            elif attrs == UI_VIRTUAL:
×
542
                c.virtual = True
×
543
            else:
544
                c.tht = True
×
545
        else:
546
            # KiCad 6
547
            if attrs & MOD_SMD:
16✔
548
                c.smd = True
16✔
549
            if attrs & MOD_THROUGH_HOLE:
16✔
550
                c.tht = True
16✔
551
            if attrs & MOD_VIRTUAL == MOD_VIRTUAL:
16✔
552
                c.virtual = True
15✔
553
            if attrs & MOD_EXCLUDE_FROM_POS_FILES:
16✔
554
                c.in_pos = False
15✔
555
            # The PCB contains another flag for the BoM
556
            # I guess it should be in sync, but: why should somebody want to unsync it?
557
            if attrs & MOD_EXCLUDE_FROM_BOM:
16✔
558
                c.in_bom_pcb = False
15✔
559
            if attrs & MOD_BOARD_ONLY:
16✔
560
                c.in_pcb_only = True
×
561
            c.pcb_id = m.m_Uuid.AsString()
16✔
562
            look_for_type = (not c.smd) and (not c.tht)
16✔
563
            for pad in m.Pads():
16✔
564
                p = PadProperty()
16✔
565
                center = pad.GetCenter()
16✔
566
                p.x = center.x
16✔
567
                p.y = center.y
16✔
568
                p.fab_property = pad.GetProperty()
16✔
569
                p.net = pad.GetNetname()
16✔
570
                p.net_class = pad.GetNetClassName()
16✔
571
                p.has_hole = pad.HasHole()
16✔
572
                name = pad.GetNumber()
16✔
573
                c.pad_properties[name] = p
16✔
574
                # Try to figure out if this is THT or SMD when not specified
575
                if look_for_type:
16✔
576
                    if p.has_hole:
15✔
577
                        # At least one THT, stop looking
578
                        c.tht = True
10✔
579
                        look_for_type = False
10✔
580
                    elif name:
15✔
581
                        # We have pad a valid pad, assume this is all SMD and keep looking
582
                        c.smd = True
5✔
583
    if kicad_variant is not None:
16✔
584
        GS.board.SetCurrentVariant(old_variant)
1✔
585

586

587
def expand_comp_fields(c, env):
16✔
588
    extra_env = {f.name.upper() if f.name.lower() in INTERNAL_FIELDS else f.name: f.value for f in c.fields}
6✔
589
    for f in c.fields:
6✔
590
        new_value = f.value
6✔
591
        depth = 1
6✔
592
        used_extra = [False]
6✔
593
        while depth < GS.MAXDEPTH:
6✔
594
            new_value = expand_env(new_value, env, extra_env, used_extra=used_extra)
6✔
595
            if not used_extra[0]:
6✔
596
                break
6✔
597
            depth += 1
4✔
598
            if depth == GS.MAXDEPTH:
4✔
599
                logger.warning(W_MAXDEPTH+'Too much nested variables replacements, possible loop ({})'.format(f.value))
×
600
        if new_value != f.value:
6✔
601
            c.set_field(f.name, new_value)
5✔
602

603

604
def expand_fields(comps, dont_copy=False):
16✔
605
    if not dont_copy:
6✔
606
        new_comps = deepcopy(comps)
6✔
607
        for n_c, c in zip(new_comps, comps):
6✔
608
            n_c.original_copy = c
6✔
609
    KiConf.init(GS.sch_file)
6✔
610
    env = KiConf.kicad_env
6✔
611
    env.update(GS.load_pro_variables())
6✔
612
    for c in comps:
6✔
613
        expand_comp_fields(c, env)
6✔
614
    return comps
6✔
615

616

617
def preflight_checks(skip_pre, targets):
16✔
618
    logger.debug("Preflight checks")
16✔
619
    BasePreFlight.configure_all()
16✔
620
    if skip_pre is not None:
16✔
621
        if skip_pre == 'all':
10✔
622
            logger.debug("Skipping all preflight actions")
10✔
623
            return
10✔
624
        else:
625
            skip_list = skip_pre.split(',')
5✔
626
            for skip in skip_list:
5✔
627
                if skip == 'all':
5✔
628
                    GS.exit_with_error('All can\'t be part of a list of actions '
5✔
629
                                       'to skip. Use `--skip all`', EXIT_BAD_ARGS)
1✔
630
                else:
631
                    if not BasePreFlight.is_registered(skip):
5✔
632
                        GS.exit_with_error(f'Unknown preflight `{skip}`', EXIT_BAD_ARGS)
5✔
633
                    o_pre = BasePreFlight.get_preflight(skip)
5✔
634
                    if not o_pre:
5✔
635
                        logger.warning(W_NONEEDSKIP + '`{}` preflight is not in use, no need to skip'.format(skip))
5✔
636
                    else:
637
                        logger.debug('Skipping `{}`'.format(skip))
5✔
638
                        o_pre.disable()
5✔
639
    BasePreFlight.run_enabled(targets)
16✔
640

641

642
def get_output_dir(o_dir, obj, dry=False):
16✔
643
    # outdir is a combination of the config and output
644
    outdir = os.path.realpath(os.path.abspath(obj.expand_dirname(os.path.join(GS.out_dir, o_dir))))
16✔
645
    # Create directory if needed
646
    logger.debug("Output destination: {}".format(outdir))
16✔
647
    if not dry:
16✔
648
        os.makedirs(outdir, exist_ok=True)
16✔
649
    return outdir
16✔
650

651

652
def config_output(out, dry=False, dont_stop=False):
16✔
653
    if out._configured:
16✔
654
        return True
16✔
655
    try:
16✔
656
        # Should we load the PCB?
657
        if not dry:
16✔
658
            if out.is_pcb():
16✔
659
                load_board()
16✔
660
            if out.is_sch():
16✔
661
                load_sch()
16✔
662
        ok = True
16✔
663
        out.config(None)
16✔
664
    except (KiPlotConfigurationError, PlotError) as e:
6✔
665
        msg = "In section '"+out.name+"' ("+out.type+"): "+str(e)
6✔
666
        GS.exit_with_error(msg, DONT_STOP if dont_stop else EXIT_BAD_CONFIG)
6✔
667
        ok = False
×
668
    except SystemExit:
5✔
669
        if not dont_stop:
5✔
670
            raise
5✔
671
        GS.errors_ignored = True
×
672
        ok = False
×
673
    return ok
16✔
674

675

676
def get_output_targets(output, parent):
16✔
677
    out = RegOutput.get_output(output)
16✔
678
    if out is None:
16✔
679
        GS.exit_with_error(f'Unknown output `{output}` selected in {parent}', WRONG_ARGUMENTS)
1✔
680
    config_output(out)
15✔
681
    out_dir = get_output_dir(out.dir, out, dry=True)
15✔
682
    files_list = out.get_targets(out_dir)
15✔
683
    return files_list, out_dir, out
15✔
684

685

686
def run_output(out, dont_stop=False):
16✔
687
    if out._done:
16✔
688
        return
9✔
689
    if GS.global_set_text_variables_before_output and hasattr(out.options, 'variant'):
16✔
690
        pre = BasePreFlight.get_preflight('set_text_variables')
×
691
        if pre:
×
692
            pre._variant = out.options.variant
×
693
            pre.apply()
×
694
            load_board()
×
695
    GS.current_output = out.name
16✔
696
    try:
16✔
697
        out.run(get_output_dir(out.dir, out))
16✔
698
        out._done = True
16✔
699
    except KiPlotConfigurationError as e:
8✔
700
        msg = "In section '"+out.name+"' ("+out.type+"): "+str(e)
6✔
701
        if dont_stop:
6✔
702
            logger.error(msg)
×
703
            GS.errors_ignored = True
×
704
        else:
705
            config_error(msg)
6✔
706
    except (PlotError, KiPlotError, SchError) as e:
8✔
707
        msg = "In output `"+str(out)+"`: "+str(e)
6✔
708
        GS.exit_with_error(msg, DONT_STOP if dont_stop else PLOT_ERROR)
6✔
709
    except KiConfError as e:
8✔
710
        ki_conf_error(e)
×
711
    except SystemExit:
8✔
712
        if not dont_stop:
8✔
713
            raise
8✔
714
        GS.errors_ignored = True
5✔
715

716

717
def configure_and_run(tree, out_dir, msg):
16✔
718
    out = RegOutput.get_class_for(tree['type'])()
4✔
719
    out.set_tree(tree)
4✔
720
    config_output(out)
4✔
721
    logger.debug(msg)
4✔
722
    out.run(out_dir)
4✔
723
    return out
4✔
724

725

726
def look_for_output(name, op_name, parent, valids):
16✔
727
    out = RegOutput.get_output(name)
9✔
728
    if out is None:
9✔
729
        raise KiPlotConfigurationError('Unknown output `{}` selected in {}'.format(name, parent))
×
730
    config_output(out)
9✔
731
    if out.type not in valids:
9✔
732
        raise KiPlotConfigurationError('`{}` must be {} type, not {}'.format(op_name, valids, out.type))
×
733
    return out
9✔
734

735

736
def _generate_outputs(targets, invert, skip_pre, cli_order, no_priority, dont_stop):
16✔
737
    logger.debug("Starting outputs for board {}".format(GS.pcb_file))
16✔
738
    # Make a list of target outputs
739
    n = len(targets)
16✔
740
    if n == 0:
16✔
741
        # No targets means all
742
        if invert:
16✔
743
            # Skip all targets
744
            logger.debug('Skipping all outputs')
5✔
745
        else:
746
            targets = [out for out in RegOutput.get_outputs() if out.run_by_default]
16✔
747
    else:
748
        # Check we got a valid list of outputs
749
        unknown = next(filter(lambda x: not RegOutput.is_output_or_group(x), targets), None)
15✔
750
        if unknown:
15✔
751
            GS.exit_with_error(f'Unknown output/group `{unknown}`', EXIT_BAD_ARGS)
5✔
752
        # Check for CLI+invert inconsistency
753
        if cli_order and invert:
15✔
754
            GS.exit_with_error("CLI order and invert options can't be used simultaneously", EXIT_BAD_ARGS)
×
755
        # Expand groups
756
        logger.debug('Outputs before groups expansion: {}'.format(targets))
15✔
757
        try:
15✔
758
            targets = RegOutput.solve_groups(targets, 'command line')
15✔
759
        except KiPlotConfigurationError as e:
×
760
            config_error(str(e))
×
761
        logger.debug('Outputs after groups expansion: {}'.format(targets))
15✔
762
        # Now convert the list of names into a list of output objects
763
        if cli_order:
15✔
764
            # Add them in the same order found at the command line
765
            targets = [RegOutput.get_output(name) for name in targets]
5✔
766
        else:
767
            # Add them in the declared order
768
            new_targets = []
15✔
769
            if invert:
15✔
770
                # Invert the selection
771
                for out in RegOutput.get_outputs():
5✔
772
                    if (out.name not in targets) and out.run_by_default:
5✔
773
                        new_targets.append(out)
5✔
774
                    else:
775
                        logger.debug('Skipping `{}` output'.format(out.name))
5✔
776
            else:
777
                # Normal list
778
                for out in RegOutput.get_outputs():
15✔
779
                    if out.name in targets:
15✔
780
                        new_targets.append(out)
15✔
781
                    else:
782
                        logger.debug('Skipping `{}` output'.format(out.name))
15✔
783
            targets = new_targets
15✔
784
    logger.debug('Outputs before preflights: {}'.format([t.name for t in targets]))
16✔
785
    # Run the preflights
786
    preflight_checks(skip_pre, targets)
16✔
787
    logger.debug('Outputs after preflights: {}'.format([t.name for t in targets]))
16✔
788
    if not cli_order and not no_priority:
16✔
789
        # Sort by priority
790
        targets = sorted(targets, key=lambda o: o.priority, reverse=True)
16✔
791
        logger.debug('Outputs after sorting: {}'.format([t.name for t in targets]))
16✔
792
    # Configure and run the outputs
793
    for out in targets:
16✔
794
        if GS.get_stop_flag():
16✔
795
            break
×
796
        if config_output(out, dont_stop=dont_stop):
16✔
797
            logger.info('- '+str(out))
16✔
798
            run_output(out, dont_stop)
16✔
799

800

801
def generate_outputs(targets, invert, skip_pre, cli_order, no_priority, dont_stop=False):
16✔
802
    setup_resources()
16✔
803
    prj = None
16✔
804
    if GS.global_restore_project:
16✔
805
        # Memorize the project content to restore it at exit
806
        prj = GS.read_pro()
5✔
807
    try:
16✔
808
        _generate_outputs(targets, invert, skip_pre, cli_order, no_priority, dont_stop)
16✔
809
    finally:
810
        # Restore the project file
811
        GS.write_pro(prj)
16✔
812

813

814
def adapt_file_name(name):
16✔
815
    if not name.startswith('/usr'):
5✔
816
        name = os.path.relpath(name)
5✔
817
    name = name.replace(' ', r'\ ')
5✔
818
    if '$' in name:
5✔
819
        logger.warning(W_WRONGCHAR+'Wrong character in file name `{}`'.format(name))
5✔
820
    return name
5✔
821

822

823
def gen_global_targets(f, pre_targets, out_targets, type):
16✔
824
    extra_targets = []
6✔
825
    pre = 'pre_'+type
6✔
826
    out = 'out_'+type
6✔
827
    all = 'all_'+type
6✔
828
    if pre_targets:
6✔
829
        f.write('{}:{}\n\n'.format(pre, pre_targets))
5✔
830
        extra_targets.append(pre)
5✔
831
    if out_targets:
6✔
832
        f.write('{}:{}\n\n'.format(out, out_targets))
5✔
833
        extra_targets.append(out)
5✔
834
    if pre_targets or out_targets:
6✔
835
        tg = ''
5✔
836
        if pre_targets:
5✔
837
            tg = ' '+pre
5✔
838
        if out_targets:
5✔
839
            tg += ' '+out
5✔
840
        f.write('{}:{}\n\n'.format(all, tg))
5✔
841
        extra_targets.append(all)
5✔
842
    return extra_targets
6✔
843

844

845
def get_pre_targets(targets, dependencies, is_pre):
16✔
846
    pcb_targets = sch_targets = ''
6✔
847
    BasePreFlight.configure_all()
6✔
848
    prefs = BasePreFlight.get_in_use_objs()
6✔
849
    try:
6✔
850
        for pre in prefs:
6✔
851
            tg = pre.get_targets()
5✔
852
            if not tg:
5✔
853
                continue
5✔
854
            name = pre.type
5✔
855
            targets[name] = [adapt_file_name(fn) for fn in tg]
5✔
856
            dependencies[name] = [adapt_file_name(fn) for fn in pre.get_dependencies()]
5✔
857
            is_pre.add(name)
5✔
858
            if pre.is_sch():
5✔
859
                sch_targets += ' '+name
5✔
860
            if pre.is_pcb():
5✔
861
                pcb_targets += ' '+name
5✔
862
    except KiPlotConfigurationError as e:
×
863
        config_error("In preflight '"+name+"': "+str(e))
×
864
    return pcb_targets, sch_targets
6✔
865

866

867
def get_out_targets(outputs, ori_names, targets, dependencies, comments, no_default):
16✔
868
    pcb_targets = sch_targets = ''
6✔
869
    try:
6✔
870
        for out in outputs:
6✔
871
            name = name2make(out.name)
5✔
872
            ori_names[name] = out.name
5✔
873
            tg = out.get_targets(out.expand_dirname(os.path.join(GS.out_dir, out.dir)))
5✔
874
            if not tg:
5✔
875
                continue
5✔
876
            targets[name] = [adapt_file_name(fn) for fn in tg]
5✔
877
            dependencies[name] = [adapt_file_name(fn) for fn in out.get_dependencies()]
5✔
878
            if out.comment:
5✔
879
                comments[name] = out.comment
5✔
880
            if not out.run_by_default:
5✔
881
                no_default.add(name)
×
882
            if out.is_sch():
5✔
883
                sch_targets += ' '+name
5✔
884
            if out.is_pcb():
5✔
885
                pcb_targets += ' '+name
5✔
886
    except KiPlotConfigurationError as e:
×
887
        config_error("In output '"+name+"': "+str(e))
×
888
    return pcb_targets, sch_targets
6✔
889

890

891
def generate_makefile(makefile, cfg_file, outputs, kibot_sys=False):
16✔
892
    cfg_file = os.path.relpath(cfg_file)
6✔
893
    logger.info('- Creating makefile `{}` from `{}`'.format(makefile, cfg_file))
6✔
894
    with open(makefile, 'wt') as f:
6✔
895
        f.write('#!/usr/bin/make\n')
6✔
896
        f.write('# Automatically generated by KiBot from `{}`\n'.format(cfg_file))
6✔
897
        fname = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'src', 'kibot'))
6✔
898
        if kibot_sys or not os.path.isfile(fname):
6✔
899
            fname = 'kibot'
1✔
900
        f.write('KIBOT?={}\n'.format(fname))
6✔
901
        dbg = ''
6✔
902
        if GS.debug_level > 0:
6✔
903
            dbg = '-'+'v'*GS.debug_level
5✔
904
        f.write('DEBUG?={}\n'.format(dbg))
6✔
905
        f.write('CONFIG={}\n'.format(cfg_file))
6✔
906
        if GS.sch_file:
6✔
907
            f.write('SCH={}\n'.format(os.path.relpath(GS.sch_file)))
6✔
908
        if GS.pcb_file:
6✔
909
            f.write('PCB={}\n'.format(os.path.relpath(GS.pcb_file)))
6✔
910
        f.write('DEST={}\n'.format(os.path.relpath(GS.out_dir)))
6✔
911
        f.write('KIBOT_CMD=$(KIBOT) $(DEBUG) -c $(CONFIG) -e $(SCH) -b $(PCB) -d $(DEST)\n')
6✔
912
        f.write('LOGFILE?=kibot_error.log\n')
6✔
913
        f.write('\n')
6✔
914
        # Configure all outputs
915
        for out in outputs:
6✔
916
            config_output(out)
5✔
917
        # Get all targets and dependencies
918
        targets = OrderedDict()
6✔
919
        dependencies = OrderedDict()
6✔
920
        comments = {}
6✔
921
        ori_names = {}
6✔
922
        is_pre = set()
6✔
923
        no_default = set()
6✔
924
        # Preflights
925
        pre_pcb_targets, pre_sch_targets = get_pre_targets(targets, dependencies, is_pre)
6✔
926
        # Outputs
927
        out_pcb_targets, out_sch_targets = get_out_targets(outputs, ori_names, targets, dependencies, comments, no_default)
6✔
928
        # all target
929
        f.write('#\n# Default target\n#\n')
6✔
930
        f.write('all: '+' '.join(filter(lambda x: x not in no_default, targets.keys()))+'\n\n')
6✔
931
        extra_targets = ['all']
6✔
932
        # PCB/SCH specific targets
933
        f.write('#\n# SCH/PCB targets\n#\n')
6✔
934
        extra_targets.extend(gen_global_targets(f, pre_sch_targets, out_sch_targets, 'sch'))
6✔
935
        extra_targets.extend(gen_global_targets(f, pre_pcb_targets, out_pcb_targets, 'pcb'))
6✔
936
        # Generate the output targets
937
        f.write('#\n# Available targets (outputs)\n#\n')
6✔
938
        for name, target in targets.items():
6✔
939
            f.write(name+': '+' '.join(target)+'\n\n')
5✔
940
        # Generate the output dependencies
941
        f.write('#\n# Rules and dependencies\n#\n')
6✔
942
        if GS.debug_enabled:
6✔
943
            kibot_cmd = '\t$(KIBOT_CMD)'
5✔
944
            log_action = ''
5✔
945
        else:
946
            kibot_cmd = '\t@$(KIBOT_CMD)'
6✔
947
            log_action = ' 2>> $(LOGFILE)'
6✔
948
        skip_all = ','.join(is_pre)
6✔
949
        for name, dep in dependencies.items():
6✔
950
            if name in comments:
5✔
951
                f.write('# '+comments[name]+'\n')
5✔
952
            dep.append(cfg_file)
5✔
953
            f.write(' '.join(targets[name])+': '+' '.join(dep)+'\n')
5✔
954
            if name in is_pre:
5✔
955
                skip = filter(lambda n: n != name, is_pre)
5✔
956
                f.write('{} -s {} -i{}\n\n'.format(kibot_cmd, ','.join(skip), log_action))
5✔
957
            else:
958
                f.write('{} -s {} "{}"{}\n\n'.format(kibot_cmd, skip_all, ori_names[name], log_action))
5✔
959
        # Mark all outputs as PHONY
960
        f.write('.PHONY: '+' '.join(extra_targets+list(targets.keys()))+'\n')
6✔
961

962

963
def guess_ki6_sch(schematics):
16✔
964
    schematics = list(filter(lambda x: x.endswith('.kicad_sch'), schematics))
5✔
965
    if len(schematics) == 1:
5✔
966
        return schematics[0]
×
967
    if len(schematics) == 0:
5✔
968
        return None
×
969
    for fname in schematics:
5✔
970
        with open(fname, 'rt') as f:
5✔
971
            text = f.read()
5✔
972
        if 'sheet_instances' in text:
5✔
973
            return fname
1✔
974
    return None
4✔
975

976

977
def avoid_mixing_5_and_6(sch, kicad_sch):
16✔
978
    GS.exit_with_error(['Found KiCad 5 and KiCad 6+ files, make sure the whole project uses one version',
×
979
                        'KiCad 5:  '+os.path.basename(sch),
980
                        'KiCad 6+: '+os.path.basename(kicad_sch)], EXIT_BAD_CONFIG)
981

982

983
def solve_schematic(base_dir, a_schematic=None, a_board_file=None, config=None, sug_e=True):
16✔
984
    schematic = a_schematic
16✔
985
    if not schematic and a_board_file:
16✔
986
        base = os.path.splitext(a_board_file)[0]
16✔
987
        sch = os.path.join(base_dir, base+'.sch')
16✔
988
        kicad_sch = os.path.join(base_dir, base+'.kicad_sch')
16✔
989
        found_sch = os.path.isfile(sch)
16✔
990
        found_kicad_sch = os.path.isfile(kicad_sch)
16✔
991
        if found_sch and found_kicad_sch:
16✔
992
            avoid_mixing_5_and_6(sch, kicad_sch)
×
993
        if found_sch:
16✔
994
            schematic = sch
×
995
        elif GS.ki6 and found_kicad_sch:
16✔
996
            schematic = kicad_sch
16✔
997
    if not schematic:
16✔
998
        schematics = glob(os.path.join(base_dir, '*.sch'))
16✔
999
        if GS.ki6:
16✔
1000
            schematics += glob(os.path.join(base_dir, '*.kicad_sch'))
16✔
1001
        if len(schematics) == 1:
16✔
1002
            schematic = schematics[0]
10✔
1003
            logger.info('Using SCH file: '+os.path.relpath(schematic))
10✔
1004
        elif len(schematics) > 1:
16✔
1005
            is_silly = W_SILLY
5✔
1006
            # Look for a schematic with the same name as the config
1007
            if config:
5✔
1008
                if config[0] == '.':
5✔
1009
                    # Unhide hidden config
1010
                    config = config[1:]
×
1011
                # Remove any extension
1012
                last_split = None
5✔
1013
                while '.' in config and last_split != config:
5✔
1014
                    last_split = config
5✔
1015
                    config = os.path.splitext(config)[0]
5✔
1016
                # Try KiCad 5
1017
                sch = os.path.join(base_dir, config+'.sch')
5✔
1018
                found_sch = os.path.isfile(sch)
5✔
1019
                # Try KiCad 6
1020
                kicad_sch = os.path.join(base_dir, config+'.kicad_sch')
5✔
1021
                found_kicad_sch = os.path.isfile(kicad_sch)
5✔
1022
                if found_sch and found_kicad_sch:
5✔
1023
                    avoid_mixing_5_and_6(sch, kicad_sch)
×
1024
                if found_sch:
5✔
1025
                    schematic = sch
×
1026
                elif GS.ki6 and found_kicad_sch:
5✔
1027
                    schematic = kicad_sch
×
1028
            if not schematic:
5✔
1029
                # Look for a schematic with a PCB and/or project
1030
                for sch in schematics:
5✔
1031
                    base = os.path.splitext(sch)[0]
5✔
1032
                    if (os.path.isfile(os.path.join(base_dir, base+'.pro')) or
5✔
1033
                       os.path.isfile(os.path.join(base_dir, base+'.kicad_pro')) or
1✔
1034
                       os.path.isfile(os.path.join(base_dir, base+'.kicad_pcb'))):
1✔
1035
                        schematic = sch
5✔
1036
                        break
5✔
1037
                else:
1038
                    # No way to select one, just take the first
1039
                    is_silly = ''
5✔
1040
                    if GS.ki6:
5✔
1041
                        schematic = guess_ki6_sch(schematics)
5✔
1042
                    if not schematic:
5✔
1043
                        schematic = schematics[0]
4✔
1044
            msg = ' if you want to use another use -e option' if sug_e else ''
5✔
1045
            logger.warning(is_silly+W_VARSCH+'More than one SCH file found in `'+base_dir+'`.\n'
5✔
1046
                           '  Using '+schematic+msg+'.')
1✔
1047
    if schematic and not os.path.isfile(schematic):
16✔
1048
        GS.exit_with_error("Schematic file not found: "+schematic, NO_SCH_FILE)
5✔
1049
    if schematic:
16✔
1050
        schematic = os.path.abspath(schematic)
16✔
1051
        logger.debug('Using schematic: `{}`'.format(schematic))
16✔
1052
        logger.debug('Real schematic name: `{}`'.format(cased_path(schematic)))
16✔
1053
    else:
1054
        logger.debug('No schematic file found')
16✔
1055
    return schematic
16✔
1056

1057

1058
def check_board_file(board_file):
16✔
1059
    if board_file and not os.path.isfile(board_file):
16✔
1060
        GS.exit_with_error("Board file not found: "+board_file, NO_PCB_FILE)
5✔
1061

1062

1063
def solve_board_file(base_dir, a_board_file=None, sug_b=True):
16✔
1064
    schematic = GS.sch_file
16✔
1065
    board_file = a_board_file
16✔
1066
    if not board_file and schematic:
16✔
1067
        pcb = os.path.join(base_dir, os.path.splitext(schematic)[0]+'.kicad_pcb')
16✔
1068
        if os.path.isfile(pcb):
16✔
1069
            board_file = pcb
16✔
1070
    if not board_file:
16✔
1071
        board_files = glob(os.path.join(base_dir, '*.kicad_pcb'))
11✔
1072
        if len(board_files) == 1:
11✔
1073
            board_file = board_files[0]
5✔
1074
            logger.info('Using PCB file: '+os.path.relpath(board_file))
5✔
1075
        elif len(board_files) > 1:
11✔
1076
            board_file = board_files[0]
5✔
1077
            msg = ' if you want to use another use -b option' if sug_b else ''
5✔
1078
            logger.warning(W_VARPCB + 'More than one PCB file found in `'+base_dir+'`.\n'
5✔
1079
                           '  Using '+board_file+msg+'.')
1✔
1080
    check_board_file(board_file)
16✔
1081
    if board_file:
16✔
1082
        logger.debug('Using PCB: `{}`'.format(board_file))
16✔
1083
        logger.debug('Real PCB name: `{}`'.format(cased_path(board_file)))
16✔
1084
    else:
1085
        logger.debug('No PCB file found')
11✔
1086
    return board_file
16✔
1087

1088

1089
def solve_project_file():
16✔
1090
    if GS.pcb_file:
16✔
1091
        pro_name = GS.pcb_no_ext+GS.pro_ext
16✔
1092
        if os.path.isfile(pro_name):
16✔
1093
            return pro_name
16✔
1094
    if GS.sch_file:
16✔
1095
        pro_name = GS.sch_no_ext+GS.pro_ext
16✔
1096
        if os.path.isfile(pro_name):
16✔
1097
            return pro_name
9✔
1098
    return None
16✔
1099

1100

1101
def look_for_used_layers():
16✔
1102
    from .layer import Layer
5✔
1103
    Layer.reset()
5✔
1104
    layers = set()
5✔
1105
    components = {}
5✔
1106
    # Look inside the modules
1107
    for m in GS.get_modules():
5✔
1108
        layer = m.GetLayer()
5✔
1109
        components[layer] = components.get(layer, 0)+1
5✔
1110
        for gi in m.GraphicalItems():
5✔
1111
            layers.add(gi.GetLayer())
5✔
1112
        for pad in m.Pads():
5✔
1113
            for id in pad.GetLayerSet().Seq():
5✔
1114
                layers.add(id)
5✔
1115
    # All drawings in the PCB
1116
    for e in GS.board.GetDrawings():
5✔
1117
        layers.add(e.GetLayer())
5✔
1118
    # Zones
1119
    for e in list(GS.board.Zones()):
5✔
1120
        layers.add(e.GetLayer())
5✔
1121
    # Tracks and vias
1122
    via_type = 'VIA' if GS.ki5 else 'PCB_VIA'
5✔
1123
    for e in GS.board.GetTracks():
5✔
1124
        if e.GetClass() == via_type:
5✔
1125
            for id in e.GetLayerSet().Seq():
5✔
1126
                layers.add(id)
5✔
1127
        else:
1128
            layers.add(e.GetLayer())
5✔
1129
    # Now filter the pads and vias potential layers
1130
    declared_layers = {la._id for la in Layer.solve('all')}
5✔
1131
    layers = sorted(declared_layers.intersection(layers))
5✔
1132
    logger.debug('- Detected layers: {}'.format(layers))
5✔
1133
    layers = Layer.solve(layers)
5✔
1134
    for la in layers:
5✔
1135
        la.components = components.get(la._id, 0)
5✔
1136
    return layers
5✔
1137

1138

1139
def discover_files(dest_dir):
16✔
1140
    """ Look for schematic and PCBs at the dest_dir.
1141
        Return the name of the example file to generate. """
1142
    GS.pcb_file = None
5✔
1143
    GS.sch_file = None
5✔
1144
    # Check if we have useful files
1145
    fname = os.path.join(dest_dir, 'kibot_generated.kibot.yaml')
5✔
1146
    GS.set_sch(solve_schematic(dest_dir, sug_e=False))
5✔
1147
    GS.set_pcb(solve_board_file(dest_dir, sug_b=False))
5✔
1148
    GS.set_pro(solve_project_file())
5✔
1149
    return fname
5✔
1150

1151

1152
def load_config(plot_config):
16✔
1153
    if not plot_config:
16✔
1154
        return []
1✔
1155
    cr = CfgYamlReader()
16✔
1156
    outputs = None
16✔
1157
    try:
16✔
1158
        # The Python way ...
1159
        with gzip.open(plot_config, mode='rt') as cf_file:
16✔
1160
            try:
16✔
1161
                outputs = cr.read(cf_file)
16✔
1162
            except KiPlotConfigurationError as e:
16✔
1163
                config_error(str(e))
×
1164
    except OSError:
16✔
1165
        pass
16✔
1166
    if outputs is None:
16✔
1167
        with open(plot_config) as cf_file:
16✔
1168
            try:
16✔
1169
                outputs = cr.read(cf_file)
16✔
1170
            except KiPlotConfigurationError as e:
6✔
1171
                config_error(str(e))
6✔
1172
    return outputs
16✔
1173

1174

1175
def yaml_dump(f, tree):
16✔
1176
    if version_str2tuple(yaml.__version__) < (3, 14):
5✔
1177
        f.write(yaml.dump(tree))
×
1178
    else:
1179
        # sort_keys was introduced after 3.13
1180
        f.write(yaml.dump(tree, sort_keys=False))
5✔
1181

1182

1183
def register_xmp_import(name, definitions=None):
16✔
1184
    """ Register an import we need for an example """
1185
    global needed_imports
1186
    assert name not in needed_imports
4✔
1187
    needed_imports[name] = definitions
4✔
1188

1189

1190
def check_we_cant_use(o):
13✔
1191
    """ Check if the output doesn't have what it needs, i.e. no PCB and this is PCB related """
1192
    return ((not (o.is_pcb() and GS.pcb_file) and
5✔
1193
             not (o.is_sch() and GS.sch_file) and
1✔
1194
             not (o.is_any() and (GS.pcb_file or GS.sch_file))) or
1✔
1195
            ((o.is_pcb() and o.is_sch()) and (not GS.pcb_file or not GS.sch_file)))
1✔
1196

1197

1198
def generate_one_example(dest_dir, types):
16✔
1199
    """ Generate a example config for dest_dir """
1200
    fname = discover_files(dest_dir)
4✔
1201
    # Abort if none
1202
    if not GS.pcb_file and not GS.sch_file:
4✔
1203
        return None
1204
    # Reset the board and schematic
1205
    GS.board = None
4✔
1206
    GS.sch = None
4✔
1207
    # Create the config
1208
    with open(fname, 'wt') as f:
4✔
1209
        logger.info('- Creating {} example configuration'.format(fname))
4✔
1210
        f.write("# This is a working example.\n")
4✔
1211
        f.write("# For a more complete reference use `--example`\n")
4✔
1212
        f.write('kibot:\n  version: 1\n\n')
4✔
1213
        # Outputs
1214
        outs = RegOutput.get_registered()
4✔
1215
        # List of layers
1216
        layers = []
4✔
1217
        if GS.pcb_file:
4✔
1218
            load_board(GS.pcb_file)
4✔
1219
            layers = look_for_used_layers()
4✔
1220
        if GS.sch_file:
4✔
1221
            load_sch()
4✔
1222
        # Filter some warnings
1223
        fil = [{'number': 1007},  # No information for a component in a distributor
4✔
1224
               {'number': 1015},  # More than one component in a search for a distributor
1225
               {'number': 58},    # Missing project file
1226
               {'number': 107},   # Stencil.side auto, we always use it for the example
1227
               {'number': 143},   # This output depends on KiCad version, use `blender_export` instead
1228
               ]
1229
        glb = {'filters': fil}
4✔
1230
        yaml_dump(f, {'global': glb})
4✔
1231
        f.write('\n')
4✔
1232
        # A helper for the internal templates
1233
        global needed_imports
1234
        needed_imports = {}
4✔
1235
        # All the preflights
1236
        preflights = {}
4✔
1237
        for n in sorted(BasePreFlight.get_registered().keys()):
4✔
1238
            o = BasePreFlight.get_object_for(n)
4✔
1239
            if types and n not in types:
4✔
1240
                logger.debug('- {}, not selected (PCB: {} SCH: {})'.format(n, o.is_pcb(), o.is_sch()))
1241
                continue
1242
            if check_we_cant_use(o):
4✔
1243
                logger.debug('- {}, skipped (PCB: {} SCH: {})'.format(n, o.is_pcb(), o.is_sch()))
4✔
1244
                continue
4✔
1245
            tree = o.get_conf_examples(n, layers)
4✔
1246
            if tree:
4✔
1247
                logger.debug('- {}, generated'.format(n))
2✔
1248
                preflights.update(tree)
2✔
1249
            else:
1250
                logger.debug('- {}, nothing to do'.format(n))
4✔
1251
        # All the outputs
1252
        outputs = []
4✔
1253
        for n, cls in OrderedDict(sorted(outs.items())).items():
4✔
1254
            o = cls()
4✔
1255
            if types and n not in types:
4✔
1256
                logger.debug('- {}, not selected (PCB: {} SCH: {})'.format(n, o.is_pcb(), o.is_sch()))
1257
                continue
1258
            if check_we_cant_use(o):
4✔
1259
                logger.debug('- {}, skipped (PCB: {} SCH: {})'.format(n, o.is_pcb(), o.is_sch()))
4✔
1260
                continue
4✔
1261
            tree = cls.get_conf_examples(n, layers)
4✔
1262
            if tree:
4✔
1263
                logger.debug('- {}, generated'.format(n))
4✔
1264
                outputs.extend(tree)
4✔
1265
            else:
1266
                logger.debug('- {}, nothing to do'.format(n))
4✔
1267
        global_defaults = None
4✔
1268
        if needed_imports:
4✔
1269
            imports = []
4✔
1270
            for n, d in sorted(needed_imports.items()):
4✔
1271
                if n == 'global':
4✔
1272
                    global_defaults = d
4✔
1273
                    continue
4✔
1274
                content = {'file': n}
4✔
1275
                if d:
4✔
1276
                    content['definitions'] = d
4✔
1277
                imports.append(content)
4✔
1278
            yaml_dump(f, {'import': imports})
4✔
1279
            f.write('\n')
4✔
1280
        if preflights:
4✔
1281
            yaml_dump(f, {'preflight': preflights})
2✔
1282
            f.write('\n')
2✔
1283
        if outputs:
4✔
1284
            yaml_dump(f, {'outputs': outputs})
4✔
1285
        else:
1286
            return None
1287
        if global_defaults:
4✔
1288
            f.write('\n...\n')
4✔
1289
            yaml_dump(f, {'definitions': global_defaults})
4✔
1290
    return fname
4✔
1291

1292

1293
def reset_config():
13✔
1294
    # Outputs, groups, filters and variants
1295
    RegOutput.reset()
1✔
1296
    # Preflights
1297
    BasePreFlight.reset()
1✔
1298

1299

1300
def generate_targets(config_file):
13✔
1301
    """ Generate all possible targets for the configuration file """
1302
    # Reset the board and schematic
1303
    GS.board = None
×
UNCOV
1304
    GS.sch = None
×
1305
    # Reset the list of outputs and preflights
UNCOV
1306
    reset_config()
×
1307
    # Read the config file
1308
    cr = CfgYamlReader()
×
1309
    with open(config_file) as cf_file:
×
UNCOV
1310
        cr.read(cf_file)
×
1311
    # Do all the job
UNCOV
1312
    generate_outputs([], False, None, False, False, dont_stop=True)
×
1313

1314

1315
def _walk(path, depth):
16✔
1316
    """ Recursively list files and directories up to a certain depth """
1317
    depth -= 1
4✔
1318
    try:
4✔
1319
        with os.scandir(path) as p:
4✔
1320
            for entry in p:
4✔
1321
                yield entry.path
4✔
1322
                if entry.is_dir() and depth > 0:
4✔
1323
                    yield from _walk(entry.path, depth)
1324
    except Exception as e:
1325
        logger.debug(f'Skipping {path} because {e}')
1326

1327

1328
def setup_fonts(source):
13✔
1329
    if not os.path.isdir(source):
13✔
1330
        logger.debug('No font resources dir')
13✔
1331
        return
13✔
1332
    dest = os.path.expanduser('~/.fonts/')
3✔
1333
    installed = False
3✔
1334
    for f in glob(os.path.join(source, '*.ttf')) + glob(os.path.join(source, '*.otf')):
3✔
1335
        fname = os.path.basename(f)
3✔
1336
        fdest = os.path.join(dest, fname)
3✔
1337
        if os.path.isfile(fdest):
3✔
1338
            logger.debug('Font {} already installed'.format(fname))
1339
            continue
1340
        logger.info('Installing font {}'.format(fname))
3✔
1341
        if not os.path.isdir(dest):
3✔
1342
            os.makedirs(dest)
3✔
1343
        copy2(f, fdest)
3✔
1344
        installed = True
3✔
1345
    if installed:
3✔
1346
        run_command(['fc-cache'])
3✔
1347

1348

1349
def setup_colors(source):
13✔
1350
    if not os.path.isdir(source):
13✔
1351
        logger.debug('No color resources dir')
13✔
1352
        return
13✔
1353
    if not GS.kicad_conf_path:
3✔
1354
        return
1355
    dest = os.path.join(GS.kicad_conf_path, 'colors')
3✔
1356
    for f in glob(os.path.join(source, '*.json')):
3✔
1357
        fname = os.path.basename(f)
3✔
1358
        fdest = os.path.join(dest, fname)
3✔
1359
        if os.path.isfile(fdest):
3✔
1360
            logger.debug('Color {} already installed'.format(fname))
1361
            continue
1362
        logger.info('Installing color {}'.format(fname))
3✔
1363
        if not os.path.isdir(dest):
3✔
1364
            os.makedirs(dest)
1✔
1365
        copy2(f, fdest)
3✔
1366

1367

1368
def setup_resources():
13✔
1369
    if not GS.global_resources_dir:
13✔
1370
        logger.debug('No resources dir')
1371
        return
1372
    setup_fonts(os.path.join(GS.global_resources_dir, 'fonts'))
13✔
1373
    setup_colors(os.path.join(GS.global_resources_dir, 'colors'))
13✔
1374

1375

1376
def generate_examples(start_dir, dry, types):
13✔
1377
    if not start_dir:
4✔
1378
        start_dir = '.'
1379
    else:
1380
        if not os.path.isdir(start_dir):
4✔
1381
            GS.exit_with_error(f'Invalid dir {start_dir} to quick start', WRONG_ARGUMENTS)
1382
    # Set default global options
1383
    glb = GS.set_global_options_tree({})
4✔
1384
    glb.config(None)
4✔
1385
    # Install the resources
1386
    setup_resources()
4✔
1387
    # Look for candidate dirs
1388
    k_files_regex = re.compile(r'([^/]+)\.(kicad_pro|pro)$')
4✔
1389
    candidates = set()
4✔
1390
    for f in _walk(start_dir, 6):
4✔
1391
        if k_files_regex.search(f):
4✔
1392
            candidates.add(os.path.realpath(os.path.dirname(f)))
4✔
1393
    if not candidates:
4✔
1394
        GS.exit_with_error(f'No KiCad projects found in `{start_dir}`', MISSING_FILES)
1395
    # Try to generate the configs in the candidate places
1396
    confs = []
4✔
1397
    for c in sorted(candidates):
4✔
1398
        logger.info('Analyzing `{}` dir'.format(c))
4✔
1399
        res = generate_one_example(c, types)
4✔
1400
        if res:
4✔
1401
            confs.append(res)
4✔
1402
        logger.info('')
4✔
1403
    confs.sort()
4✔
1404
    # Just the configs, not the targets
1405
    if dry:
4✔
1406
        return
4✔
1407
    # Try to generate all the stuff
1408
    if GS.out_dir_in_cmd_line:
1409
        out_dir = GS.out_dir
1410
    else:
1411
        out_dir = 'Generated'
1412
    for n, c in enumerate(confs):
1413
        conf_dir = os.path.dirname(c)
1414
        if len(confs) > 1:
1415
            subdir = '%03d-%s' % (n+1, conf_dir.replace('/', ',').replace(' ', '_'))
1416
            dest = os.path.join(out_dir, subdir)
1417
        else:
1418
            dest = out_dir
1419
        GS.out_dir = dest
1420
        logger.info('Generating targets for `{}`, destination: `{}`'.format(c, dest))
1421
        os.makedirs(dest, exist_ok=True)
1422
        # Create a log file with all the debug we can
1423
        fl = log.set_file_log(os.path.join(dest, 'kibot.log'))
1424
        old_lvl = GS.debug_level
1425
        GS.debug_level = 10
1426
        # Detect the SCH and PCB again
1427
        discover_files(conf_dir)
1428
        # Generate all targets
1429
        generate_targets(c)
1430
        # Close the debug file
1431
        log.remove_file_log(fl)
1432
        GS.debug_level = old_lvl
1433
        logger.info('')
1434
    # Try to open a browser
1435
    index = os.path.join(GS.out_dir, 'index.html')
1436
    if os.environ.get('DISPLAY') and which('x-www-browser') and os.path.isfile(index):
1437
        Popen(['x-www-browser', index])
1438

1439

1440
def get_columns():
13✔
1441
    """ Create a list of valid columns """
1442
    if GS.sch:
11✔
1443
        cols = deepcopy(ColumnList.COLUMNS_DEFAULT)
11✔
1444
        return (GS.sch.get_field_names(cols), ColumnList.COLUMNS_EXTRA)
11✔
1445
    return (ColumnList.COLUMNS_DEFAULT, ColumnList.COLUMNS_EXTRA)
1✔
1446

1447

1448
# To avoid circular dependencies: Optionable needs it, but almost everything needs Optionable
1449
GS.load_board = load_board
16✔
1450
GS.load_sch = load_sch
16✔
1451
GS.exec_with_retry = exec_with_retry
16✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc