WIP move project to uv

This commit is contained in:
jaseg 2025-11-16 19:37:48 +01:00
parent 379cf273cb
commit 718de2b338
530 changed files with 1246 additions and 577 deletions

View file

@ -1,89 +0,0 @@
import os
from pathlib import Path
import tqdm
import multiprocessing.pool
import subprocess
from itertools import chain
import pytest
from .image_support import ImageDifference, run_cargo_cmd, bulk_populate_kicad_fp_export_cache, KICAD_CONTAINER
def pytest_assertrepr_compare(op, left, right):
if isinstance(left, ImageDifference) or isinstance(right, ImageDifference):
diff = left if isinstance(left, ImageDifference) else right
return [
f'Image difference assertion failed.',
f' Calculated difference: {diff}',
f' Histogram: {diff.histogram}', ]
# store report in node object so tmp_gbr can determine if the test failed.
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
setattr(item, f'rep_{rep.when}', rep)
fail_dir = Path('gerbonara_test_failures')
def pytest_sessionstart(session):
if 'PYTEST_XDIST_WORKER' in os.environ: # only run this on the controller
return
for f in chain(fail_dir.glob('*.gbr'), fail_dir.glob('*.png')):
f.unlink()
try:
run_cargo_cmd('resvg', '--help', stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except FileNotFoundError:
pytest.exit('resvg binary not found, aborting test.', 2)
def pytest_configure(config):
os.nice(20)
# Resvg can sometimes consume a lot of memory. Make sure we don't kill the user's session.
if (oom_adj := Path('/proc/self/oom_adj')).is_file():
oom_adj.write_text('15\n')
if 'PYTEST_XDIST_WORKER' in os.environ: # only run this on the controller
return
if (lib_dir := os.environ.get('KICAD_FOOTPRINTS')):
lib_dir = Path(lib_dir).expanduser()
if not lib_dir.is_dir():
raise ValueError(f'Path "{lib_dir}" given by KICAD_FOOTPRINTS environment variable does not exist or is not a directory.')
print('Updating podman image')
subprocess.run(['podman', 'pull', KICAD_CONTAINER], check=True)
print('Checking and bulk re-building KiCad footprint library cache')
with multiprocessing.pool.ThreadPool() as pool: # use thread pool here since we're only monitoring podman processes
lib_dirs = list(lib_dir.glob('*.pretty'))
res = list(tqdm.tqdm(pool.imap(lambda path: bulk_populate_kicad_fp_export_cache(path), lib_dirs), total=len(lib_dirs)))
def pytest_addoption(parser):
parser.addoption('--kicad-symbol-library', nargs='*', help='Run symbol library tests on given symbol libraries. May be given multiple times.')
parser.addoption('--kicad-footprint-files', nargs='*', help='Run footprint library tests on given footprint files. May be given multiple times.')
def pytest_generate_tests(metafunc):
if 'kicad_library_file' in metafunc.fixturenames:
if not (library_files := metafunc.config.getoption('symbol_library', None)):
if (lib_dir := os.environ.get('KICAD_SYMBOLS')):
lib_dir = Path(lib_dir).expanduser()
library_files = list(lib_dir.glob('*.kicad_sym'))
else:
raise ValueError('Either --kicad-symbol-library command line parameter or KICAD_SYMBOLS environment variable must be given to run kicad symbol tests.')
metafunc.parametrize('kicad_library_file', library_files, ids=list(map(str, library_files)))
if 'kicad_mod_file' in metafunc.fixturenames:
if not (mod_files := metafunc.config.getoption('footprint_files', None)):
if (lib_dir := os.environ.get('KICAD_FOOTPRINTS')):
lib_dir = Path(lib_dir).expanduser()
mod_files = list(lib_dir.glob('*.pretty/*.kicad_mod'))
else:
raise ValueError('Either --kicad-footprint-files command line parameter or KICAD_FOOTPRINTS environment variable must be given to run kicad footprint tests.')
metafunc.parametrize('kicad_mod_file', mod_files, ids=list(map(str, mod_files)))

View file

@ -1,360 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Based on https://github.com/tracespace/tracespace
#
import subprocess
from pathlib import Path
import tempfile
import textwrap
import os
import sys
import stat
import random
import statistics
from functools import total_ordering
import shutil
import bs4
from contextlib import contextmanager
import hashlib
import tqdm
import numpy as np
from PIL import Image
cachedir = Path(__file__).parent / 'image_cache'
cachedir.mkdir(exist_ok=True)
KICAD_CONTAINER = os.environ.get('KICAD_CONTAINER', 'registry.hub.docker.com/kicad/kicad:nightly')
@total_ordering
class ImageDifference:
def __init__(self, value, histogram):
self.value = value
self.histogram = histogram
def __float__(self):
return float(self.value)
def __eq__(self, other):
return float(self) == float(other)
def __lt__(self, other):
return float(self) < float(other)
def __str__(self):
return str(float(self))
@total_ordering
class Histogram:
def __init__(self, value, size):
self.value, self.size = value, size
def __eq__(self, other):
other = np.array(other)
other[other == None] = self.value[other == None]
return (self.value == other).all()
def __lt__(self, other):
other = np.array(other)
other[other == None] = self.value[other == None]
return (self.value <= other).all()
def __getitem__(self, index):
return self.value[index]
def __str__(self):
return f'{list(self.value)} size={self.size}'
def run_cargo_cmd(cmd, args, **kwargs):
if cmd.upper() in os.environ:
return subprocess.run([os.environ[cmd.upper()], *args], **kwargs)
try:
return subprocess.run([cmd, *args], **kwargs)
except FileNotFoundError:
return subprocess.run([str(Path.home() / '.cargo' / 'bin' / cmd), *args], **kwargs)
def svg_to_png(in_svg, out_png, dpi=100, bg=None):
params = f'{dpi}{bg}'.encode()
digest = hashlib.blake2b(Path(in_svg).read_bytes() + params).hexdigest()
cachefile = cachedir / f'{digest}.png'
if not cachefile.is_file():
bg = 'black' if bg is None else bg
run_cargo_cmd('resvg', ['--background', bg, '--dpi', str(dpi), in_svg, cachefile], check=True, stdout=subprocess.DEVNULL)
shutil.copy(cachefile, out_png)
to_gerbv_svg_units = lambda val, unit='mm': val*72 if unit == 'inch' else val/25.4*72
def gerbv_export(in_gbr, out_svg, export_format='svg', origin=(0, 0), size=(6, 6), fg='#ffffff', bg='#000000', override_unit_spec=None):
params = f'{origin}{size}{fg}{bg}'.encode()
digest = hashlib.blake2b(Path(in_gbr).read_bytes() + params).hexdigest()
cachefile = cachedir / f'{digest}.svg'
if not cachefile.is_file():
print(f'Building cache for {Path(in_gbr).name}')
# NOTE: gerbv seems to always export 'clear' polarity apertures as white, irrespective of --foreground, --background
# and project file color settings.
# TODO: File issue upstream.
with tempfile.NamedTemporaryFile('w') as f:
if override_unit_spec:
units, zeros, digits = override_unit_spec
print(f'{Path(in_gbr).name}: overriding excellon unit spec to {units=} {zeros=} {digits=}')
units = 0 if units == 'inch' else 1
zeros = {None: 0, 'leading': 1, 'trailing': 2}[zeros]
unit_spec = textwrap.dedent(f'''(cons 'attribs (list
(list 'autodetect 'Boolean 0)
(list 'zero_suppression 'Enum {zeros})
(list 'units 'Enum {units})
(list 'digits 'Integer {digits})
))''')
else:
unit_spec = ''
r, g, b = int(fg[1:3], 16), int(fg[3:5], 16), int(fg[5:], 16)
color = f"(cons 'color #({r*257} {g*257} {b*257}))"
f.write(f'''(gerbv-file-version! "2.0A")(define-layer! 0 (cons 'filename "{in_gbr}"){unit_spec}{color})''')
f.flush()
if override_unit_spec:
shutil.copy(f.name, '/tmp/foo.gbv')
x, y = origin
w, h = size
cmd = ['gerbv', '-x', export_format,
'--border=0',
f'--origin={x:.6f}x{y:.6f}', f'--window_inch={w:.6f}x{h:.6f}',
f'--background={bg}',
f'--foreground={fg}',
'-o', str(cachefile), '-p', f.name]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
print(f'Re-using cache for {Path(in_gbr).name}')
shutil.copy(cachefile, out_svg)
def kicad_fp_export(mod_file, out_svg):
mod_file = Path(mod_file)
if mod_file.suffix.lower() != '.kicad_mod':
raise ValueError("KiCad footprint file must have .kicad_mod extension for kicad-cli to do it's thing")
params = f'(noparams)'.encode()
digest = hashlib.blake2b(mod_file.read_bytes() + params).hexdigest()
cachefile = cachedir / f'{digest}.svg'
if not cachefile.is_file():
print(f'Building cache for {mod_file.name}')
with tempfile.TemporaryDirectory() as tmpdir:
os.chmod(tmpdir, 0o1777)
pretty_dir = mod_file.parent
fp_name = mod_file.name[:-len('.kicad_mod')]
cmd = ['podman', 'run',
'--rm', # Clean up volumes after exit
'--userns=keep-id', # To allow container to read from bind mount
'--mount', f'type=bind,src={pretty_dir},dst=/{pretty_dir.name}',
'--mount', f'type=bind,src={tmpdir},dst=/out',
KICAD_CONTAINER,
'kicad-cli', 'fp', 'export', 'svg', '--output', '/out', '--footprint', fp_name, f'/{pretty_dir.name}']
subprocess.run(cmd, check=True) #, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
out_file = Path(tmpdir) / f'{fp_name}.svg'
shutil.copy(out_file, cachefile)
else:
print(f'Re-using cache for {mod_file.name}')
shutil.copy(cachefile, out_svg)
def bulk_populate_kicad_fp_export_cache(pretty_dir):
def cachefile(mod_file):
params = f'(noparams)'.encode()
digest = hashlib.blake2b(mod_file.read_bytes() + params).hexdigest()
return cachedir / f'{digest}.svg'
mod_files = list(pretty_dir.glob('*.kicad_mod'))
hit_rate = statistics.mean([int(cachefile(fn).is_file())
for fn in random.sample(mod_files, min(len(mod_files), 50))])
if hit_rate < 0.9:
#tqdm.tqdm.write(f'Modfile cache is out of date (hit rate {hit_rate*100:.0f}%), re-building entire cache in bulk')
with tempfile.TemporaryDirectory() as tmpdir:
os.chmod(tmpdir, 0o1777)
cmd = ['podman', 'run',
'--rm', # Clean up volumes after exit
'--userns=keep-id', # To allow container to read from bind mount
'--mount', f'type=bind,src={pretty_dir},dst=/{pretty_dir.name}',
'--mount', f'type=bind,src={tmpdir},dst=/out',
KICAD_CONTAINER,
'kicad-cli', 'fp', 'export', 'svg', '--output', '/out', f'/{pretty_dir.name}']
try:
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL)
except subprocess.CalledProcessError as e:
print('Error running command with command line:', ' '.join(e.cmd), file=sys.stderr)
raise e
for fn in mod_files:
out_file = Path(tmpdir) / fn.with_suffix('.svg').name
if not out_file.is_file():
tqdm.tqdm.write(f'Output file {out_file} is missing while bulk re-building cache for {pretty_dir}.')
else:
shutil.copy(out_file, cachefile(fn))
@contextmanager
def svg_soup(filename):
with open(filename, 'r') as f:
soup = bs4.BeautifulSoup(f.read(), 'xml')
yield soup
with open(filename, 'w') as f:
f.write(str(soup))
def cleanup_gerbv_svg(soup):
width = soup.svg["width"]
height = soup.svg["height"]
width = width[:-2] if width.endswith('pt') else width
height = height[:-2] if height.endswith('pt') else height
soup.svg['width'] = f'{float(width)/72*25.4:.4f}mm'
soup.svg['height'] = f'{float(height)/72*25.4:.4f}mm'
for group in soup.find_all('g'):
# gerbv uses Cairo's SVG canvas. Cairo's SVG canvas is kind of broken. It has no support for unit
# handling at all, which means the output files just end up being in pixels at 72 dpi. Further, it
# seems gerbv's aperture macro rendering interacts poorly with Cairo's SVG export. gerbv renders
# aperture macros into a new surface, which for some reason gets clipped by Cairo to the given
# canvas size. This is just wrong, so we just nuke the clip path from these SVG groups here.
#
# Apart from being graphically broken, this additionally causes very bad rendering performance.
del group['clip-path']
def gerber_difference(reference, actual, diff_out=None, svg_transform=None, size=(10,10), ref_unit_spec=None):
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref_svg:
gerbv_export(reference, ref_svg.name, size=size, export_format='svg', override_unit_spec=ref_unit_spec)
gerbv_export(actual, act_svg.name, size=size, export_format='svg')
with svg_soup(ref_svg.name) as soup:
if svg_transform is not None:
svg = soup.svg
children = list(svg.children)
g = soup.new_tag('g', attrs={'transform': svg_transform})
for c in children:
g.append(c.extract())
svg.append(g)
cleanup_gerbv_svg(soup)
with svg_soup(act_svg.name) as soup:
cleanup_gerbv_svg(soup)
return svg_difference(ref_svg.name, act_svg.name, diff_out=diff_out)
def gerber_difference_merge(ref1, ref2, actual, diff_out=None, composite_out=None, svg_transform1=None, svg_transform2=None, size=(10,10)):
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref1_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref2_svg:
gerbv_export(ref1, ref1_svg.name, size=size, export_format='svg')
gerbv_export(ref2, ref2_svg.name, size=size, export_format='svg')
gerbv_export(actual, act_svg.name, size=size, export_format='svg')
for var in ['ref1_svg', 'ref2_svg', 'act_svg']:
print(f'=== {var} ===')
print(Path(locals()[var].name).read_text().splitlines()[1])
with svg_soup(ref1_svg.name) as soup1:
if svg_transform1 is not None:
svg = soup1.svg
children = list(svg.children)
g = soup1.new_tag('g', attrs={'transform': svg_transform1})
for c in children:
g.append(c.extract())
svg.append(g)
cleanup_gerbv_svg(soup1)
with svg_soup(ref2_svg.name) as soup2:
if svg_transform2 is not None:
svg = soup2.svg
children = list(svg.children)
g = soup2.new_tag('g', attrs={'transform': svg_transform2})
for c in children:
g.append(c.extract())
svg.append(g)
cleanup_gerbv_svg(soup2)
defs1 = soup1.find('defs')
if not defs1:
defs1 = soup1.new_tag('defs')
soup1.find('svg').insert(0, defs1)
defs2 = soup2.find('defs')
if defs2:
defs2 = defs2.extract()
# explicitly convert .contents into list here and below because else bs4 stumbles over itself
# iterating because we modify the tree in the loop body.
for c in list(defs2.contents):
if hasattr(c, 'attrs'):
c['id'] = 'gn-merge-b-' + c.attrs.get('id', str(id(c)))
defs1.append(c)
for use in soup2.find_all('use', recursive=True):
if (href := use.get('xlink:href', '')).startswith('#'):
use['xlink:href'] = f'#gn-merge-b-{href[1:]}'
svg1 = soup1.find('svg')
for c in list(soup2.find('svg').contents):
if hasattr(c, 'attrs'):
c['id'] = 'gn-merge-b-' + c.attrs.get('id', str(id(c)))
svg1.append(c)
if composite_out:
shutil.copyfile(ref1_svg.name, composite_out)
with svg_soup(act_svg.name) as soup:
cleanup_gerbv_svg(soup)
return svg_difference(ref1_svg.name, act_svg.name, diff_out=diff_out)
def svg_difference(reference, actual, diff_out=None, background=None, dpi=100):
with tempfile.NamedTemporaryFile(suffix='-ref.png') as ref_png,\
tempfile.NamedTemporaryFile(suffix='-act.png') as act_png:
svg_to_png(reference, ref_png.name, bg=background, dpi=dpi)
svg_to_png(actual, act_png.name, bg=background, dpi=dpi)
return image_difference(ref_png.name, act_png.name, diff_out=diff_out)
def image_difference(reference, actual, diff_out=None):
ref = np.array(Image.open(reference)).astype(float)
out = np.array(Image.open(actual)).astype(float)
ref, out = ref.mean(axis=2), out.mean(axis=2) # convert to grayscale
# TODO blur images here before comparison to mitigate aliasing issue
delta = np.abs(out - ref).astype(float) / 255
if diff_out:
Image.fromarray((delta*255).astype(np.uint8), mode='L').save(diff_out)
hist, _bins = np.histogram(delta, bins=10, range=(0, 1))
return (ImageDifference(delta.mean(), hist),
ImageDifference(delta.max(), hist),
Histogram(hist, out.size))

69
pyproject.toml Normal file
View file

@ -0,0 +1,69 @@
[project]
name = "gerbonara"
version = "1.5.0"
description = "Tools to handle Gerber and Excellon files in Python"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.12"
dependencies = ["click", "rtree", "quart"]
authors = [
{ name = "jaseg" },
{ name = "XenGi" },
]
maintainers = [
{ name = "Gerbonara maintainers", email = "gerbonara@jaseg.de" },
]
keywords = ["gerber", "excellon", "pcb", "RS274x", "EDA"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: Information Technology",
"Intended Audience :: Manufacturing",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: Apache Software License",
"Topic :: Artistic Software",
"Topic :: Multimedia :: Graphics",
"Topic :: Printing",
"Topic :: Scientific/Engineering",
"Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)",
"Topic :: Scientific/Engineering :: Image Processing",
"Topic :: Utilities",
]
[project.urls]
Homepage = "https://jaseg.de/projects/gerbonara/"
Documentation = "https://gerbolyze.gitlab.io/gerbonara/"
Source = "https://git.jaseg.de/gerbonara.git"
Tracker = "https://gitlab.com/gerbolyze/gerbonara/issues"
[project.scripts]
gerbonara = "gerbonara.cli:cli"
[dependency-groups]
dev = [
"pytest",
"pytest-xdist",
"numpy",
"scipy",
"tqdm",
"beautifulsoup4",
"pillow"
]
[build-system]
requires = ["uv-build"]
build-backend = "uv_build"
[tool.pytest]
testpaths = ["tests"]
norecursedirs = ["*"]
kicad_symbols_tag = "9.0.6"
kicad_footprints_tag = "9.0.6"
# Tag to use for container for footprint svg export
# For a list of available tags, see https://hub.docker.com/r/kicad/kicad/tags
kicad_container_tag = "9.0.6-full"

View file

@ -1,3 +0,0 @@
[pytest]
testpaths = gerbonara/tests
norecursedirs=*

View file

@ -1,66 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
from setuptools import setup, find_packages
import subprocess
def version():
try:
res = subprocess.run(['git', 'describe', '--tags', '--match', 'v*'], capture_output=True, check=True, text=True)
version, _, _rest = res.stdout.strip()[1:].partition('-')
return version
except:
subprocess.run(['git', 'describe', '--tags', '--match', 'v*'])
raise
setup(
name='gerbonara',
version=version(),
author='jaseg, XenGi',
author_email='gerbonara@jaseg.de',
description='Tools to handle Gerber and Excellon files in Python',
long_description=Path('README.md').read_text(),
long_description_content_type='text/markdown',
url='https://gitlab.com/gerbolyze/gerbonara',
project_urls={
'Documentation': 'https://gerbolyze.gitlab.io/gerbonara/',
# 'Funding': 'https://donate.pypi.org',
# 'Say Thanks!': 'http://saythanks.io/to/example',
'Source': 'https://gitlab.com/gerbolyze/gerbonara',
'Tracker': 'https://gitlab.com/gerbolyze/gerbonara/issues',
},
packages=find_packages(exclude=['tests']),
include_package_data=True,
install_requires=['click', 'rtree', 'quart'],
entry_points={
'console_scripts': [
'gerbonara = gerbonara.cli:cli',
],
},
classifiers=[
'Development Status :: 4 - Beta',
#'Development Status :: 5 - Production/Stable',
'Environment :: Console',
'Intended Audience :: Developers',
'Intended Audience :: Information Technology',
'Intended Audience :: Manufacturing',
'Intended Audience :: Science/Research',
'License :: OSI Approved :: Apache Software License',
'Natural Language :: English',
'Operating System :: POSIX :: Linux',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Topic :: Artistic Software',
'Topic :: Multimedia :: Graphics',
'Topic :: Printing',
'Topic :: Scientific/Engineering',
'Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)',
'Topic :: Scientific/Engineering :: Image Processing',
'Topic :: Utilities',
'Typing :: Typed',
],
keywords='gerber excellon pcb',
python_requires='>=3.10',
)

View file

@ -30,5 +30,6 @@ from .excellon import ExcellonFile
from .ipc356 import Netlist
from .layers import LayerStack
from .utils import MM, Inch
from importlib.metadata import version
__version__ = '1.5.0'
__version__ = version('gerbonara')

129
tests/conftest.py Normal file
View file

@ -0,0 +1,129 @@
import os
from pathlib import Path
import tqdm
import multiprocessing.pool
import subprocess
from itertools import chain
import pytest
from .image_support import ImageDifference, run_cargo_cmd, ImageSupport
@pytest.fixture
def kicad_container(request):
return request.config.kicad_container
@pytest.fixture()
def kicad_footprints_libdir(request):
return request.config.kicad_footprints_libdir
@pytest.fixture()
def kicad_symbols_libdir(request):
return request.config.kicad_symbols_libdir
@pytest.fixture()
def img_support(request):
return request.config.image_support
def pytest_assertrepr_compare(op, left, right):
if isinstance(left, ImageDifference) or isinstance(right, ImageDifference):
diff = left if isinstance(left, ImageDifference) else right
return [
f'Image difference assertion failed.',
f' Calculated difference: {diff}',
f' Histogram: {diff.histogram}', ]
# store report in node object so tmp_gbr can determine if the test failed.
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
setattr(item, f'rep_{rep.when}', rep)
fail_dir = Path('gerbonara_test_failures')
def pytest_sessionstart(session):
if 'PYTEST_XDIST_WORKER' in os.environ: # only run this on the controller
return
for f in chain(fail_dir.glob('*.gbr'), fail_dir.glob('*.png')):
f.unlink()
try:
run_cargo_cmd('resvg', '--help', stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except FileNotFoundError:
pytest.exit('resvg binary not found, aborting test.', 2)
def _update_repo_cache(lib_dir, repo_url, tag):
if not lib_dir.is_dir():
print(f'Checking out KiCad footprint repo tag {tag}')
subprocess.run(['git', '-c', 'advice.detachedHead=false', 'clone', '--branch', tag, '--depth', '1', repo_url, str(lib_dir)], check=True)
else:
print(f'Found cached KiCad footprint checkout, updating to {tag}')
subprocess.run(['git', '-C', str(lib_dir), 'fetch', '--depth', '1', 'origin', tag], check=True)
subprocess.run(['git', '-c', 'advice.detachedHead=false', '-C', str(lib_dir), 'reset', '--hard', tag], check=True)
subprocess.run(['git', '-C', str(lib_dir), 'clean', '--force', '-d', '-x'], check=True)
def pytest_addoption(parser):
parser.addini('kicad_footprints_tag', 'git tag or branch for KiCad footprint library repo used as testdata', default='main')
parser.addini('kicad_symbols_tag', 'git tag or branch for KiCad symbol library repo used as testdata', default='main')
parser.addini('kicad_container_tag', 'docker hub tag for the KiCad container to use for exporting footprint images', default='main')
def pytest_configure(config):
os.nice(20)
# Resvg can sometimes consume a lot of memory. Make sure we don't kill the user's session.
if (oom_adj := Path('/proc/self/oom_adj')).is_file():
oom_adj.write_text('15\n')
if 'PYTEST_XDIST_WORKER' in os.environ: # only run this on the controller
return
if (lib_dir := os.environ.get('KICAD_FOOTPRINTS')):
config.kicad_footprints_libdir = Path(lib_dir).expanduser()
else:
config.kicad_footprints_libdir = config.cache.mkdir('kicad-footprints') / 'repo'
if (lib_dir := os.environ.get('KICAD_SYMBOLS')):
config.kicad_symbols_libdir = Path(lib_dir).expanduser()
else:
config.kicad_symbols_libdir = config.cache.mkdir('kicad-symbols') / 'repo'
# Update cached library repos unless they are overridden from outside.
if not os.environ.get('KICAD_FOOTPRINTS'):
tag = config.getini('kicad_footprints_tag')
_update_repo_cache(config.kicad_footprints_libdir, 'https://gitlab.com/kicad/libraries/kicad-footprints', tag)
if not os.environ.get('KICAD_SYMBOLS'):
tag = config.getini('kicad_symbols_tag')
_update_repo_cache(config.kicad_symbols_libdir, 'https://gitlab.com/kicad/libraries/kicad-symbols', tag)
print('Updating podman image')
tag = config.getini("kicad_container_tag")
config.kicad_container = os.environ.get('KICAD_CONTAINER', f'registry.hub.docker.com/kicad/kicad:{tag}')
subprocess.run(['podman', 'pull', config.kicad_container], check=True)
config.image_support = ImageSupport(config.cache.mkdir('image_cache'), config.kicad_container)
print('Checking KiCad footprint library render cache')
with multiprocessing.pool.ThreadPool() as pool: # use thread pool here since we're only monitoring podman processes
lib_dirs = list(config.kicad_footprints_libdir.glob('*.pretty'))
res = list(tqdm.tqdm(pool.imap(lambda path: config.image_support.bulk_populate_kicad_fp_export_cache(path), lib_dirs), total=len(lib_dirs)))
def pytest_generate_tests(metafunc):
if 'kicad_library_file' in metafunc.fixturenames:
library_files = list(kicad_symbols_libdir(metafunc.config).glob('*.kicad_sym'))
metafunc.parametrize('kicad_library_file', library_files, ids=list(map(str, library_files)))
if 'kicad_mod_file' in metafunc.fixturenames:
mod_files = list(kicad_footprints_libdir(metafunc.config).glob('*.pretty/*.kicad_mod'))
metafunc.parametrize('kicad_mod_file', mod_files, ids=list(map(str, mod_files)))

View file

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 556 B

After

Width:  |  Height:  |  Size: 556 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

372
tests/image_support.py Normal file
View file

@ -0,0 +1,372 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Based on https://github.com/tracespace/tracespace
#
import subprocess
from pathlib import Path
import tempfile
import textwrap
import os
import sys
import stat
import random
import statistics
from functools import total_ordering
import shutil
import bs4
from contextlib import contextmanager
import hashlib
import tqdm
import numpy as np
from PIL import Image
@total_ordering
class ImageDifference:
def __init__(self, value, histogram):
self.value = value
self.histogram = histogram
def __float__(self):
return float(self.value)
def __eq__(self, other):
return float(self) == float(other)
def __lt__(self, other):
return float(self) < float(other)
def __str__(self):
return str(float(self))
@total_ordering
class Histogram:
def __init__(self, value, size):
self.value, self.size = value, size
def __eq__(self, other):
other = np.array(other)
other[other == None] = self.value[other == None]
return (self.value == other).all()
def __lt__(self, other):
other = np.array(other)
other[other == None] = self.value[other == None]
return (self.value <= other).all()
def __getitem__(self, index):
return self.value[index]
def __str__(self):
return f'{list(self.value)} size={self.size}'
def run_cargo_cmd(cmd, args, **kwargs):
if cmd.upper() in os.environ:
return subprocess.run([os.environ[cmd.upper()], *args], **kwargs)
try:
return subprocess.run([cmd, *args], **kwargs)
except FileNotFoundError:
return subprocess.run([str(Path.home() / '.cargo' / 'bin' / cmd), *args], **kwargs)
to_gerbv_svg_units = lambda val, unit='mm': val*72 if unit == 'inch' else val/25.4*72
@contextmanager
def svg_soup(filename):
with open(filename, 'r') as f:
soup = bs4.BeautifulSoup(f.read(), 'xml')
yield soup
with open(filename, 'w') as f:
f.write(str(soup))
class ImageSupport:
def __init__(self, cachedir, kicad_container):
self.cache_root = cachedir
self.kicad_container = kicad_container
def cachedir(self, scope, filename, suffix):
return self.cache_root / scope / f'{filename}.{suffix}'
def svg_to_png(self, in_svg, out_png, dpi=100, bg=None):
params = f'{dpi}{bg}'.encode()
digest = hashlib.blake2b(Path(in_svg).read_bytes() + params).hexdigest()
cachefile = self.cachedir('svg_render', digest, 'png')
if not cachefile.is_file():
bg = 'black' if bg is None else bg
run_cargo_cmd('resvg', ['--background', bg, '--dpi', str(dpi), in_svg, cachefile], check=True, stdout=subprocess.DEVNULL)
shutil.copy(cachefile, out_png)
def gerbv_export(self, in_gbr, out_svg, export_format='svg', origin=(0, 0), size=(6, 6), fg='#ffffff', bg='#000000', override_unit_spec=None):
params = f'{origin}{size}{fg}{bg}'.encode()
digest = hashlib.blake2b(Path(in_gbr).read_bytes() + params).hexdigest()
cachefile = self.cachedir('gerbv-export', digest, 'svg')
if not cachefile.is_file():
print(f'Building cache for {Path(in_gbr).name}')
# NOTE: gerbv seems to always export 'clear' polarity apertures as white, irrespective of --foreground, --background
# and project file color settings.
# TODO: File issue upstream.
with tempfile.NamedTemporaryFile('w') as f:
if override_unit_spec:
units, zeros, digits = override_unit_spec
print(f'{Path(in_gbr).name}: overriding excellon unit spec to {units=} {zeros=} {digits=}')
units = 0 if units == 'inch' else 1
zeros = {None: 0, 'leading': 1, 'trailing': 2}[zeros]
unit_spec = textwrap.dedent(f'''(cons 'attribs (list
(list 'autodetect 'Boolean 0)
(list 'zero_suppression 'Enum {zeros})
(list 'units 'Enum {units})
(list 'digits 'Integer {digits})
))''')
else:
unit_spec = ''
r, g, b = int(fg[1:3], 16), int(fg[3:5], 16), int(fg[5:], 16)
color = f"(cons 'color #({r*257} {g*257} {b*257}))"
f.write(f'''(gerbv-file-version! "2.0A")(define-layer! 0 (cons 'filename "{in_gbr}"){unit_spec}{color})''')
f.flush()
if override_unit_spec:
shutil.copy(f.name, '/tmp/foo.gbv')
x, y = origin
w, h = size
cmd = ['gerbv', '-x', export_format,
'--border=0',
f'--origin={x:.6f}x{y:.6f}', f'--window_inch={w:.6f}x{h:.6f}',
f'--background={bg}',
f'--foreground={fg}',
'-o', str(cachefile), '-p', f.name]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
print(f'Re-using cache for {Path(in_gbr).name}')
shutil.copy(cachefile, out_svg)
def kicad_fp_export(self, mod_file, out_svg):
mod_file = Path(mod_file)
if mod_file.suffix.lower() != '.kicad_mod':
raise ValueError("KiCad footprint file must have .kicad_mod extension for kicad-cli to do it's thing")
params = f'(noparams)'.encode()
digest = hashlib.blake2b(mod_file.read_bytes() + params).hexdigest()
cachefile = self.cachedir('kicad-footprint-export', digest, 'svg')
if not cachefile.is_file():
print(f'Building cache for {mod_file.name}')
with tempfile.TemporaryDirectory() as tmpdir:
os.chmod(tmpdir, 0o1777)
pretty_dir = mod_file.parent
fp_name = mod_file.name[:-len('.kicad_mod')]
cmd = ['podman', 'run',
'--rm', # Clean up volumes after exit
'--userns=keep-id', # To allow container to read from bind mount
'--mount', f'type=bind,src={pretty_dir},dst=/{pretty_dir.name}',
'--mount', f'type=bind,src={tmpdir},dst=/out',
self.kicad_container,
'kicad-cli', 'fp', 'export', 'svg', '--output', '/out', '--footprint', fp_name, f'/{pretty_dir.name}']
subprocess.run(cmd, check=True) #, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
out_file = Path(tmpdir) / f'{fp_name}.svg'
shutil.copy(out_file, cachefile)
else:
print(f'Re-using cache for {mod_file.name}')
shutil.copy(cachefile, out_svg)
def bulk_populate_kicad_fp_export_cache(self, pretty_dir):
def cachefile(mod_file):
params = f'(noparams)'.encode()
digest = hashlib.blake2b(mod_file.read_bytes() + params).hexdigest()
return self.cachedir('kicad-footprint-export', digest, 'svg')
mod_files = list(pretty_dir.glob('*.kicad_mod'))
hit_rate = statistics.mean([int(cachefile(fn).is_file())
for fn in random.sample(mod_files, min(len(mod_files), 50))])
if hit_rate < 0.9:
#tqdm.tqdm.write(f'Modfile cache is out of date (hit rate {hit_rate*100:.0f}%), re-building entire cache in bulk')
with tempfile.TemporaryDirectory() as tmpdir:
os.chmod(tmpdir, 0o1777)
cmd = ['podman', 'run',
'--rm', # Clean up volumes after exit
'--userns=keep-id', # To allow container to read from bind mount
'--mount', f'type=bind,src={pretty_dir},dst=/{pretty_dir.name}',
'--mount', f'type=bind,src={tmpdir},dst=/out',
self.kicad_container,
'kicad-cli', 'fp', 'export', 'svg', '--output', '/out', f'/{pretty_dir.name}']
print(f'Running "{" ".join(cmd)}"')
try:
subprocess.run(cmd, check=True) #, stdout=subprocess.DEVNULL)
except subprocess.CalledProcessError as e:
print('Error running command with command line:', ' '.join(e.cmd), file=sys.stderr)
raise e
for fn in mod_files:
out_file = Path(tmpdir) / fn.with_suffix('.svg').name
if not out_file.is_file():
tqdm.tqdm.write(f'Output file {out_file} is missing while bulk re-building cache for {pretty_dir}.')
else:
shutil.copy(out_file, cachefile(fn))
def cleanup_gerbv_svg(self, soup):
width = soup.svg["width"]
height = soup.svg["height"]
width = width[:-2] if width.endswith('pt') else width
height = height[:-2] if height.endswith('pt') else height
soup.svg['width'] = f'{float(width)/72*25.4:.4f}mm'
soup.svg['height'] = f'{float(height)/72*25.4:.4f}mm'
for group in soup.find_all('g'):
# gerbv uses Cairo's SVG canvas. Cairo's SVG canvas is kind of broken. It has no support for unit
# handling at all, which means the output files just end up being in pixels at 72 dpi. Further, it
# seems gerbv's aperture macro rendering interacts poorly with Cairo's SVG export. gerbv renders
# aperture macros into a new surface, which for some reason gets clipped by Cairo to the given
# canvas size. This is just wrong, so we just nuke the clip path from these SVG groups here.
#
# Apart from being graphically broken, this additionally causes very bad rendering performance.
del group['clip-path']
def gerber_difference(self, reference, actual, diff_out=None, svg_transform=None, size=(10,10), ref_unit_spec=None):
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref_svg:
self.gerbv_export(reference, ref_svg.name, size=size, export_format='svg', override_unit_spec=ref_unit_spec)
self.gerbv_export(actual, act_svg.name, size=size, export_format='svg')
with svg_soup(ref_svg.name) as soup:
if svg_transform is not None:
svg = soup.svg
children = list(svg.children)
g = soup.new_tag('g', attrs={'transform': svg_transform})
for c in children:
g.append(c.extract())
svg.append(g)
self.cleanup_gerbv_svg(soup)
with svg_soup(act_svg.name) as soup:
self.cleanup_gerbv_svg(soup)
return self.svg_difference(ref_svg.name, act_svg.name, diff_out=diff_out)
def gerber_difference_merge(self, ref1, ref2, actual, diff_out=None, composite_out=None, svg_transform1=None, svg_transform2=None, size=(10,10)):
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref1_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref2_svg:
self.gerbv_export(ref1, ref1_svg.name, size=size, export_format='svg')
self.gerbv_export(ref2, ref2_svg.name, size=size, export_format='svg')
self.gerbv_export(actual, act_svg.name, size=size, export_format='svg')
for var in ['ref1_svg', 'ref2_svg', 'act_svg']:
print(f'=== {var} ===')
print(Path(locals()[var].name).read_text().splitlines()[1])
with svg_soup(ref1_svg.name) as soup1:
if svg_transform1 is not None:
svg = soup1.svg
children = list(svg.children)
g = soup1.new_tag('g', attrs={'transform': svg_transform1})
for c in children:
g.append(c.extract())
svg.append(g)
self.cleanup_gerbv_svg(soup1)
with svg_soup(ref2_svg.name) as soup2:
if svg_transform2 is not None:
svg = soup2.svg
children = list(svg.children)
g = soup2.new_tag('g', attrs={'transform': svg_transform2})
for c in children:
g.append(c.extract())
svg.append(g)
self.cleanup_gerbv_svg(soup2)
defs1 = soup1.find('defs')
if not defs1:
defs1 = soup1.new_tag('defs')
soup1.find('svg').insert(0, defs1)
defs2 = soup2.find('defs')
if defs2:
defs2 = defs2.extract()
# explicitly convert .contents into list here and below because else bs4 stumbles over itself
# iterating because we modify the tree in the loop body.
for c in list(defs2.contents):
if hasattr(c, 'attrs'):
c['id'] = 'gn-merge-b-' + c.attrs.get('id', str(id(c)))
defs1.append(c)
for use in soup2.find_all('use', recursive=True):
if (href := use.get('xlink:href', '')).startswith('#'):
use['xlink:href'] = f'#gn-merge-b-{href[1:]}'
svg1 = soup1.find('svg')
for c in list(soup2.find('svg').contents):
if hasattr(c, 'attrs'):
c['id'] = 'gn-merge-b-' + c.attrs.get('id', str(id(c)))
svg1.append(c)
if composite_out:
shutil.copyfile(ref1_svg.name, composite_out)
with svg_soup(act_svg.name) as soup:
self.cleanup_gerbv_svg(soup)
return self.svg_difference(ref1_svg.name, act_svg.name, diff_out=diff_out)
def svg_difference(self, reference, actual, diff_out=None, background=None, dpi=100):
with tempfile.NamedTemporaryFile(suffix='-ref.png') as ref_png,\
tempfile.NamedTemporaryFile(suffix='-act.png') as act_png:
svg_to_png(reference, ref_png.name, bg=background, dpi=dpi)
svg_to_png(actual, act_png.name, bg=background, dpi=dpi)
return self.image_difference(ref_png.name, act_png.name, diff_out=diff_out)
def image_difference(self, reference, actual, diff_out=None):
ref = np.array(Image.open(reference)).astype(float)
out = np.array(Image.open(actual)).astype(float)
ref, out = ref.mean(axis=2), out.mean(axis=2) # convert to grayscale
# TODO blur images here before comparison to mitigate aliasing issue
delta = np.abs(out - ref).astype(float) / 255
if diff_out:
Image.fromarray((delta*255).astype(np.uint8), mode='L').save(diff_out)
hist, _bins = np.histogram(delta, bins=10, range=(0, 1))
return (ImageDifference(delta.mean(), hist),
ImageDifference(delta.max(), hist),
Histogram(hist, out.size))

Some files were not shown because too many files have changed in this diff Show more