Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: lswap and lreverse commands #300

Merged
merged 4 commits into from
Jul 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
#### 1.8 (UNRELEASED)

New features and improvements:
* ...
* Added `lswap` command to swap the content of two layers (#300)
* Added `lreverse` command to reverse the order of paths within a layer (#300)

Bug fixes:
* ...
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ and much more.
#### General

- Easy to use **CLI** interface with integrated help (`vpype --help`and `vpype COMMAND --help`) and support for arbitrary units (e.g. `vpype read input.svg translate 3cm 2in`).
- First-class **multi-layer support** with global or per-layer processing (e.g. `vpype COMMANDNAME --layer 1,3`) and optionally-probabilistic layer edition commands ([`lmove`](https://vpype.readthedocs.io/en/stable/reference.html#lmove), [`lcopy`](https://vpype.readthedocs.io/en/stable/reference.html#lcopy), [`ldelete`](https://vpype.readthedocs.io/en/stable/reference.html#ldelete)).
- First-class **multi-layer support** with global or per-layer processing (e.g. `vpype COMMANDNAME --layer 1,3`) and optionally-probabilistic layer edition commands ([`lmove`](https://vpype.readthedocs.io/en/stable/reference.html#lmove), [`lcopy`](https://vpype.readthedocs.io/en/stable/reference.html#lcopy), [`ldelete`](https://vpype.readthedocs.io/en/stable/reference.html#ldelete), [`lswap`](https://vpype.readthedocs.io/en/stable/reference.html#lswap), [`lreverse`](https://vpype.readthedocs.io/en/stable/reference.html#lreverse)).
- Powerful hardware-accelerated **display** command with adjustable units, optional per-line coloring, optional pen-up trajectories display and per-layer visibility control ([`show`](https://vpype.readthedocs.io/en/stable/reference.html#show)).
- Geometry **statistics** extraction ([`stat`](https://vpype.readthedocs.io/en/stable/reference.html#stat)).
- Support for **command history** recording (`vpype -H [...]`)
Expand Down
425 changes: 218 additions & 207 deletions poetry.lock

Large diffs are not rendered by default.

24 changes: 12 additions & 12 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,19 @@ sphinx-rtd-theme = { version = "^0.5.0", optional = true }
recommonmark = { version = ">=0.6,<0.8", optional = true }

[tool.poetry.dev-dependencies]
coverage = {extras = ["toml"], version = "^5.4"}
pytest = "^6.2.3"
pytest-cov = "^2.11.0"
pytest-benchmark = "^3.2.3"
black = "^21.5b1"
isort = "^5.8.0"
coverage = {extras = ["toml"], version = ">=5.4"}
pytest = ">=6.2.3"
pytest-cov = ">=2.11.0"
pytest-benchmark = ">=3.2.3"
black = ">=21.5b2"
isort = ">=5.8.0"
pyinstaller = "^4.3"
packaging = "^20.8"
pytest-mpl = "^0.12"
mypy = "^0.901"
types-cachetools = "^0.1.6"
types-toml = "^0.1.1"
types-pkg-resources = "^0.1.2"
packaging = ">=20.8"
pytest-mpl = ">=0.12"
mypy = ">=0.901"
types-cachetools = ">=0.1.6"
types-toml = ">=0.1.1"
types-pkg-resources = ">=0.1.2"

[tool.poetry.extras]
docs = ["Sphinx", "sphinx-click", "sphinx-autodoc-typehints", "sphinx-rtd-theme", "recommonmark"]
Expand Down
148 changes: 84 additions & 64 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import itertools
from dataclasses import dataclass

import numpy as np
import pytest
Expand All @@ -12,45 +13,58 @@

EXAMPLE_SVG = TESTS_DIRECTORY / "data" / "test_svg" / "svg_width_height" / "percent_size.svg"


@dataclass
class Command:
command: str
exit_code_no_layer: int
exit_code_one_layer: int
exit_code_two_layers: int


MINIMAL_COMMANDS = [
"begin grid 2 2 line 0 0 10 10 end",
"begin repeat 2 line 0 0 10 10 end",
"frame",
"random",
"line 0 0 1 1",
"rect 0 0 1 1",
"arc 0 0 1 1 0 90",
"circle 0 0 1",
"ellipse 0 0 2 4",
f"read '{EXAMPLE_SVG}'",
f"read -m '{EXAMPLE_SVG}'",
"write -f svg -",
"write -f hpgl -d hp7475a -p a4 -",
"rotate 0",
"scale 1 1",
"scaleto 10cm 10cm",
"skew 0 0",
"translate 0 0",
"crop 0 0 1 1",
"linesort",
"linesort --two-opt",
"linemerge",
"linesimplify",
"multipass",
"reloop",
"lmove 1 new",
"lcopy 1 new",
"ldelete 1",
"trim 1mm 1mm",
"splitall",
"filter --min-length 1mm",
"pagesize 10inx15in",
"stat",
"snap 1",
"reverse",
"layout a4",
"squiggles",
"text 'hello wold'",
Command("begin grid 2 2 line 0 0 10 10 end", 0, 0, 0),
Command("begin repeat 2 line 0 0 10 10 end", 0, 0, 0),
Command("frame", 0, 0, 0),
Command("random", 0, 0, 0),
Command("line 0 0 1 1", 0, 0, 0),
Command("rect 0 0 1 1", 0, 0, 0),
Command("arc 0 0 1 1 0 90", 0, 0, 0),
Command("circle 0 0 1", 0, 0, 0),
Command("ellipse 0 0 2 4", 0, 0, 0),
Command(f"read '{EXAMPLE_SVG}'", 0, 0, 0),
Command(f"read -m '{EXAMPLE_SVG}'", 0, 0, 0),
Command("write -f svg -", 0, 0, 0),
Command("write -f hpgl -d hp7475a -p a4 -", 0, 0, 0),
Command("rotate 0", 0, 0, 0),
Command("scale 1 1", 0, 0, 0),
Command("scaleto 10cm 10cm", 0, 0, 0),
Command("skew 0 0", 0, 0, 0),
Command("translate 0 0", 0, 0, 0),
Command("crop 0 0 1 1", 0, 0, 0),
Command("linesort", 0, 0, 0),
Command("linesort --two-opt", 0, 0, 0),
Command("linemerge", 0, 0, 0),
Command("linesimplify", 0, 0, 0),
Command("multipass", 0, 0, 0),
Command("reloop", 0, 0, 0),
Command("lmove 1 new", 0, 0, 0),
Command("lcopy 1 new", 0, 0, 0),
Command("ldelete 1", 0, 0, 0),
Command("lswap 1 2", 2, 2, 0),
Command("lreverse 1", 0, 0, 0),
Command("line 0 0 10 10 lreverse 1", 0, 0, 0),
Command("random -l1 random -l2 lswap 1 2", 0, 0, 0),
Command("trim 1mm 1mm", 0, 0, 0),
Command("splitall", 0, 0, 0),
Command("filter --min-length 1mm", 0, 0, 0),
Command("pagesize 10inx15in", 0, 0, 0),
Command("stat", 0, 0, 0),
Command("snap 1", 0, 0, 0),
Command("reverse", 0, 0, 0),
Command("layout a4", 0, 0, 0),
Command("squiggles", 0, 0, 0),
Command("text 'hello wold'", 0, 0, 0),
]

# noinspection SpellCheckingInspection
Expand All @@ -61,50 +75,54 @@
)


@pytest.mark.parametrize("args", MINIMAL_COMMANDS)
def test_commands_empty_geometry(runner, args):
result = runner.invoke(cli, args, catch_exceptions=False)
assert result.exit_code == 0
@pytest.mark.parametrize("cmd", MINIMAL_COMMANDS)
def test_commands_empty_geometry(runner, cmd):
result = runner.invoke(cli, cmd.command, catch_exceptions=False)
assert result.exit_code == cmd.exit_code_no_layer


@pytest.mark.parametrize("args", MINIMAL_COMMANDS)
def test_commands_single_line(runner, args):
result = runner.invoke(cli, "line 0 0 10 10 " + args, catch_exceptions=False)
assert result.exit_code == 0
@pytest.mark.parametrize("cmd", MINIMAL_COMMANDS)
def test_commands_single_line(runner, cmd):
result = runner.invoke(cli, "line 0 0 10 10 " + cmd.command, catch_exceptions=False)
assert result.exit_code == cmd.exit_code_one_layer


@pytest.mark.parametrize("args", MINIMAL_COMMANDS)
def test_commands_degenerate_line(runner, args):
result = runner.invoke(cli, "line 0 0 0 0 " + args)
assert result.exit_code == 0
@pytest.mark.parametrize("cmd", MINIMAL_COMMANDS)
def test_commands_degenerate_line(runner, cmd):
result = runner.invoke(cli, "line 0 0 0 0 " + cmd.command)
assert result.exit_code == cmd.exit_code_one_layer


@pytest.mark.parametrize("args", MINIMAL_COMMANDS)
def test_commands_random_input(runner, args):
result = runner.invoke(cli, "random -n 100 " + args)
assert result.exit_code == 0
@pytest.mark.parametrize("cmd", MINIMAL_COMMANDS)
def test_commands_random_input(runner, cmd):
result = runner.invoke(cli, "random -n 100 " + cmd.command)
assert result.exit_code == cmd.exit_code_one_layer


@pytest.mark.parametrize("args", MINIMAL_COMMANDS)
def test_commands_execute(args):
execute(args)
if args.exit_code_no_layer == 0:
execute(args.command)


@pytest.mark.parametrize("args", MINIMAL_COMMANDS)
def test_commands_must_return_document(runner, args):
@pytest.mark.parametrize("cmd", MINIMAL_COMMANDS)
def test_commands_must_return_document(runner, cmd):
@cli.command()
@vp.global_processor
def assertdoc(document):
assert document is not None
assert type(document) is vp.Document

result = runner.invoke(cli, "line 0 0 10 10 " + args + " assertdoc")
assert result.exit_code == 0
result = runner.invoke(cli, "line 0 0 10 10 " + cmd.command + " assertdoc")
assert result.exit_code == cmd.exit_code_one_layer


@pytest.mark.parametrize("args", MINIMAL_COMMANDS)
def test_commands_keeps_page_size(runner, args):
@pytest.mark.parametrize("cmd", MINIMAL_COMMANDS)
def test_commands_keeps_page_size(runner, cmd):
"""No command shall "forget" the current page size, unless its `pagesize` of course."""

args = cmd.command

if args.split()[0] in ["pagesize", "layout"]:
return

Expand All @@ -117,8 +135,10 @@ def getpagesize(doc: vp.Document) -> vp.Document:
page_size = doc.page_size
return doc

result = runner.invoke(cli, "pagesize --landscape 5432x4321 " + args + " getpagesize")
assert result.exit_code == 0
result = runner.invoke(
cli, "random random -l2 pagesize --landscape 5432x4321 " + args + " getpagesize"
)
assert result.exit_code == cmd.exit_code_two_layers
assert page_size == (5432, 4321)


Expand Down Expand Up @@ -449,7 +469,7 @@ def test_snap_no_duplicate(pitch: float):
def test_splitall_filter_duplicates(line, expected):
lc = execute_single_line("splitall", line)

assert np.all(l == el for l, el in zip(lc, expected))
assert np.all(line == expected_line for line, expected_line in zip(lc, expected))


@pytest.mark.parametrize(
Expand Down
32 changes: 32 additions & 0 deletions tests/test_layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import math
import random

import click
import numpy as np
import pytest

import vpype as vp
Expand All @@ -28,6 +30,9 @@
("line 0 0 1 1 lmove all new", [2]),
("line 0 0 1 1 line -l new 0 0 1 1 lmove all 2", [2]),
("line 0 0 1 1 line -l new 0 0 1 1 lmove all 3", [3]),
("line 0 0 1 1 lreverse 1", [1]),
("lreverse 1", []), # lreverse doesnt create phantom layers
("line -l2 0 0 1 1 lreverse 1", [2]),
],
)
def test_layer_creation(runner, command, layers):
Expand Down Expand Up @@ -83,6 +88,12 @@ def big_doc():
return doc


def test_layer_not_new():
with pytest.raises(click.exceptions.BadParameter) as exc:
execute("ldelete new")
assert "existing" in exc.value.message


def test_lmove(big_doc):
doc = execute("lmove 1 2", big_doc)

Expand Down Expand Up @@ -176,3 +187,24 @@ def test_lcopy_prob_zero(big_doc):

assert len(doc.layers[1]) == 1000
assert 2 not in doc.layers


def test_lswap():
doc = execute("line -l1 0 0 10 10 line -l2 20 20 30 30 lswap 1 2")

assert np.all(doc.layers[1][0] == np.array([20 + 20j, 30 + 30j]))
assert np.all(doc.layers[2][0] == np.array([0, 10 + 10j]))


def test_lswap_prob_zero():
doc = execute("line -l1 0 0 10 10 line -l2 20 20 30 30 lswap --prob 0. 1 2")

assert np.all(doc.layers[2][0] == np.array([20 + 20j, 30 + 30j]))
assert np.all(doc.layers[1][0] == np.array([0, 10 + 10j]))


def test_lreverse():
doc = execute("line 0 0 10 10 line 20 20 30 30 lreverse 1")

assert np.all(doc.layers[1][0] == np.array([20 + 20j, 30 + 30j]))
assert np.all(doc.layers[1][1] == np.array([0, 10 + 10j]))
28 changes: 24 additions & 4 deletions vpype/layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,16 @@ def multiple_to_layer_ids(
return []


def single_to_layer_id(layer: Optional[int], document: Document) -> int:
def single_to_layer_id(
layer: Optional[int], document: Document, must_exist: bool = False
) -> int:
"""Convert single-layer CLI argument to layer ID, accounting for the existence of a current
a current target layer and dealing with default behavior.
Arg:
layer: value from a :class:`LayerType` argument
document: target :class:`Document` instance (for new layer ID)
must_exists: if True, the function
Returns:
Target layer ID
Expand All @@ -71,6 +74,9 @@ def single_to_layer_id(layer: Optional[int], document: Document) -> int:
else:
lid = layer

if must_exist and lid not in document.layers:
raise click.BadParameter(f"layer {layer} does not exist")

return lid


Expand Down Expand Up @@ -113,12 +119,22 @@ def convert(self, value, param, ctx):
if self.accept_multiple:
return LayerType.ALL
else:
self.fail("'all' was not expected", param, ctx)
self.fail(
f"parameter {param.human_readable_name} must be a single layer and does "
"not accept `all`",
param,
ctx,
)
elif value.lower() == "new":
if self.accept_new:
return LayerType.NEW
else:
self.fail("'new' was not expected", param, ctx)
self.fail(
f"parameter {param.human_readable_name} must be an existing layer and "
"does not accept `new`",
param,
ctx,
)

try:
if self.accept_multiple:
Expand All @@ -132,4 +148,8 @@ def convert(self, value, param, ctx):
except TypeError:
self.fail(f"unexpected {value!r} of type {type(value).__name__}", param, ctx)
except ValueError:
self.fail(f"{value!r} is not a valid value", param, ctx)
self.fail(
f"{value!r} is not a valid value for parameter {param.human_readable_name}",
param,
ctx,
)
Loading