From d43eff8b49022719b2933e8429a05e3a35b8762f Mon Sep 17 00:00:00 2001 From: jaseg Date: Thu, 23 Feb 2023 23:52:29 +0100 Subject: Extend CLI tests --- gerbonara/cli.py | 52 ++++++++++++----------- gerbonara/rs274x.py | 4 +- gerbonara/tests/test_cli.py | 93 +++++++++++++++++++++++++++++++++++++++++- gerbonara/tests/test_rs274x.py | 4 +- 4 files changed, 123 insertions(+), 30 deletions(-) diff --git a/gerbonara/cli.py b/gerbonara/cli.py index 845e44a..2025726 100644 --- a/gerbonara/cli.py +++ b/gerbonara/cli.py @@ -29,7 +29,7 @@ from pathlib import Path from .utils import MM, Inch from .cam import FileSettings from .rs274x import GerberFile -from .layers import LayerStack, NamingScheme +from . import layers as lyr from . import __version__ @@ -54,7 +54,7 @@ def apply_transform(transform, unit, layer_or_stack): layer_or_stack.scale(factor) def rotate(angle, cx=0, cy=0): - layer_or_stack.rotate(math.radians(angle), (cx, cy), unit) + layer_or_stack.rotate(math.radians(angle), cx, cy, unit) (x_min, y_min), (x_max, y_max) = layer_or_stack.bounding_box(unit, default=((0, 0), (0, 0))) width, height = x_max - x_min, y_max - y_min @@ -76,7 +76,7 @@ class Coordinate(click.ParamType): def convert(self, value, param, ctx): try: - coords = map(float, value.split(',')) + coords = [float(e) for e in value.split(',')] if len(coords) != self.dimension: raise ValueError() return coords @@ -89,7 +89,7 @@ class Rotation(click.ParamType): def convert(self, value, param, ctx): try: - coords = map(float, value.split(',')) + coords = [float(e) for e in value.split(',')] if len(coords) not in (1, 3): raise ValueError() @@ -115,10 +115,10 @@ class NamingScheme(click.Choice): name = 'naming_scheme' def __init__(self): - super().__init__([n for n in dir(NamingScheme) if not n.startswith('_')]) + super().__init__([n for n in dir(lyr.NamingScheme) if not n.startswith('_')]) def convert(self, value, param, ctx): - return getattr(NamingScheme, super().convert(value, param, ctx)) + return getattr(lyr.NamingScheme, super().convert(value, param, ctx)) @click.group() @@ -161,9 +161,9 @@ def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules, with warnings.catch_warnings(): warnings.simplefilter(format_warnings) if force_zip: - stack = LayerStack.open_zip(inpath, overrides=overrides, autoguess=use_builtin_name_rules) + stack = lyr.LayerStack.open_zip(inpath, overrides=overrides, autoguess=use_builtin_name_rules) else: - stack = LayerStack.open(inpath, overrides=overrides, autoguess=use_builtin_name_rules) + stack = lyr.LayerStack.open(inpath, overrides=overrides, autoguess=use_builtin_name_rules) if force_bounds: min_x, min_y, max_x, max_y = list(map(float, force_bounds.split(','))) @@ -210,10 +210,10 @@ def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules, @click.argument('outfile') def rewrite(transform, command_line_units, number_format, units, zero_suppression, keep_comments, output_format, input_number_format, input_units, input_zero_suppression, infile, outfile, format_warnings): - """ Parse a gerber file, apply transformations, and re-serialize it into a new gerber file. Without transformations, - this command can be used to convert a gerber file to use different settings (e.g. units, precision), but can also be - used to "normalize" gerber files in a weird format into a more standards-compatible one as gerbonara's gerber parser - is significantly more robust for weird inputs than others. """ + """ Parse a single gerber file, apply transformations, and re-serialize it into a new gerber file. Without + transformations, this command can be used to convert a gerber file to use different settings (e.g. units, + precision), but can also be used to "normalize" gerber files in a weird format into a more standards-compatible one + as gerbonara's gerber parser is significantly more robust for weird inputs than others. """ input_settings = FileSettings() if input_number_format: @@ -232,12 +232,13 @@ def rewrite(transform, command_line_units, number_format, units, zero_suppressio if transform: apply_transform(transform, command_line_units or MM, f) - output_format = FileSettings() if output_format == 'reuse' else FileSettings.defaults() + output_format = f.import_settings if output_format == 'reuse' else FileSettings.defaults() if number_format: a, _, b = number_format.partition('.') output_format.number_format = (int(a), int(b)) - output_format.unit = units + if units: + output_format.unit = units if zero_suppression: output_format.zeros = None if zero_suppression == 'off' else zero_suppression @@ -286,13 +287,13 @@ def transform(transform, units, output_format, inpath, outpath, with warnings.catch_warnings(): warnings.simplefilter(format_warnings) if force_zip: - stack = LayerStack.open_zip(path, overrides=overrides, autoguess=use_builtin_name_rules) + stack = lyr.LayerStack.open_zip(path, overrides=overrides, autoguess=use_builtin_name_rules) else: - stack = LayerStack.open(path, overrides=overrides, autoguess=use_builtin_name_rules) + stack = lyr.LayerStack.open(path, overrides=overrides, autoguess=use_builtin_name_rules) apply_transform(transform, units, stack) - output_format = FileSettings() if output_format == 'reuse' else FileSettings.defaults() + output_format = None if output_format == 'reuse' else FileSettings.defaults() stack.save_to_directory(outpath, naming_scheme=output_naming_scheme or {}, gerber_settings=output_format, excellon_settings=dataclasses.replace(output_format, zeros=None)) @@ -343,13 +344,13 @@ def merge(inpath, outpath, offset, rotation, input_map, command_line_units, outp raise click.UsageError('More --offset, --rotation or --input-map options than input files') offset = offset or (0, 0) - theta, cx, cy = rotation or 0, 0, 0 + theta, cx, cy = rotation or (0, 0, 0) overrides = json.loads(input_map.read_bytes()) if input_map else None with warnings.catch_warnings(): warnings.simplefilter(format_warnings) - stack = LayerStack.open(p, overrides=overrides, autoguess=use_builtin_name_rules) + stack = lyr.LayerStack.open(p, overrides=overrides, autoguess=use_builtin_name_rules) if not math.isclose(offset[0], 0, abs_tol=1e-3) and math.isclose(offset[1], 0, abs_tol=1e-3): stack.offset(*offset, command_line_units or MM) @@ -366,7 +367,7 @@ def merge(inpath, outpath, offset, rotation, input_map, command_line_units, outp if not output_naming_scheme: warnings.warn('--output-board-name given without --output-naming-scheme. This will be ignored.') target.board_name = output_board_name - output_format = FileSettings() if output_format == 'reuse' else FileSettings.defaults() + output_format = None if output_format == 'reuse' else FileSettings.defaults() target.save_to_directory(outpath, naming_scheme=output_naming_scheme or {}, gerber_settings=output_format, excellon_settings=dataclasses.replace(output_format, zeros=None)) @@ -406,17 +407,19 @@ def bounding_box(infile, format_warnings, input_number_format, input_units, inpu @cli.command() +@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True) @click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default', help='''Enable or disable file format warnings during parsing (default: on)''') @click.option('--force-zip', is_flag=True, help='Force treating input path as zip file (default: guess file type from extension and contents)') @click.argument('path', type=click.Path(exists=True)) def layers(path, force_zip, format_warnings): + """ Read layers from a directory or zip with Gerber files and list the found layer / path assignment. """ with warnings.catch_warnings(): warnings.simplefilter(format_warnings) if force_zip: - stack = LayerStack.open_zip(path) + stack = lyr.LayerStack.open_zip(path) else: - stack = LayerStack.open(path) + stack = lyr.LayerStack.open(path) print(f'Detected board name: {stack.board_name}') print(f'Probably exported by: {stack.generator or "Unknown"}') @@ -441,6 +444,7 @@ def layers(path, force_zip, format_warnings): @cli.command() +@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True) @click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), help='''Enable or disable file format warnings during parsing (default: on)''') @click.option('--force-zip', is_flag=True, help='Force treating input path as zip file (default: guess file type from extension and contents)') @@ -452,9 +456,9 @@ def meta(path, force_zip, format_warnings): with warnings.catch_warnings(): warnings.simplefilter(format_warnings) if force_zip: - stack = LayerStack.open_zip(path) + stack = lyr.LayerStack.open_zip(path) else: - stack = LayerStack.open(path) + stack = lyr.LayerStack.open(path) out = {} out['board_name'] = stack.board_name diff --git a/gerbonara/rs274x.py b/gerbonara/rs274x.py index 8c6d9ae..062f246 100644 --- a/gerbonara/rs274x.py +++ b/gerbonara/rs274x.py @@ -292,7 +292,7 @@ class GerberFile(CamFile): for obj in self.objects: obj.offset(dx, dy, unit) - def rotate(self, angle:'radian', center=(0,0), unit=MM): + def rotate(self, angle:'radian', cx=0, cy=0, unit=MM): if math.isclose(angle % (2*math.pi), 0): return @@ -302,7 +302,7 @@ class GerberFile(CamFile): ap.rotation += angle for obj in self.objects: - obj.rotate(angle, *center, unit) + obj.rotate(angle, cx, cy, unit) def invert_polarity(self): """ Invert the polarity (color) of each object in this file. """ diff --git a/gerbonara/tests/test_cli.py b/gerbonara/tests/test_cli.py index e085894..d5a0ad4 100644 --- a/gerbonara/tests/test_cli.py +++ b/gerbonara/tests/test_cli.py @@ -21,18 +21,34 @@ from pathlib import Path import re import tempfile import json +from unittest import mock import pytest from click.testing import CliRunner from bs4 import BeautifulSoup from .utils import * -from ..cli import render, rewrite, transform, merge, bounding_box, layers, meta +from .. import cli +from ..utils import MM + + +@pytest.fixture() +def file_mock(): + old = cli.GerberFile + c_obj = cli.GerberFile = mock.Mock() + i_obj = c_obj.open.return_value = mock.Mock() + i_obj.bounding_box.return_value = (0, 0), (50, 100) + yield i_obj + cli.GerberFile = old + class TestRender: def invoke(self, *args): runner = CliRunner() - res = runner.invoke(render, list(map(str, args))) + res = runner.invoke(cli.render, list(map(str, args))) + print(res.output) + if res.exception: + raise res.exception assert res.exit_code == 0 return res.output @@ -115,3 +131,76 @@ class TestRender: assert len(colors_without) == len(colors_with) assert colors_with - {'#67890a'} == set(test_colorscheme.values()) - {'#67890abc'} + +class TestRewrite: + def invoke(self, *args): + runner = CliRunner() + res = runner.invoke(cli.rewrite, list(map(str, args))) + print(res.output) + if res.exception: + raise res.exception + assert res.exit_code == 0 + return res.output + + def test_basic(self): + assert self.invoke('--version').startswith('Version ') + + @pytest.mark.parametrize('reference', ['example_flash_obround.gbr'], indirect=True) + def test_transforms(self, reference, file_mock): + with tempfile.NamedTemporaryFile() as tmpout: + self.invoke(reference, tmpout.name, '--transform', 'rotate(90); translate(10, 10); rotate(-45.5); scale(2)') + file_mock.rotate.assert_has_calls([ + mock.call(math.radians(90), 0, 0, MM), + mock.call(math.radians(-45.5), 0, 0, MM)]) + file_mock.offset.assert_called_with(10, 10, MM) + file_mock.scale.assert_called_with(2) + assert file_mock.save.called + assert file_mock.save.call_args[0][0] == tmpout.name + + @pytest.mark.parametrize('reference', ['example_flash_obround.gbr'], indirect=True) + def test_real_invocation(self, reference): + with tempfile.NamedTemporaryFile() as tmpout: + self.invoke(reference, tmpout.name, '--transform', 'rotate(45); translate(10, 0)') + assert tmpout.read() + + +class TestMerge: + def invoke(self, *args): + runner = CliRunner() + res = runner.invoke(cli.merge, list(map(str, args))) + if res.exception: + raise res.exception + assert res.exit_code == 0 + return res.output + + def test_basic(self): + assert self.invoke('--version').startswith('Version ') + + @pytest.mark.parametrize('file_a', ['kicad-older']) + @pytest.mark.parametrize('file_b', ['eagle-newer']) + def test_real_invocation(self, file_a, file_b): + with tempfile.TemporaryDirectory() as outdir: + self.invoke(reference_path(file_a), '--rotation', '90', '--offset', '0,0', + reference_path(file_b), '--offset', '100,100', '--rotation', '0', + outdir, '--output-naming-scheme', 'kicad', '--output-board-name', 'foobar', + '--warnings', 'ignore') + assert (Path(outdir) / 'foobar-F.Cu.gbr').exists() + + +class TestMeta: + def invoke(self, *args): + runner = CliRunner() + res = runner.invoke(cli.meta, list(map(str, args))) + print(res.output) + if res.exception: + raise res.exception + assert res.exit_code == 0 + return res.output + + def test_basic(self): + assert self.invoke('--version').startswith('Version ') + + @pytest.mark.parametrize('reference', ['example_flash_obround.gbr'], indirect=True) + def test_real_invocation(self, reference): + j = json.loads(self.invoke(reference, '--warnings', 'ignore')) + diff --git a/gerbonara/tests/test_rs274x.py b/gerbonara/tests/test_rs274x.py index 0807bfd..2fb51d9 100644 --- a/gerbonara/tests/test_rs274x.py +++ b/gerbonara/tests/test_rs274x.py @@ -336,7 +336,7 @@ def test_rotation_center(reference, angle, center, tmpfile): tmp_gbr = tmpfile('Output gerber', '.gbr') f = GerberFile.open(reference) - f.rotate(math.radians(angle), center=center) + f.rotate(math.radians(angle), *center) f.save(tmp_gbr) # calculate circle center in SVG coordinates @@ -379,7 +379,7 @@ def test_combined(reference, angle, center, offset, tmpfile): tmp_gbr = tmpfile('Output gerber', '.gbr') f = GerberFile.open(reference) - f.rotate(math.radians(angle), center=center) + f.rotate(math.radians(angle), *center) f.offset(*offset) f.save(tmp_gbr, settings=FileSettings(unit=f.unit, number_format=(4,7))) -- cgit