KiBot/kibot/out_kikit_present.py

444 lines
19 KiB
Python

# -*- coding: utf-8 -*-
# Copyright (c) 2022-2023 Salvador E. Tropea
# Copyright (c) 2022-2023 Instituto Nacional de Tecnología Industrial
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
"""
Dependencies:
- name: markdown2
python_module: true
debian: python3-markdown2
arch: python-markdown2
role: mandatory
- from: Git
role: Find commit hash and/or date
"""
import glob
import os
import shutil
import stat
import subprocess
import sys
from tempfile import NamedTemporaryFile, TemporaryDirectory, mkdtemp
from .error import KiPlotConfigurationError
from .misc import PCB_GENERATORS, RENDERERS, W_MORERES
from .gs import GS
from .kiplot import config_output, run_output, get_output_dir, load_board, run_command, configure_and_run
from .optionable import BaseOptions, Optionable
from .out_base import BaseOutput
from .registrable import RegOutput
from .macros import macros, document, output_class # noqa: F401
from . import log
logger = log.get_logger()
def _get_tmp_name(ext):
with NamedTemporaryFile(mode='w', suffix='.'+ext, delete=False) as f:
f.close()
os.chmod(f.name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH)
return f.name
class PresentBoards(Optionable):
def __init__(self):
super().__init__()
with document:
self.mode = 'local'
""" [local,file,external] How images and gerbers are obtained.
*local*: Only applies to the currently selected PCB.
You must provide the names of the outputs used to render
the images and compress the gerbers.
When empty KiBot will use the first render/gerber output
it finds.
To apply variants use `pcb_from_output` and a `pcb_variant`
output.
*file*: You must specify the file names used for the images and
the gerbers.
*external*: You must specify an external KiBot configuration.
It will be applied to the selected PCB to create the images and
the gerbers. The front image must be generated in a dir called
*front*, the back image in a dir called *back* and the gerbers
in a dir called *gerbers* """
self.name = ''
""" *Name for this board. If empty we use the name of the PCB.
Applies to all modes """
self.comment = ''
""" A comment or description for this board.
Applies to all modes """
self.pcb_file = ''
""" Name of the KiCad PCB file. When empty we use the current PCB.
Is ignored for the *local* mode """
self.pcb_from_output = ''
""" Use the PCB generated by another output.
Is ignored for the *file* mode """
self.front_image = ''
""" How to obtain the front view of the PCB.
*local*: the name of an output to render it.
If empty we use the first renderer.
*file*: the name of the rendered image.
*external*: ignored, we use `extrenal_config` """
self.back_image = ''
""" How to obtain the back view of the PCB.
*local*: the name of an output to render it.
If empty we use the first renderer.
*file*: the name of the rendered image.
*external*: ignored, we use `extrenal_config` """
self.gerbers = ''
""" How to obtain an archive with the gerbers.
*local*: the name of a `gerber` output.
If empty we use the first `gerber` output.
*file*: the name of a compressed archive.
*external*: ignored, we use `extrenal_config` """
self.external_config = ''
""" Name of an external KiBot configuration.
Only used in the *external* mode """
def config(self, parent):
super().config(parent)
if not self.name:
self.name = GS.pcb_basename
if not self.pcb_file:
self.pcb_file = GS.pcb_file
if self.mode == 'file':
files = [self.front_image, self.back_image, self.gerbers]
for f in files:
if not os.path.isfile(f):
raise KiPlotConfigurationError('Missing file: `{}`'.format(f))
elif self.mode == 'external':
if not self.external_config:
raise KiPlotConfigurationError('`external_config` must be specified for the external mode')
if not os.path.isfile(self.external_config):
raise KiPlotConfigurationError('Missing external config: `{}`'.format(self.external_config))
if self.pcb_from_output and self.mode != 'file':
out = RegOutput.get_output(self.pcb_from_output)
self._pcb_from_output = out
if out is None:
raise KiPlotConfigurationError('Unknown output `{}` selected in board {}'.
format(self.pcb_from_output, self.name))
def fill_empty_values(self, parent):
# The defaults are good enough, but we need to attach to a parent
self._parent = parent
def solve_file(self):
return self.name, self.comment, self.pcb_file, self.front_image, self.back_image, self.gerbers
def generate_image(self, back, tmp_name):
options = self._renderer.get_renderer_options()
if options is None:
raise KiPlotConfigurationError('No suitable renderer ({})'.format(self._renderer))
# Memorize the current options
options.save_renderer_options()
logger.debug('Starting renderer with back: {}, name: {}'.format(back, tmp_name))
# Configure it according to our needs
options.setup_renderer([], [], back, tmp_name)
self._renderer.dir = self._parent._parent.dir
self._renderer._done = False
run_output(self._renderer)
# Restore the options
options.restore_renderer_options()
def do_compress(self, tmp_name, out):
tree = {'name': '_temporal_compress_gerbers',
'type': 'compress',
'comment': 'Internally created to compress gerbers',
'options': {'output': tmp_name, 'files': [{'from_output': out.name, 'dest': '/'}]}}
configure_and_run(tree, os.path.dirname(tmp_name), 'Creating gerbers archive ...')
def generate_archive(self, out, tmp_name):
out.options
logger.debug('Starting gerber name: {}'.format(out.name))
# Save options
old_dir = out.dir
old_done = out._done
old_variant = out.options.variant
# Configure it according to our needs
with TemporaryDirectory() as tmp_dir:
logger.debug('Generating the gerbers at '+tmp_dir)
out.done = False
out.dir = tmp_dir
out.options.variant = None
run_output(out)
self.do_compress(tmp_name, out)
# Restore options
out.dir = old_dir
out._done = old_done
out.options.variant = old_variant
def solve_pcb(self, load_it=True):
if not self.pcb_from_output:
return False
out_name = self.pcb_from_output
out = RegOutput.get_output(out_name)
if out is None:
raise KiPlotConfigurationError('Unknown output `{}` selected in board {}'. format(out_name, self.name))
if out.type not in PCB_GENERATORS:
raise KiPlotConfigurationError("Output `{}` can't be used to render the PCB, must be {}".
format(out, PCB_GENERATORS))
config_output(out)
run_output(out)
new_pcb = out.get_targets(get_output_dir(out.dir, out))[0]
if load_it:
GS.board = None
self.old_pcb = GS.pcb_file
GS.set_pcb(new_pcb)
load_board()
self.new_pcb = new_pcb
return True
def solve_local_image(self, out_name, back=False):
if not out_name:
out = next(filter(lambda x: x.type in RENDERERS, RegOutput.get_outputs()), None)
if not out:
raise KiPlotConfigurationError('No renderer output found, must be {}'.format(RENDERERS))
else:
out = RegOutput.get_output(out_name)
if out is None:
raise KiPlotConfigurationError('Unknown output `{}` selected in board {}'. format(out_name, self.name))
if out.type not in RENDERERS:
raise KiPlotConfigurationError("Output `{}` can't be used to render the PCB, must be {}".
format(out, RENDERERS))
config_output(out)
self._renderer = out
tmp_name = _get_tmp_name(out.get_extension())
self.temporals.append(tmp_name)
self.generate_image(back, tmp_name)
return tmp_name
def solve_local_gerbers(self, out_name):
if not out_name:
out = next(filter(lambda x: x.type == 'gerber', RegOutput.get_outputs()), None)
if not out:
raise KiPlotConfigurationError('No gerber output found')
else:
out = RegOutput.get_output(out_name)
if out is None:
raise KiPlotConfigurationError('Unknown output `{}` selected in board {}'. format(out_name, self.name))
if out.type != 'gerber':
raise KiPlotConfigurationError("Output `{}` must be `gerber` type, not {}". format(out, out.type))
config_output(out)
tmp_name = _get_tmp_name('zip')
self.temporals.append(tmp_name)
# Generate the archive
self.generate_archive(out, tmp_name)
return tmp_name
def solve_local(self):
fname = GS.pcb_file
pcb_changed = self.solve_pcb()
front_image = self.solve_local_image(self.front_image)
back_image = self.solve_local_image(self.back_image, back=True)
gerbers = self.solve_local_gerbers(self.gerbers)
if pcb_changed:
fname = self.new_pcb
GS.set_pcb(self.old_pcb)
GS.reload_project(GS.pro_file)
return self.name, self.comment, fname, front_image, back_image, gerbers
def get_ext_file(self, main_dir, sub_dir):
d_name = os.path.join(main_dir, sub_dir)
logger.debugl(1, 'Looking for results at '+d_name)
if not os.path.isdir(d_name):
raise KiPlotConfigurationError('`{}` should create a directory called `{}`'.
format(self.external_config, sub_dir))
res = glob.glob(os.path.join(d_name, '*'))
if not res:
raise KiPlotConfigurationError('`{}` created an empty `{}`'.
format(self.external_config, sub_dir))
if len(res) > 1:
logger.warning(W_MORERES+'`{}` generated more than one file at `{}`'.
format(self.external_config, sub_dir))
return res[0]
def solve_external(self):
tmp_dir = mkdtemp()
self.temporals.append(tmp_dir)
fname = self.new_pcb if self.solve_pcb(load_it=False) else GS.pcb_file
cmd = [sys.argv[0], '-c', self.external_config, '-b', fname, '-d', tmp_dir]
run_command(cmd)
front_image = self.get_ext_file(tmp_dir, 'front')
back_image = self.get_ext_file(tmp_dir, 'back')
gerbers = self.get_ext_file(tmp_dir, 'gerbers')
return self.name, self.comment, fname, front_image, back_image, gerbers
def solve(self, temporals):
self.temporals = temporals
if self.mode == 'file':
return self.solve_file()
elif self.mode == 'local':
return self.solve_local()
# external
return self.solve_external()
class KiKit_PresentOptions(BaseOptions):
def __init__(self):
with document:
self.description = ''
""" *Name for a markdown file containing the main part of the page to be generated.
This is mandatory and is the description of your project.
You can embed the markdown code. If the text doesn't map to a file and contains
more than one line KiBot will assume this is the markdown """
self.boards = PresentBoards
""" [dict|list(dict)] One or more boards that compose your project.
When empty we will use only the main PCB for the current project """
self.resources = Optionable
""" [string|list(string)=''] A list of file name patterns for additional resources to be included.
I.e. images referenced in description.
They will be copied relative to the output dir """
self.template = 'default'
""" Path to a template directory or a name of built-in one.
See KiKit's doc/present.md for template specification """
self.repository = ''
""" URL of the repository. Will be passed to the template.
If empty we will try to find it using `git remote get-url origin`.
The default template uses it to create an URL for the current commit """
self.name = ''
""" Name of the project. Will be passed to the template.
If empty we use the name of the KiCad project.
The default template uses it for things like the page title """
super().__init__()
self._git_solved = False
def get_git_command(self):
if not self._git_solved:
self._git_command = self.check_tool('Git')
self._git_solved = True
return self._git_command
def config(self, parent):
super().config(parent)
# Validate the input file name
if not self.description:
raise KiPlotConfigurationError('You must specify an input markdown file using `description`')
if not os.path.isfile(self.description):
self.description = self.description.split('\n')
if len(self.description) == 1:
raise KiPlotConfigurationError('Missing description file `{}`'.format(self.description))
# List of boards
if isinstance(self.boards, type):
a_board = PresentBoards()
a_board.fill_empty_values(self)
self.boards = [a_board]
elif isinstance(self.boards, PresentBoards):
self.boards = [self.boards]
# else ... we have a list of boards
self.resources = self.force_list(self.resources, comma_sep=False)
if not self.name:
self.name = GS.pcb_basename
# Make sure the template exists
if not os.path.exists(os.path.join(self.template, "template.json")):
try:
self.template = GS.get_resource_path(os.path.join('pcbdraw', 'present', 'templates', self.template))
except SystemExit:
raise KiPlotConfigurationError('Missing template `{}`'.format(self.template))
# Repo URL
if not self.repository and self.get_git_command():
try:
self.repository = run_command([self.get_git_command(), 'remote', 'get-url', 'origin'], just_raise=True)
except subprocess.CalledProcessError:
pass
def get_targets(self, out_dir):
self.ensure_tool('markdown2')
from .PcbDraw.present import readTemplate
# The web page
out_dir = self._parent.expand_dirname(out_dir)
res = [os.path.join(out_dir, 'index.html')]
# The resources
template = readTemplate(self.template)
for r in self.resources:
template.addResource(r)
res.extend(template.listResources(out_dir))
# The boards
res.append(os.path.join(out_dir, 'boards'))
return res
def generate_images(self, dir_name, content):
# Memorize the current options
self.save_options()
dir = os.path.dirname(os.path.join(dir_name, self.imgname))
if not os.path.exists(dir):
os.makedirs(dir)
counter = 0
for item in content:
if item["type"] != "steps":
continue
for x in item["steps"]:
counter += 1
filename = self.imgname.replace('%d', str(counter))
x["img"] = self.generate_image(x["side"], x["components"], x["active_components"], filename)
# Restore the options
self.restore_options()
return content
def run(self, dir_name):
self.ensure_tool('markdown2')
from .PcbDraw.present import boardpage
# Generate missing images
board = []
temporals = []
try:
for brd in self.boards:
board.append(brd.solve(temporals))
# Support embedded markdown
if isinstance(self.description, list):
tmp_md = _get_tmp_name('md')
with open(tmp_md, 'w') as f:
f.writelines([ln+'\n' for ln in self.description])
self._description = tmp_md
temporals.append(tmp_md)
else:
self._description = self.description
try:
boardpage(dir_name, self._description, board, self.resources, self.template, self.repository, self.name,
self.get_git_command())
except RuntimeError as e:
raise KiPlotConfigurationError('KiKit present error: '+str(e))
finally:
for f in temporals:
if os.path.isfile(f):
os.remove(f)
elif os.path.isdir(f):
shutil.rmtree(f)
@output_class
class KiKit_Present(BaseOutput):
""" KiKit's Present - Project Presentation
Creates an HTML file showing your project.
It can contain one or more PCBs, showing their top and bottom sides.
Also includes a download link and the gerbers. """
def __init__(self):
super().__init__()
with document:
self.options = KiKit_PresentOptions
""" *[dict] Options for the `kikit_present` output """
self._category = 'PCB/docs'
@staticmethod
def get_conf_examples(name, layers):
if not GS.check_tool(name, 'markdown2'):
return None
outs = BaseOutput.simple_conf_examples(name, 'Simple project presentation', 'Presentation')
outs[0]['options'] = {'description': '# Presentation for '+GS.pcb_basename+'\n'
'This is an automatically generated presentation page',
'boards': {'mode': 'local', 'comment': 'Resources included',
'front_image': 'renderer_for_present',
'back_image': 'renderer_for_present',
'gerbers': 'gerbers_for_present'}}
# Renderer
renderer = BaseOutput.simple_conf_examples('pcbdraw', 'Renderer for the presentation', 'Render_for_presentation')
renderer[0]['name'] = 'renderer_for_present'
renderer[0]['run_by_default'] = False
outs.append(renderer[0])
# Gerbers
gerber = BaseOutput.simple_conf_examples('gerber', 'Gerbers for the presentation', 'Gerber_for_presentation')
gerber[0]['name'] = 'gerbers_for_present'
gerber[0]['layers'] = 'copper'
gerber[0]['run_by_default'] = False
outs.append(gerber[0])
return outs