KiBot/kibot/__main__.py

542 lines
23 KiB
Python

# -*- coding: utf-8 -*-
# Copyright (c) 2020-2023 Salvador E. Tropea
# Copyright (c) 2020-2023 Instituto Nacional de Tecnología Industrial
# Copyright (c) 2018 John Beard
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
# Adapted from: https://github.com/johnbeard/kiplot
"""KiBot: KiCad automation tool for documents generation
Usage:
kibot [-b BOARD] [-e SCHEMA] [-c CONFIG] [-d OUT_DIR] [-s PRE]
[-q | -v...] [-L LOGFILE] [-C | -i | -n] [-m MKFILE] [-A] [-g DEF] ...
[-E DEF] ... [--defs-from-env] [-w LIST] [-D | -W] [--banner N]
[TARGET...]
kibot [-v...] [-b BOARD] [-e SCHEMA] [-c PLOT_CONFIG] [--banner N]
[-E DEF] ... [--defs-from-env] [--config-outs]
[--only-pre|--only-groups] [--only-names] [--output-name-first] --list
kibot [-v...] [-c PLOT_CONFIG] [--banner N] [-E DEF] ... [--only-names]
--list-variants
kibot [-v...] [-b BOARD] [-d OUT_DIR] [-p | -P] [--banner N] --example
kibot [-v...] [--start PATH] [-d OUT_DIR] [--dry] [--banner N]
[-t, --type TYPE]... --quick-start
kibot [-v...] [--rst] --help-filters
kibot [-v...] [--markdown|--json|--rst] --help-dependencies
kibot [-v...] [--rst] --help-global-options
kibot [-v...] --help-list-offsets
kibot [-v...] [--rst] --help-list-outputs
kibot [-v...] --help-list-rotations
kibot [-v...] --help-output=HELP_OUTPUT
kibot [-v...] [--rst] [-d OUT_DIR] --help-outputs
kibot [-v...] [--rst] --help-preflights
kibot [-v...] [--rst] --help-variants
kibot [-v...] --help-banners
kibot [-v...] [--rst] --help-errors
kibot -h | --help
kibot --version
Arguments:
TARGET Outputs to generate, default is all
Options:
-A, --no-auto-download Disable dependencies auto-download
-b BOARD, --board-file BOARD The PCB .kicad-pcb board file
--banner N Display banner number N (-1 == random)
-c CONFIG, --plot-config CONFIG The plotting config file to use
-C, --cli-order Generate outputs using the indicated order
--config-outs Configure all outputs before listing them
-d OUT_DIR, --out-dir OUT_DIR The output directory [default: .]
-D, --dont-stop Try to continue if an output fails
--defs-from-env Use the environment vars as preprocessor
values
-e SCHEMA, --schematic SCHEMA The schematic file (.sch/.kicad_sch)
-E DEF, --define DEF Define preprocessor value (VAR=VAL)
-g DEF, --global-redef DEF Overwrite a global value (VAR=VAL)
-i, --invert-sel Generate the outputs not listed as targets
-l, --list List available outputs, preflights and
groups (in the config file).
You don't need to specify an SCH/PCB unless
using --config-outs
--list-variants List the available variants and exit
-L, --log LOGFILE Log to LOGFILE using maximum debug level.
Is independent of what is logged to stderr
-m MKFILE, --makefile MKFILE Generate a Makefile (no targets created)
-n, --no-priority Don't sort targets by priority
-p, --copy-options Copy plot options from the PCB file
--only-names Print only the names. Note that for --list
if no other --only-* option is provided it
also acts as a virtual --only-outputs
--only-groups Print only the groups.
--only-pre Print only the preflights
--output-name-first Use the output name first when listing
-P, --copy-and-expand As -p but expand the list of layers
-q, --quiet Remove information logs
-s PRE, --skip-pre PRE Skip preflights, comma separated or `all`
-v, --verbose Show debugging information
-V, --version Show program's version number and exit
-w, --no-warn LIST Exclude the mentioned warnings (comma sep)
-W, --stop-on-warnings Stop on warnings
-x, --example Create a template configuration file
Quick start options:
--quick-start Generates demo config files and their outputs
--dry Just generate the config files
--start PATH Starting point for the search [default: .]
-t, --type TYPE Generate examples only for the indicated type/s
Help options:
-h, --help Show this help message and exit
--help-banners Show all available banners
--help-dependencies List dependencies in human readable format
--help-errors List of error levels
--help-filters List supported filters and details
--help-global-options List supported global variables
--help-list-offsets List footprint offsets (JLCPCB)
--help-list-outputs List supported outputs
--help-list-rotations List footprint rotations (JLCPCB)
--help-output HELP_OUTPUT Help for this particular output
--help-outputs List supported outputs and details
--help-preflights List supported preflights and details
--help-variants List supported variants and details
"""
from datetime import datetime
from glob import glob
import gzip
import locale
import os
import platform
import re
import sys
from sys import path as sys_path
from . import __version__, __copyright__, __license__
# Import log first to set the domain
from . import log
log.set_domain('kibot')
logger = log.init()
from .docopt import docopt
# GS will import pcbnew, so we must solve the nightly setup first
# Check if we have to run the nightly KiCad build
nightly = False
if os.environ.get('KIAUS_USE_NIGHTLY'): # pragma: no cover (nightly)
# Path to the Python module
pcbnew_path = '/usr/lib/kicad-nightly/lib/python3/dist-packages'
sys_path.insert(0, pcbnew_path)
# This helps other tools like iBoM to pick-up the right pcbnew module
if 'PYTHONPATH' in os.environ:
os.environ['PYTHONPATH'] += os.pathsep+pcbnew_path
else:
os.environ['PYTHONPATH'] = pcbnew_path
nightly = True
from .banner import get_banner, BANNERS
from .gs import GS
from . import dep_downloader
from .misc import EXIT_BAD_ARGS, W_VARCFG, NO_PCBNEW_MODULE, W_NOKIVER, hide_stderr, TRY_INSTALL_CHECK, W_ONWIN
from .pre_base import BasePreFlight
from .error import KiPlotConfigurationError, config_error
from .config_reader import (CfgYamlReader, print_outputs_help, print_output_help, print_preflights_help, create_example,
print_filters_help, print_global_options_help, print_dependencies, print_variants_help,
print_errors, print_list_rotations, print_list_offsets)
from .kiplot import (generate_outputs, load_actions, config_output, generate_makefile, generate_examples, solve_schematic,
solve_board_file, solve_project_file, check_board_file)
from .registrable import RegOutput
GS.kibot_version = __version__
def list_pre_and_outs_names(logger, outputs, do_config, only_pre, only_groups):
pf = BasePreFlight.get_in_use_names()
if only_pre:
for c in sorted(pf):
logger.info(c)
return
if only_groups:
for g in sorted(RegOutput.get_group_names()):
logger.info(g)
return
if outputs:
for o in sorted(outputs, key=lambda x: x.name.lower()):
if do_config:
config_output(o, dry=False)
logger.info(o.name)
def list_pre_and_outs(logger, outputs, do_config, only_names, only_pre, only_groups, output_name_first):
if only_names:
return list_pre_and_outs_names(logger, outputs, do_config, only_pre, only_groups)
pf = BasePreFlight.get_in_use_objs()
groups = RegOutput.get_groups()
if pf and not only_groups:
logger.info('Available pre-flights:')
for c in pf:
logger.info('- '+str(c))
logger.info("")
if outputs and not only_pre and not only_groups:
fmt = "name: comment/description [type]" if output_name_first else "'comment/description' (name) [type]"
logger.info("Available outputs: format is: `{}`".format(fmt))
for o in outputs:
# Note: we can't do a `dry` config because some layer and field names can be validated only if we
# load the schematic and the PCB.
if do_config:
config_output(o, dry=False)
if output_name_first:
logger.info('- {}: {} [{}]'.format(o.name, o.comment, o.type))
else:
logger.info('- '+str(o))
logger.info("")
if groups and not only_pre:
logger.info("Available groups:")
for g, items in groups.items():
logger.info('- '+g+': '+', '.join(items))
logger.info("")
if pf:
logger.info("You can use e.g. `kibot --skip-pre preflight_name1,preflight_name2` to")
logger.info("skip specific preflights (or pass `all` to skip them all).")
logger.info("")
if outputs:
logger.info("You can use e.g. `kibot output_name1 output_name2` to generate only")
logger.info("specific outputs by name.")
logger.info("")
if groups:
logger.info("You can use the name of a group instead of an output name.")
logger.info("")
def list_variants(logger, only_names):
variants = RegOutput.get_variants()
if not variants:
if not only_names:
logger.info('No variants defined')
return
if only_names:
for name in sorted(variants.keys()):
logger.info(name)
return
logger.info("Available variants: 'comment/description' (name) [type]")
for name in sorted(variants.keys()):
logger.info('- '+str(variants[name]))
def solve_config(a_plot_config, quiet=False):
plot_config = a_plot_config
if not plot_config:
plot_configs = glob('*.kibot.yaml')+glob('*.kiplot.yaml')+glob('*.kibot.yaml.gz')+glob('*.kibot.yml')
if len(plot_configs) == 1:
plot_config = plot_configs[0]
if not quiet:
logger.info('Using config file: '+os.path.relpath(plot_config))
elif len(plot_configs) > 1:
plot_config = plot_configs[0]
logger.warning(W_VARCFG + 'More than one config file found in current directory.\n'
' Using '+plot_config+' if you want to use another use -c option.')
else:
GS.exit_with_error('No config file found (*.kibot.yaml), use -c to specify one.', EXIT_BAD_ARGS)
if not os.path.isfile(plot_config):
GS.exit_with_error("Plot config file not found: "+plot_config, EXIT_BAD_ARGS)
logger.debug('Using configuration file: `{}`'.format(plot_config))
return plot_config
def set_locale():
""" Try to set the locale for all the cataegories.
If it fails try with LC_NUMERIC (the one we need for tests). """
try:
locale.setlocale(locale.LC_ALL, '')
return
except locale.Error:
pass
try:
locale.setlocale(locale.LC_NUMERIC, '')
return
except locale.Error:
pass
def detect_kicad():
try:
import pcbnew
except ImportError:
GS.exit_with_error(["Failed to import pcbnew Python module."
" Is KiCad installed?"
" Do you need to add it to PYTHONPATH?",
TRY_INSTALL_CHECK], NO_PCBNEW_MODULE)
try:
GS.kicad_version = pcbnew.GetBuildVersion()
except AttributeError:
logger.warning(W_NOKIVER+"Unknown KiCad version, please install KiCad 5.1.6 or newer")
# Assume the best case
GS.kicad_version = '5.1.5'
try:
# Debian sid may 2021 mess:
really_index = GS.kicad_version.index('really')
GS.kicad_version = GS.kicad_version[really_index+6:]
except ValueError:
pass
m = re.search(r'(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?', GS.kicad_version)
if m is None:
GS.exit_with_error(f"Unable to detect KiCad version, got: `{GS.kicad_version}`", NO_PCBNEW_MODULE)
GS.kicad_version_major = int(m.group(1))
GS.kicad_version_minor = int(m.group(2))
GS.kicad_version_patch = int(m.group(3))
GS.kicad_version_subpatch = 0 if m.group(4) is None else int(m.group(4))
GS.kicad_version_n = (GS.kicad_version_major*10000000+GS.kicad_version_minor*10000+GS.kicad_version_patch*10 +
GS.kicad_version_subpatch)
GS.ki5 = GS.kicad_version_major < 6
GS.ki6 = GS.kicad_version_major >= 6
GS.ki6_only = GS.kicad_version_major == 6
GS.ki7 = GS.kicad_version_major >= 7
GS.ki8 = (GS.kicad_version_major == 7 and GS.kicad_version_minor >= 99) or GS.kicad_version_major >= 8
GS.footprint_gr_type = 'MGRAPHIC' if not GS.ki8 else 'PCB_SHAPE'
GS.board_gr_type = 'DRAWSEGMENT' if GS.ki5 else 'PCB_SHAPE'
GS.footprint_update_local_coords = GS.dummy1 if GS.ki8 else GS.footprint_update_local_coords_ki7
logger.debug('Detected KiCad v{}.{}.{} ({} {})'.format(GS.kicad_version_major, GS.kicad_version_minor,
GS.kicad_version_patch, GS.kicad_version, GS.kicad_version_n))
# Used to look for plug-ins.
# KICAD_PATH isn't good on my system.
# The kicad-nightly package overwrites the regular package!!
GS.kicad_share_path = '/usr/share/kicad'
if GS.ki6:
GS.kicad_conf_path = pcbnew.GetSettingsManager().GetUserSettingsPath()
if nightly:
# Nightly Debian packages uses `/usr/share/kicad-nightly/kicad-nightly.env` as an environment extension
# This script defines KICAD_CONFIG_HOME="$HOME/.config/kicadnightly"
# So we just patch it, as we patch the name of the binaries
# No longer needed for 202112021512+6.0.0+rc1+287+gbb08ef2f41+deb11
# GS.kicad_conf_path = GS.kicad_conf_path.replace('/kicad/', '/kicadnightly/')
GS.kicad_share_path = GS.kicad_share_path.replace('/kicad/', '/kicad-nightly/')
GS.kicad_dir = 'kicad-nightly'
GS.pro_ext = '.kicad_pro'
# KiCad 6 doesn't support the Rescue layer
GS.work_layer = 'User.9'
else:
# Bug in KiCad (#6989), prints to stderr:
# `../src/common/stdpbase.cpp(62): assert "traits" failed in Get(test_dir): create wxApp before calling this`
# Found in KiCad 5.1.8, 5.1.9
# So we temporarily suppress stderr
with hide_stderr():
GS.kicad_conf_path = pcbnew.GetKicadConfigPath()
GS.pro_ext = '.pro'
GS.work_layer = 'Rescue'
# Dirs to look for plugins
GS.kicad_plugins_dirs = []
# /usr/share/kicad/*
GS.kicad_plugins_dirs.append(os.path.join(GS.kicad_share_path, 'scripting'))
GS.kicad_plugins_dirs.append(os.path.join(GS.kicad_share_path, 'scripting', 'plugins'))
GS.kicad_plugins_dirs.append(os.path.join(GS.kicad_share_path, '3rdparty', 'plugins')) # KiCad 6.0 PCM
# ~/.config/kicad/*
GS.kicad_plugins_dirs.append(os.path.join(GS.kicad_conf_path, 'scripting'))
GS.kicad_plugins_dirs.append(os.path.join(GS.kicad_conf_path, 'scripting', 'plugins'))
# ~/.kicad_plugins and ~/.kicad
if 'HOME' in os.environ:
home = os.environ['HOME']
GS.kicad_plugins_dirs.append(os.path.join(home, '.kicad_plugins'))
GS.kicad_plugins_dirs.append(os.path.join(home, '.kicad', 'scripting'))
GS.kicad_plugins_dirs.append(os.path.join(home, '.kicad', 'scripting', 'plugins'))
if GS.kicad_version_major >= 6:
ver_dir = str(GS.kicad_version_major)+'.'+str(GS.kicad_version_minor)
local_share = os.path.join(home, '.local', 'share', 'kicad', ver_dir)
GS.kicad_plugins_dirs.append(os.path.join(local_share, 'scripting'))
GS.kicad_plugins_dirs.append(os.path.join(local_share, 'scripting', 'plugins'))
GS.kicad_plugins_dirs.append(os.path.join(local_share, '3rdparty', 'plugins')) # KiCad 6.0 PCM
if GS.debug_level > 1:
logger.debug('KiCad config path {}'.format(GS.kicad_conf_path))
def parse_defines(args):
if args.defs_from_env:
GS.cli_defines.update(os.environ)
for define in args.define:
if '=' not in define:
GS.exit_with_error(f'Malformed `define` option, must be VARIABLE=VALUE ({define})', EXIT_BAD_ARGS)
var = define.split('=')[0]
GS.cli_defines[var] = define[len(var)+1:]
def parse_global_redef(args):
for redef in args.global_redef:
if '=' not in redef:
GS.exit_with_error(f'Malformed global-redef option, must be VARIABLE=VALUE ({redef})', EXIT_BAD_ARGS)
var = redef.split('=')[0]
GS.cli_global_defs[var] = redef[len(var)+1:]
class SimpleFilter(object):
def __init__(self, num):
self.number = num
self.regex = re.compile('')
self.error = ''
def apply_warning_filter(args):
if args.no_warn:
try:
log.set_filters([SimpleFilter(int(n)) for n in args.no_warn.split(',')])
except ValueError:
GS.exit_with_error(f'-w/--no-warn must specify a comma separated list of numbers ({args.no_warn})', EXIT_BAD_ARGS)
def debug_arguments(args):
if GS.debug_level > 1:
logger.debug('Command line arguments:\n'+str(sys.argv))
logger.debug('Command line parsed:\n'+str(args))
def detect_windows(): # pragma: no cover (Windows)
if platform.system() != 'Windows':
return
# Note: We assume this is the Python from KiCad, but we should check it ...
GS.on_windows = True
logger.warning(W_ONWIN+'Running on Windows, this is experimental, please report any problem')
def main():
set_locale()
ver = 'KiBot '+__version__+' - '+__copyright__+' - License: '+__license__
GS.out_dir_in_cmd_line = '-d' in sys.argv or '--out-dir' in sys.argv
args = docopt(__doc__, version=ver, options_first=True)
# Set the specified verbosity
GS.debug_enabled = log.set_verbosity(logger, args.verbose, args.quiet)
log.debug_level = GS.debug_level = args.verbose
# We can log all the debug info to a separated file
if args.log:
if os.path.isfile(args.log):
os.remove(args.log)
else:
os.makedirs(os.path.dirname(os.path.abspath(args.log)), exist_ok=True)
log.set_file_log(args.log)
log.debug_level = GS.debug_level = 10
# The log setup finished, this is our first log message
logger.debug('KiBot {} verbose level: {} started on {}'.format(__version__, args.verbose, datetime.now()))
apply_warning_filter(args)
log.stop_on_warnings = args.stop_on_warnings
# Now we have the debug level set we can check (and optionally inform) KiCad info
detect_kicad()
detect_windows()
debug_arguments(args)
# Force iBoM to avoid the use of graphical stuff
os.environ['INTERACTIVE_HTML_BOM_NO_DISPLAY'] = 'True'
# Parse global overwrite options
parse_global_redef(args)
# Disable auto-download if needed
if args.no_auto_download:
dep_downloader.disable_auto_download = True
# Output dir: relative to CWD (absolute path overrides)
GS.out_dir = os.path.join(os.getcwd(), args.out_dir)
# Load output and preflight plugins
load_actions()
if args.banner is not None:
try:
id = int(args.banner)
except ValueError:
GS.exit_with_error(f'The banner option needs an integer ({id})', EXIT_BAD_ARGS)
logger.info(get_banner(id))
if args.help_outputs or args.help_list_outputs:
print_outputs_help(args.rst, details=args.help_outputs)
sys.exit(0)
if args.help_output:
print_output_help(args.help_output)
sys.exit(0)
if args.help_preflights:
print_preflights_help(args.rst)
sys.exit(0)
if args.help_variants:
print_variants_help(args.rst)
sys.exit(0)
if args.help_filters:
print_filters_help(args.rst)
sys.exit(0)
if args.help_global_options:
print_global_options_help(args.rst)
sys.exit(0)
if args.help_dependencies:
print_dependencies(args.markdown, args.json, args.rst)
sys.exit(0)
if args.help_list_rotations:
print_list_rotations()
sys.exit(0)
if args.help_list_offsets:
print_list_offsets()
sys.exit(0)
if args.help_banners:
for c, b in enumerate(BANNERS):
logger.info('Banner '+str(c))
logger.info(b)
sys.exit(0)
if args.help_errors:
print_errors(args.rst)
sys.exit(0)
if args.example:
check_board_file(args.board_file)
if args.copy_options and not args.board_file:
GS.exit_with_error('Asked to copy options but no PCB specified.', EXIT_BAD_ARGS)
create_example(args.board_file, GS.out_dir, args.copy_options, args.copy_and_expand)
sys.exit(0)
if args.quick_start:
# Some kind of wizard to get usable examples
generate_examples(args.start, args.dry, args.type)
sys.exit(0)
# Determine the YAML file
plot_config = solve_config(args.plot_config, args.only_names)
if not (args.list or args.list_variants) or args.config_outs:
# Determine the SCH file
GS.set_sch(solve_schematic('.', args.schematic, args.board_file, plot_config))
# Determine the PCB file
GS.set_pcb(solve_board_file('.', args.board_file))
# Determine the project file
GS.set_pro(solve_project_file())
# Parse preprocessor defines
parse_defines(args)
# Read the config file
cr = CfgYamlReader()
outputs = None
try:
# The Python way ...
with gzip.open(plot_config, mode='rt') as cf_file:
try:
outputs = cr.read(cf_file)
except KiPlotConfigurationError as e:
config_error(str(e))
except OSError:
pass
if outputs is None:
with open(plot_config) as cf_file:
try:
outputs = cr.read(cf_file)
except KiPlotConfigurationError as e:
config_error(str(e))
# Is just "list the available targets"?
if args.list:
list_pre_and_outs(logger, outputs, args.config_outs, args.only_names, args.only_pre, args.only_groups,
args.output_name_first)
sys.exit(0)
if args.list_variants:
list_variants(logger, args.only_names)
sys.exit(0)
if args.makefile:
# Only create a makefile
generate_makefile(args.makefile, plot_config, outputs)
else:
# Do all the job (preflight + outputs)
generate_outputs(outputs, args.target, args.invert_sel, args.skip_pre, args.cli_order, args.no_priority,
dont_stop=args.dont_stop)
# Print total warnings
logger.log_totals()
if __name__ == "__main__":
main() # pragma: no cover