Source code for pystrich.cli

"""Command-line interface for pyStrich.

Run ``pystrich --help`` for usage. Each subcommand corresponds to a barcode
format and uses the matching ``Encoder`` class.
"""

from __future__ import annotations

import abc
import argparse
import importlib.metadata
import os
import sys
from typing import Any, ClassVar, Literal, get_args

from pystrich.code39 import Code39Encoder
from pystrich.code128 import Code128Encoder
from pystrich.datamatrix import (
    DATAMATRIX_DEFAULT_QUIET_ZONE,
    FNC1,
    DataMatrixCodeword,
    DataMatrixData,
    DataMatrixEncoder,
)
from pystrich.dxf import DxfUnit
from pystrich.ean13 import EAN13Encoder, EAN13RenderOptions
from pystrich.exceptions import PyStrichInvalidInput, PyStrichInvalidOption
from pystrich.marks import MarkShape
from pystrich.qrcode import QRCodeEncoder
from pystrich.types import BarcodeRenderOptions

OutputType = Literal["png", "svg", "eps", "ascii", "terminal", "dxf"]

_MARK_SHAPE_BY_NAME: dict[str, MarkShape] = {
    "square": MarkShape.SQUARE_CELLS,
    "circular": MarkShape.CIRCULAR_CELLS,
    "horizontal-runs": MarkShape.HORIZONTAL_RUNS,
}

_OUTPUT_BY_EXTENSION: dict[str, OutputType] = {
    ".png": "png",
    ".svg": "svg",
    ".eps": "eps",
    ".dxf": "dxf",
}

_DXF_UNIT_CHOICES = (*get_args(DxfUnit), "unspecified")


[docs] class Format(abc.ABC): """Abstract barcode format. Leaf subclasses are registered in FORMATS.""" name: ClassVar[str] help: ClassVar[str] text_help: ClassVar[str] = "text to encode" available_outputs: ClassVar[tuple[OutputType, ...]] = ("png",) def add_args(self, sp: argparse.ArgumentParser) -> None: sp.add_argument("--text", help=f"{self.text_help} (default: read from stdin)") sp.add_argument( "-o", "--output", default=None, help="output path; '-' or omitted writes to stdout", ) sp.add_argument( "-t", "--type", dest="output_type", choices=("auto", *self.available_outputs), default="auto", help="output format; 'auto' resolves from -o filename or output context", ) @abc.abstractmethod def encoder(self, args: argparse.Namespace) -> Any: ... def render(self, args: argparse.Namespace) -> bytes: result = getattr(self, f"render_{args.output_type}")(args) return result if isinstance(result, bytes) else result.encode("utf-8")
[docs] class OneDFormat(Format): available_outputs = ("png", "svg", "eps") def add_args(self, sp: argparse.ArgumentParser) -> None: super().add_args(sp) sp.add_argument( "--bar-width", type=int, default=3, help="width of the narrowest bar (default: 3)", ) sp.add_argument( "--height", type=int, default=None, help="image height in pixels", ) def render_png(self, args: argparse.Namespace) -> bytes: return self.encoder(args).get_imagedata(args.bar_width) def render_svg(self, args: argparse.Namespace) -> str: return self.encoder(args).get_svg(args.bar_width) def render_eps(self, args: argparse.Namespace) -> str: return self.encoder(args).get_eps(args.bar_width)
[docs] class TwoDFormat(Format): available_outputs = ("png", "svg", "eps", "ascii", "terminal", "dxf") def add_args(self, sp: argparse.ArgumentParser) -> None: super().add_args(sp) sp.add_argument( "--cell-size", type=float, default=None, help="side length of one module (default: 5 for raster/SVG/EPS, 1.0 for DXF)", ) sp.add_argument( "--inverse", action=argparse.BooleanOptionalAction, default=None, help="invert (light cells filled instead of dark); applies to svg/eps/dxf", ) sp.add_argument( "--mark-shape", choices=tuple(_MARK_SHAPE_BY_NAME), default=None, help="how matched cells are drawn in vector output", ) sp.add_argument( "--dxf-units", choices=_DXF_UNIT_CHOICES, default=None, help="DXF units (default: mm); 'unspecified' writes $INSUNITS=0", ) @staticmethod def _reject_flags(args: argparse.Namespace, output_type: OutputType, *names: str) -> None: bad = [_FLAG_LABELS[n] for n in names if getattr(args, n) is not None] if bad: raise PyStrichInvalidOption( f"{', '.join(bad)} not supported for output type {output_type!r}" ) @staticmethod def _raster_cell_size(args: argparse.Namespace) -> int: if args.cell_size is None: return 5 if args.cell_size <= 0: raise PyStrichInvalidOption(f"--cell-size must be positive, got {args.cell_size}") return round(args.cell_size) @staticmethod def _dxf_cell_size(args: argparse.Namespace) -> float: if args.cell_size is None: return 1.0 if args.cell_size <= 0: raise PyStrichInvalidOption(f"--cell-size must be positive, got {args.cell_size}") return float(args.cell_size) @staticmethod def _vector_kwargs(args: argparse.Namespace) -> dict[str, Any]: kwargs: dict[str, Any] = {} if args.inverse is not None: kwargs["inverse"] = args.inverse if args.mark_shape is not None: kwargs["mark_shape"] = _MARK_SHAPE_BY_NAME[args.mark_shape] return kwargs def render_png(self, args: argparse.Namespace) -> bytes: self._reject_flags(args, "png", "inverse", "mark_shape", "dxf_units") return self.encoder(args).get_imagedata(self._raster_cell_size(args)) def render_svg(self, args: argparse.Namespace) -> str: self._reject_flags(args, "svg", "dxf_units") return self.encoder(args).get_svg(self._raster_cell_size(args), **self._vector_kwargs(args)) def render_eps(self, args: argparse.Namespace) -> str: self._reject_flags(args, "eps", "dxf_units") return self.encoder(args).get_eps(self._raster_cell_size(args), **self._vector_kwargs(args)) def render_ascii(self, args: argparse.Namespace) -> str: self._reject_flags(args, "ascii", "inverse", "mark_shape", "dxf_units") return self.encoder(args).get_ascii() + "\n" def render_terminal(self, args: argparse.Namespace) -> str: self._reject_flags(args, "terminal", "inverse", "mark_shape", "dxf_units") return self.encoder(args).get_terminal_art(ansi_bg=args.is_tty) + "\n" def render_dxf(self, args: argparse.Namespace) -> str: units: DxfUnit | None if args.dxf_units is None or args.dxf_units == "mm": units = "mm" elif args.dxf_units == "unspecified": units = None else: units = args.dxf_units return self.encoder(args).get_dxf( cellsize=self._dxf_cell_size(args), units=units, **self._vector_kwargs(args) )
_FLAG_LABELS = { "inverse": "--inverse", "mark_shape": "--mark-shape", "dxf_units": "--dxf-units", }
[docs] class Code39(OneDFormat): name = "code39" help = "Code 39 (1D)" def add_args(self, sp: argparse.ArgumentParser) -> None: super().add_args(sp) sp.add_argument( "--full-ascii", action="store_true", help="enable full-ASCII Code 39 encoding", ) sp.add_argument( "--show-label", action=argparse.BooleanOptionalAction, default=None, help="render the human-readable label below the bars", ) def encoder(self, args: argparse.Namespace) -> Code39Encoder: opts: BarcodeRenderOptions = {} if args.height is not None: opts["height"] = args.height if args.show_label is not None: opts["show_label"] = args.show_label return Code39Encoder(args.text, full_ascii=args.full_ascii, options=opts or None)
[docs] class Code128(OneDFormat): name = "code128" help = "Code 128 (1D)" def add_args(self, sp: argparse.ArgumentParser) -> None: super().add_args(sp) sp.add_argument( "--show-label", action=argparse.BooleanOptionalAction, default=None, help="render the human-readable label below the bars", ) def encoder(self, args: argparse.Namespace) -> Code128Encoder: opts: BarcodeRenderOptions = {} if args.height is not None: opts["height"] = args.height if args.show_label is not None: opts["show_label"] = args.show_label return Code128Encoder(args.text, options=opts or None)
[docs] class EAN13(OneDFormat): name = "ean13" help = "EAN-13 (1D, 12 or 13 digits)" text_help = "12 or 13 digits" def encoder(self, args: argparse.Namespace) -> EAN13Encoder: opts: EAN13RenderOptions = {} if args.height is not None: opts["height"] = args.height return EAN13Encoder(args.text, options=opts or None)
[docs] class DataMatrix(TwoDFormat): name = "datamatrix" help = "Data Matrix (2D)" def add_args(self, sp: argparse.ArgumentParser) -> None: super().add_args(sp) sp.add_argument( "--quiet-zone", type=int, default=DATAMATRIX_DEFAULT_QUIET_ZONE, help=f"quiet-zone width in cells (default: {DATAMATRIX_DEFAULT_QUIET_ZONE})", ) sp.add_argument( "--encoding", choices=("auto", "ascii", "iso-8859-1", "utf-8"), default="auto", help="DataMatrix charset (default: auto picks the narrowest that fits)", ) sp.add_argument( "--substitute-with-fnc1", metavar="CHAR", default=None, help="replace each occurrence of CHAR in --text with an FNC1 codeword", ) def encoder(self, args: argparse.Namespace) -> DataMatrixEncoder: segments = _datamatrix_segments(args.text, args.substitute_with_fnc1) if args.encoding == "auto": data = DataMatrixData(*segments, auto_encoding=True) else: data = DataMatrixData(*segments, encoding=args.encoding) return DataMatrixEncoder(data, quiet_zone=args.quiet_zone)
def _datamatrix_segments( text: str, substitute_with_fnc1: str | None ) -> list[str | DataMatrixCodeword]: if substitute_with_fnc1 is None: return [text] if len(substitute_with_fnc1) != 1: raise PyStrichInvalidOption( f"--substitute-with-fnc1 must be exactly one character, got {substitute_with_fnc1!r}" ) segments: list[str | DataMatrixCodeword] = [] for i, chunk in enumerate(text.split(substitute_with_fnc1)): if i > 0: segments.append(FNC1) if chunk: segments.append(chunk) return segments
[docs] class QRCode(TwoDFormat): name = "qrcode" help = "QR Code (2D)" def add_args(self, sp: argparse.ArgumentParser) -> None: super().add_args(sp) sp.add_argument( "--ecl", choices=("L", "M", "Q", "H"), help="error-correction level (default: M)", ) def encoder(self, args: argparse.Namespace) -> QRCodeEncoder: return QRCodeEncoder(args.text, ecl=args.ecl)
FORMATS: list[Format] = [Code39(), Code128(), EAN13(), DataMatrix(), QRCode()] def _build_parser() -> argparse.ArgumentParser: try: version = importlib.metadata.version("pyStrich") except importlib.metadata.PackageNotFoundError: version = "unknown" parser = argparse.ArgumentParser( prog="pystrich", description=( "Generate 1D/2D barcodes " "(Code 39, Code 128, EAN-13, Data Matrix, QR Code). " "Pass input via --text or stdin." ), ) parser.add_argument("--version", action="version", version=f"%(prog)s {version}") sub = parser.add_subparsers(dest="format", required=True, metavar="FORMAT") for f in FORMATS: f.add_args(sub.add_parser(f.name, help=f.help)) return parser def _resolve_text(arg: str | None) -> str: if arg is not None: return arg return sys.stdin.read().rstrip("\n") def _resolve_output_type(args: argparse.Namespace, fmt: Format) -> OutputType: if args.output_type != "auto": return args.output_type if args.output is not None and args.output != "-": ext = os.path.splitext(args.output)[1].lower() candidate = _OUTPUT_BY_EXTENSION.get(ext) if candidate is not None: if candidate not in fmt.available_outputs: raise PyStrichInvalidOption( f"output type {candidate!r} (inferred from {args.output!r}) " f"is not supported by {fmt.name}" ) return candidate if args.is_tty: if "terminal" in fmt.available_outputs: return "terminal" raise PyStrichInvalidOption( f"refusing to write {fmt.name} binary output to a terminal; " "pass -o <file> or -t <format>" ) raise PyStrichInvalidOption( "specify -t <format> when output is not a terminal " "(or pass -o <file> with a recognised extension)" ) def _write_payload(output: str | None, payload: bytes) -> None: if output is None or output == "-": sys.stdout.buffer.write(payload) return with open(output, "wb") as fp: fp.write(payload)
[docs] def main(argv: list[str] | None = None) -> int: """Run the CLI; return the process exit code.""" args = _build_parser().parse_args(argv) fmt = {f.name: f for f in FORMATS}[args.format] args.is_tty = (args.output is None or args.output == "-") and sys.stdout.isatty() try: args.output_type = _resolve_output_type(args, fmt) args.text = _resolve_text(args.text) payload = fmt.render(args) except PyStrichInvalidInput as exc: print(f"pystrich: invalid input: {exc}", file=sys.stderr) return 2 except PyStrichInvalidOption as exc: print(f"pystrich: invalid option: {exc}", file=sys.stderr) return 2 _write_payload(args.output, payload) return 0
if __name__ == "__main__": raise SystemExit(main())