Repository: abey79/vpype

Issue:
Expression: `in` doesn't work (reserved keyword)



Discussion:

Resolving patch:
diff --git a/docs/conf.py b/docs/conf.py
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,6 +1,8 @@
 import os
 import sys
 
+import vpype as vp
+
 # let sphinx find vpype packages
 sys.path.insert(0, os.path.abspath("../"))
 
@@ -66,6 +68,16 @@
 
 napoleon_include_init_with_doc = True
 
+# -- Substitutions
+
+UNIT_STRINGS = ", ".join(f"``{s}``" for s in sorted(vp.UNITS.keys()))
+UNIT_EXPR_STRINGS = ", ".join(f"``{s}``" for s in sorted(vp.UNITS.keys()) if s != "in")
+
+rst_prolog = f"""
+.. |units| replace:: {UNIT_STRINGS}
+.. |units_expr| replace:: {UNIT_EXPR_STRINGS}
+"""
+
 
 # noinspection PyUnusedLocal
 def autodoc_skip_member(app, what, name, obj, skip, options):
diff --git a/vpype/utils.py b/vpype/utils.py
--- a/vpype/utils.py
+++ b/vpype/utils.py
@@ -9,9 +9,11 @@
 # REMINDER: anything added here must be added to docs/api.rst
 __all__ = [
     "UNITS",
+    "UNIT_SYSTEMS",
     "ANGLE_UNITS",
     "PAGE_SIZES",
     "convert_length",
+    "format_length",
     "convert_angle",
     "convert_page_size",
     "union",
@@ -27,14 +29,23 @@ def _mm_to_px(x: float, y: float) -> tuple[float, float]:
 UNITS = {
     "px": 1.0,
     "in": 96.0,
+    "inch": 96.0,
     "ft": 12.0 * 96.0,
+    "yd": 36.0 * 96.0,
+    "mi": 1760.0 * 36.0 * 96.0,
     "mm": 96.0 / 25.4,
     "cm": 96.0 / 2.54,
     "m": 100.0 * 96.0 / 2.54,
+    "km": 100_000.0 * 96.0 / 2.54,
     "pc": 16.0,
     "pt": 96.0 / 72.0,
 }
 
+UNIT_SYSTEMS = {
+    "metric": ["mm", "cm", "m", "km"],
+    "imperial": ["in", "ft", "yd", "mi"],
+}
+
 ANGLE_UNITS = {
     "deg": 1.0,
     "grad": 9.0 / 10.0,
@@ -58,17 +69,58 @@ def _mm_to_px(x: float, y: float) -> tuple[float, float]:
     "tabloid": _mm_to_px(279.4, 431.8),
 }
 
+_FLOAT_WITH_UNIT_RE = re.compile(r"^([+\-0-9.e]*)([a-z]*)$", flags=re.IGNORECASE)
+
 
-def _convert_unit(value: str | float, units: dict[str, float]) -> float:
+def _convert_unit(value: str | float | int, units: dict[str, float]) -> float:
     """Converts a string with unit to a value"""
-    if isinstance(value, str):
-        value = value.strip().lower()
-        for unit, factor in units.items():
-            if value.endswith(unit):
-                num = value.strip(unit)
-                return (float(num) if len(num) > 0 else 1.0) * factor
-
-    return float(value)
+    if isinstance(value, (float, int)):
+        return value
+
+    mo = _FLOAT_WITH_UNIT_RE.match(value.strip().lower())
+    try:
+        if mo is None or value == "":
+            raise ValueError
+        number = mo.groups()[0]
+        unit = mo.groups()[1]
+        return (float(number) if number else 1.0) * (units[unit] if unit else 1.0)
+    except (ValueError, KeyError):
+        raise ValueError(f"cannot convert value '{value}'")
+
+
+def format_length(value: float, unit: str, full_precision: bool = True) -> str:
+    """Convert a length in pixel to a string representation with the provided unit or unit
+    system.
+
+    The representation is for display only and
+
+    Args:
+        value: the value to convert (in pixels)
+        unit: unit (from :data:`vpype.UNITS`) or unit system (from :data:`vp.UNIT_SYSTEMS`)
+        full_precision: if False, the return string will use a limited precision best suited
+            for informative display
+
+    Returns:
+        a string representation of the length
+    """
+    if unit in UNITS:
+        units = [unit]
+    elif unit in UNIT_SYSTEMS:
+        units = UNIT_SYSTEMS[unit]
+    else:
+        raise ValueError(f"unknown unit '{unit}'")
+
+    scaled_values = zip((value / UNITS[unit] for unit in units), units)
+    picked_value, picked_unit = sorted(
+        scaled_values, key=lambda x: abs(math.log10(abs(x[0])) - 0.5) if abs(x[0]) > 0 else 0
+    )[0]
+
+    if full_precision:
+        return str(picked_value) + picked_unit
+    elif picked_value < 0.1:
+        return f"{picked_value:.4g}" + picked_unit
+    else:
+        return f"{picked_value:.3f}" + picked_unit
 
 
 def convert_length(value: str | float) -> float:
diff --git a/vpype_cli/decorators.py b/vpype_cli/decorators.py
--- a/vpype_cli/decorators.py
+++ b/vpype_cli/decorators.py
@@ -111,7 +111,7 @@ def global_processor(f):
 
     This type of command implement a global, multi-layer processing and should be used for
     processors which cannot be applied layer-by-layer independently (in which case, using
-    a :func:`layer_processor` is advised.
+    a :func:`layer_processor` is advised).
 
     No option is automatically added to global processors. In cases where the user should be
     able to control on which layer(s) the processing must be applied, it is advised to
@@ -173,7 +173,7 @@ def generator(f):
 
     Generator do not have input, have automatically a "-l, --layer" option added to them, and
     must return a LineCollection structure, which will be added to a new layer or an existing
-    one depending the option.
+    one depending on the option.
     """
 
     @click.option(


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -5,7 +5,17 @@
 
 @pytest.mark.parametrize(
     "lf rt".split(),
-    [("10cm", "0.1m"), ("10cm", "100mm"), ("6in", ".5ft")],
+    [
+        ("10cm", "1e-1m"),
+        ("10cm", "100mm"),
+        ("1km", "1000m"),
+        ("1km", "+1e+6mm"),
+        ("1km", "100000cm"),
+        ("6in", ".5ft"),
+        ("-18in", "-.5yd"),
+        ("1mi", "63360in"),
+        ("69in", "69inch"),
+    ],
 )
 def test_convert_length(lf, rt):
     assert vp.convert_length(lf) == pytest.approx(vp.convert_length(rt))
@@ -14,3 +24,31 @@ def test_convert_length(lf, rt):
 def test_convert_length_fail():
     with pytest.raises(ValueError):
         vp.convert_length("invalid")
+
+    with pytest.raises(ValueError):
+        vp.convert_length("")
+
+    with pytest.raises(ValueError):
+        vp.convert_length("3in3")
+
+
+def test_format_length():
+    assert vp.format_length(1_000.12345657, "metric") == "26.461599788414585cm"
+    assert vp.format_length(1_000.12345657, "metric", False) == "26.462cm"
+    assert vp.format_length(1_000_000.12345657, "metric", False) == "0.265km"
+    assert vp.format_length(0.12345657, "metric", False) == "0.03266mm"
+
+    assert vp.format_length(1_000.12345657, "imperial") == "10.417952672604168in"
+    assert vp.format_length(1_000_000.12345657, "imperial") == "0.16440448157627216mi"
+    assert vp.format_length(10_000.12345657, "imperial") == "2.8935542409056714yd"
+
+    assert vp.format_length(1_000_000.12345657, "mm") == "264583.36599788413mm"
+
+
+def test_format_length_zero():
+    assert vp.format_length(0, "px") == "0.0px"
+
+
+def test_format_length_fail():
+    with pytest.raises(ValueError):
+        vp.format_length(50, "au")