Merge pull request #107 from DavisGoglin/improve-cli
Improve cli.py Complete rewrite of cli.py with configurable printer
This commit is contained in:
commit
abebf7eb99
|
@ -12,5 +12,10 @@ temp
|
||||||
# packaging and testing
|
# packaging and testing
|
||||||
.tox/
|
.tox/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
.coverage
|
||||||
|
|
||||||
|
# testing temporary directories
|
||||||
|
test/test-cli-output/
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
Config
|
||||||
|
---------
|
||||||
|
Module :py:mod:`escpos.config`
|
||||||
|
|
||||||
|
.. automodule:: escpos.config
|
||||||
|
:members:
|
||||||
|
:inherited-members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
:member-order: bysource
|
|
@ -35,6 +35,7 @@ Content
|
||||||
api/printer
|
api/printer
|
||||||
api/constants
|
api/constants
|
||||||
api/exceptions
|
api/exceptions
|
||||||
|
api/config
|
||||||
|
|
||||||
Indices and tables
|
Indices and tables
|
||||||
==================
|
==================
|
||||||
|
|
|
@ -3,4 +3,4 @@ Pillow>=2.0
|
||||||
qrcode>=4.0
|
qrcode>=4.0
|
||||||
pyserial
|
pyserial
|
||||||
sphinx-rtd-theme
|
sphinx-rtd-theme
|
||||||
setuptools-scm
|
setuptools-scm
|
||||||
|
|
|
@ -120,6 +120,63 @@ on USB interface
|
||||||
# Cut paper
|
# Cut paper
|
||||||
Epson.cut()
|
Epson.cut()
|
||||||
|
|
||||||
|
Configuration File
|
||||||
|
------------------
|
||||||
|
|
||||||
|
You can create a configuration file for python-escpos. This will
|
||||||
|
allow you to use the CLI, and skip some setup when using the library
|
||||||
|
programically.
|
||||||
|
|
||||||
|
The default configuration file is named ``config.yaml``. It's in the YAML
|
||||||
|
format. For windows it is probably at::
|
||||||
|
|
||||||
|
%appdata%/python-escpos/config.yaml
|
||||||
|
|
||||||
|
And for linux::
|
||||||
|
|
||||||
|
$HOME/.config/python-escpos/config.yaml
|
||||||
|
|
||||||
|
If you aren't sure, run::
|
||||||
|
|
||||||
|
from escpos import config
|
||||||
|
c = config.Config()
|
||||||
|
c.load()
|
||||||
|
|
||||||
|
If it can't find the configuration file in the default location, it will tell
|
||||||
|
you where it's looking. You can always pass a path or a list of paths to
|
||||||
|
search to the ``load()`` method.
|
||||||
|
|
||||||
|
|
||||||
|
To load the configured pritner, run::
|
||||||
|
|
||||||
|
from escpos import config
|
||||||
|
c = config.Config()
|
||||||
|
printer = c.printer()
|
||||||
|
|
||||||
|
|
||||||
|
The printer section
|
||||||
|
^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
The ``printer`` configuration section defines a default printer to create.
|
||||||
|
|
||||||
|
The only required paramter is ``type``. The value of this should be one of the
|
||||||
|
printers defined in :doc:`/user/printers`.
|
||||||
|
|
||||||
|
The rest of the parameters are whatever you want to pass to the printer.
|
||||||
|
|
||||||
|
An example file printer::
|
||||||
|
|
||||||
|
printer:
|
||||||
|
type: File
|
||||||
|
devfile: /dev/someprinter
|
||||||
|
|
||||||
|
And for a network printer::
|
||||||
|
|
||||||
|
printer:
|
||||||
|
type: network
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 9000
|
||||||
|
|
||||||
How to update your code for USB printers
|
How to update your code for USB printers
|
||||||
----------------------------------------
|
----------------------------------------
|
||||||
|
|
||||||
|
|
|
@ -1,213 +1,527 @@
|
||||||
#!/usr/bin/env python2
|
#!/usr/bin/env python
|
||||||
# -*- coding: utf-8 -*-
|
""" CLI
|
||||||
"""A simple command-line interface for common python-escpos functionality
|
|
||||||
|
|
||||||
Usage: python -m escpos.cli --help
|
This module acts as a command line interface for python-escpos. It mirrors
|
||||||
|
closely the available ESCPOS commands while adding a couple extra ones for convience.
|
||||||
|
|
||||||
Dependencies:
|
It requires you to have a configuration file. See documentation for details.
|
||||||
- DavisGoglin/python-escpos or better
|
|
||||||
- A file named weather.png (for the 'test' subcommand)
|
|
||||||
|
|
||||||
Reasons for using the DavisGoglin/python-escpos fork:
|
|
||||||
- image() accepts a PIL.Image object rather than requiring me to choose
|
|
||||||
between writing a temporary file to disk or calling a "private" method.
|
|
||||||
- fullimage() allows me to print images of arbitrary length using slicing.
|
|
||||||
|
|
||||||
How to print unsupported barcodes:
|
|
||||||
barcode -b 'BARCODE' -e 'code39' -E | convert -density 200% eps:- code.png
|
|
||||||
python test_escpos.py --images code.png
|
|
||||||
|
|
||||||
Copyright (C) 2014 Stephan Sokolow (deitarion/SSokolow)
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of this software and associated documentation files (the "Software"),
|
|
||||||
to deal in the Software without restriction, including without limitation
|
|
||||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
||||||
and/or sell copies of the Software, and to permit persons to whom the
|
|
||||||
Software is furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included
|
|
||||||
in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
||||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
||||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
||||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
|
|
||||||
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import absolute_import
|
from __future__ import absolute_import
|
||||||
|
from __future__ import division
|
||||||
|
from __future__ import print_function
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
__author__ = "Stephan Sokolow (deitarion/SSokolow)"
|
import argparse
|
||||||
__license__ = "MIT"
|
import sys
|
||||||
|
import six
|
||||||
|
from . import config
|
||||||
|
|
||||||
import re
|
# Must be defined before it's used in DEMO_FUNCTIONS
|
||||||
|
def str_to_bool(string):
|
||||||
|
""" Used as a type in argparse so that we get back a proper
|
||||||
|
bool instead of always True
|
||||||
|
"""
|
||||||
|
return string.lower() in ('y', 'yes', '1', 'true')
|
||||||
|
|
||||||
from escpos import printer
|
# A list of functions that work better with a newline to be sent after them.
|
||||||
|
REQUIRES_NEWLINE = ('qr', 'barcode', 'text', 'block_text')
|
||||||
|
|
||||||
epson = printer.Usb(0x0416, 0x5011)
|
# Used in demo method
|
||||||
# TODO: Un-hardcode this
|
# Key: The name of escpos function and the argument passed on the CLI. Some
|
||||||
|
# manual translation is done in the case of barcodes_a -> barcode.
|
||||||
|
# Value: A list of dictionaries to pass to the escpos function as arguments.
|
||||||
|
DEMO_FUNCTIONS = {
|
||||||
|
'text': [
|
||||||
|
{'txt': 'Hello, World!\n',}
|
||||||
|
],
|
||||||
|
'qr': [
|
||||||
|
{'text': 'This tests a QR code'},
|
||||||
|
{'text': 'https://en.wikipedia.org/'}
|
||||||
|
],
|
||||||
|
'barcodes_a': [
|
||||||
|
{'bc': 'UPC-A', 'code': '13243546576'},
|
||||||
|
{'bc': 'UPC-E', 'code': '132435'},
|
||||||
|
{'bc': 'EAN13', 'code': '1324354657687'},
|
||||||
|
{'bc': 'EAN8', 'code': '1324354'},
|
||||||
|
{'bc': 'CODE39', 'code': 'TEST'},
|
||||||
|
{'bc': 'ITF', 'code': '55867492279103'},
|
||||||
|
{'bc': 'NW7', 'code': 'A00000000A'},
|
||||||
|
],
|
||||||
|
'barcodes_b': [
|
||||||
|
{'bc': 'UPC-A', 'code': '13243546576', 'function_type': 'B'},
|
||||||
|
{'bc': 'UPC-E', 'code': '132435', 'function_type': 'B'},
|
||||||
|
{'bc': 'EAN13', 'code': '1324354657687', 'function_type': 'B'},
|
||||||
|
{'bc': 'EAN8', 'code': '1324354', 'function_type': 'B'},
|
||||||
|
{'bc': 'CODE39', 'code': 'TEST', 'function_type': 'B'},
|
||||||
|
{'bc': 'ITF', 'code': '55867492279103', 'function_type': 'B'},
|
||||||
|
{'bc': 'NW7', 'code': 'A00000000A', 'function_type': 'B'},
|
||||||
|
{'bc': 'CODE93', 'code': 'A00000000A', 'function_type': 'B'},
|
||||||
|
{'bc': 'CODE93', 'code': '1324354657687', 'function_type': 'B'},
|
||||||
|
{'bc': 'CODE128A', 'code': 'TEST', 'function_type': 'B'},
|
||||||
|
{'bc': 'CODE128B', 'code': 'TEST', 'function_type': 'B'},
|
||||||
|
{'bc': 'CODE128C', 'code': 'TEST', 'function_type': 'B'},
|
||||||
|
{'bc': 'GS1-128', 'code': '00123456780000000001', 'function_type': 'B'},
|
||||||
|
{'bc': 'GS1 DataBar Omnidirectional', 'code': '0000000000000', 'function_type': 'B'},
|
||||||
|
{'bc': 'GS1 DataBar Truncated', 'code': '0000000000000', 'function_type': 'B'},
|
||||||
|
{'bc': 'GS1 DataBar Limited', 'code': '0000000000000', 'function_type': 'B'},
|
||||||
|
{'bc': 'GS1 DataBar Expanded', 'code': '00AAAAAAA', 'function_type': 'B'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used to build the CLI
|
||||||
def _print_text_file(path):
|
# A list of dictionaries. Each dict is a CLI argument.
|
||||||
"""Print the given text file"""
|
# Keys:
|
||||||
epson.set(align='left')
|
# parser: A dict of args for command_parsers.add_parser
|
||||||
with open(path, 'rU') as fobj:
|
# defaults: A dict of args for subparser.set_defaults
|
||||||
for line in fobj:
|
# arguments: A list of dicts of args for subparser.add_argument
|
||||||
epson.text(line)
|
ESCPOS_COMMANDS = [
|
||||||
|
{
|
||||||
|
'parser': {
|
||||||
def _print_image_file(path):
|
'name': 'qr',
|
||||||
"""Print the given image file."""
|
'help': 'Print a QR code',
|
||||||
epson.fullimage(path, histeq=False, width=384)
|
},
|
||||||
|
'defaults': {
|
||||||
|
'func': 'qr',
|
||||||
def print_files(args):
|
},
|
||||||
"""The 'print' subcommand"""
|
'arguments': [
|
||||||
for path in args.paths:
|
{
|
||||||
if args.images:
|
'option_strings': ('--text',),
|
||||||
_print_image_file(path)
|
'help': 'Text to print as a qr code',
|
||||||
else:
|
'required': True,
|
||||||
_print_text_file(path)
|
}
|
||||||
epson.cut()
|
],
|
||||||
|
},
|
||||||
# {{{ 'echo' Subcommand
|
{
|
||||||
|
'parser': {
|
||||||
KNOWN_BARCODE_TYPES = ['UPC-A', 'UPC-E', 'EAN13', 'ITF']
|
'name': 'barcode',
|
||||||
re_barcode_escape = re.compile(r'^%(?P<type>\S+)\s(?P<data>[0-9X]+)$')
|
'help': 'Print a barcode',
|
||||||
|
},
|
||||||
|
'defaults': {
|
||||||
def echo(args): # pylint: disable=unused-argument
|
'func': 'barcode',
|
||||||
"""TTY-like line-by-line keyboard-to-printer echo loop."""
|
},
|
||||||
try:
|
'arguments': [
|
||||||
while True:
|
{
|
||||||
line = raw_input()
|
'option_strings': ('--code',),
|
||||||
match = re_barcode_escape.match(line)
|
'help': 'Barcode data to print',
|
||||||
if match and match.group('type') in KNOWN_BARCODE_TYPES:
|
'required': True,
|
||||||
bctype, data = match.groups()
|
},
|
||||||
epson.barcode(data, bctype, 48, 2, '', '')
|
{
|
||||||
epson.set(align='left')
|
'option_strings': ('--bc',),
|
||||||
else:
|
'help': 'Barcode format',
|
||||||
epson.text('{0}\n'.format(line))
|
'required': True,
|
||||||
except KeyboardInterrupt:
|
},
|
||||||
epson.cut()
|
{
|
||||||
|
'option_strings': ('--height',),
|
||||||
# }}}
|
'help': 'Barcode height in px',
|
||||||
# {{{ 'test' Subcommand
|
'type': int,
|
||||||
|
},
|
||||||
from PIL import Image, ImageDraw
|
{
|
||||||
|
'option_strings': ('--width',),
|
||||||
|
'help': 'Barcode width',
|
||||||
def _stall_test(width, height):
|
'type': int,
|
||||||
"""Generate a pattern to detect print glitches due to vertical stalling."""
|
},
|
||||||
img = Image.new('1', (width, height))
|
{
|
||||||
for pos in [(x, y) for y in range(0, height) for x in range(0, width)]:
|
'option_strings': ('--pos',),
|
||||||
img.putpixel(pos, not sum(pos) % 10)
|
'help': 'Label position',
|
||||||
return img
|
'choices': ['BELOW', 'ABOVE', 'BOTH', 'OFF'],
|
||||||
|
},
|
||||||
|
{
|
||||||
def _test_basic():
|
'option_strings': ('--font',),
|
||||||
"""The original test code from python-escpos's Usage wiki page"""
|
'help': 'Label font',
|
||||||
epson.set(align='left')
|
'choices': ['A', 'B'],
|
||||||
# Print text
|
},
|
||||||
epson.text("TODO:\n") # pylint: disable=fixme
|
{
|
||||||
epson.text("[ ] Task 1\n")
|
'option_strings': ('--align_ct',),
|
||||||
epson.text("[ ] Task 2\n")
|
'help': 'Align barcode center',
|
||||||
# Print image
|
'type': str_to_bool,
|
||||||
# TODO: Bundle an image so this can be used
|
},
|
||||||
# epson.image("weather.png")
|
{
|
||||||
# Print QR Code (must have a white border to be scanned)
|
'option_strings': ('--function_type',),
|
||||||
epson.set(align='center')
|
'help': 'ESCPOS function type',
|
||||||
epson.text("Scan to recall TODO list") # pylint: disable=fixme
|
'choices': ['A', 'B'],
|
||||||
epson.qr("http://www.example.com/")
|
},
|
||||||
# Print barcode
|
],
|
||||||
epson.barcode('1234567890128', 'EAN13', 32, 2, '', '')
|
},
|
||||||
# Cut paper
|
{
|
||||||
epson.cut()
|
'parser': {
|
||||||
|
'name': 'text',
|
||||||
|
'help': 'Print plain text',
|
||||||
def _test_barcodes():
|
},
|
||||||
"""Print test barcodes for all ESCPOS-specified formats."""
|
'defaults': {
|
||||||
for name, data in (
|
'func': 'text',
|
||||||
# pylint: disable=bad-continuation
|
},
|
||||||
('UPC-A', '123456789012\x00'),
|
'arguments': [
|
||||||
('UPC-E', '02345036\x00'),
|
{
|
||||||
('EAN13', '1234567890128\x00'),
|
'option_strings': ('--txt',),
|
||||||
('EAN8', '12345670\x00'),
|
'help': 'Plain text to print',
|
||||||
('CODE39', 'BARCODE12345678\x00'),
|
'required': True,
|
||||||
('ITF', '123456\x00'),
|
}
|
||||||
('CODABAR', 'A40156B'),
|
],
|
||||||
# TODO: CODE93 and CODE128
|
},
|
||||||
):
|
{
|
||||||
# TODO: Fix the library to restore old alignment somehow
|
'parser': {
|
||||||
epson.set(align='center')
|
'name': 'block_text',
|
||||||
epson.text('\n{0}\n'.format(name))
|
'help': 'Print wrapped text',
|
||||||
epson.barcode(data, name, 64, 2, '', '')
|
},
|
||||||
|
'defaults': {
|
||||||
|
'func': 'block_text',
|
||||||
def _test_patterns(width=384, height=255):
|
},
|
||||||
"""Print a set of test patterns for raster image output."""
|
'arguments': [
|
||||||
# Test our guess of the paper width
|
{
|
||||||
img = Image.new('1', (width, height), color=1)
|
'option_strings': ('--txt',),
|
||||||
draw = ImageDraw.Draw(img)
|
'help': 'block_text to print',
|
||||||
draw.polygon(((0, 0), img.size, (0, img.size[1])), fill=0)
|
'required': True,
|
||||||
epson.image(img)
|
},
|
||||||
del draw, img
|
{
|
||||||
|
'option_strings': ('--columns',),
|
||||||
# Test the consistency of printing large data and whether stall rate is
|
'help': 'Number of columns',
|
||||||
# affected by data rate
|
'type': int,
|
||||||
epson.image(_stall_test(width, height))
|
},
|
||||||
epson.image(_stall_test(width / 2, height))
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
def test(args):
|
'parser': {
|
||||||
"""The 'test' subcommand"""
|
'name': 'cut',
|
||||||
if args.barcodes:
|
'help': 'Cut the paper',
|
||||||
_test_barcodes()
|
},
|
||||||
elif args.patterns:
|
'defaults': {
|
||||||
_test_patterns()
|
'func': 'cut',
|
||||||
else:
|
},
|
||||||
_test_basic()
|
'arguments': [
|
||||||
|
{
|
||||||
|
'option_strings': ('--mode',),
|
||||||
# }}}
|
'help': 'Type of cut',
|
||||||
|
'choices': ['FULL', 'PART'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'parser': {
|
||||||
|
'name': 'cashdraw',
|
||||||
|
'help': 'Kick the cash drawer',
|
||||||
|
},
|
||||||
|
'defaults': {
|
||||||
|
'func': 'cashdraw',
|
||||||
|
},
|
||||||
|
'arguments': [
|
||||||
|
{
|
||||||
|
'option_strings': ('--pin',),
|
||||||
|
'help': 'Which PIN to kick',
|
||||||
|
'choices': [2, 5],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'parser': {
|
||||||
|
'name': 'image',
|
||||||
|
'help': 'Print an image',
|
||||||
|
},
|
||||||
|
'defaults': {
|
||||||
|
'func': 'image',
|
||||||
|
},
|
||||||
|
'arguments': [
|
||||||
|
{
|
||||||
|
'option_strings': ('--path_img',),
|
||||||
|
'help': 'Path to image',
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'parser': {
|
||||||
|
'name': 'fullimage',
|
||||||
|
'help': 'Print a fullimage',
|
||||||
|
},
|
||||||
|
'defaults': {
|
||||||
|
'func': 'fullimage',
|
||||||
|
},
|
||||||
|
'arguments': [
|
||||||
|
{
|
||||||
|
'option_strings': ('--img',),
|
||||||
|
'help': 'Path to img',
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'option_strings': ('--max_height',),
|
||||||
|
'help': 'Max height of image in px',
|
||||||
|
'type': int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'option_strings': ('--width',),
|
||||||
|
'help': 'Max width of image in px',
|
||||||
|
'type': int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'option_strings': ('--histeq',),
|
||||||
|
'help': 'Equalize the histrogram',
|
||||||
|
'type': str_to_bool,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'option_strings': ('--bandsize',),
|
||||||
|
'help': 'Size of bands to divide into when printing',
|
||||||
|
'type': int,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'parser': {
|
||||||
|
'name': 'charcode',
|
||||||
|
'help': 'Set character code table',
|
||||||
|
},
|
||||||
|
'defaults': {
|
||||||
|
'func': 'charcode',
|
||||||
|
},
|
||||||
|
'arguments': [
|
||||||
|
{
|
||||||
|
'option_strings': ('--code',),
|
||||||
|
'help': 'Character code',
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'parser': {
|
||||||
|
'name': 'set',
|
||||||
|
'help': 'Set text properties',
|
||||||
|
},
|
||||||
|
'defaults': {
|
||||||
|
'func': 'set',
|
||||||
|
},
|
||||||
|
'arguments': [
|
||||||
|
{
|
||||||
|
'option_strings': ('--align',),
|
||||||
|
'help': 'Horizontal alignment',
|
||||||
|
'choices': ['left', 'center', 'right'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'option_strings': ('--font',),
|
||||||
|
'help': 'Font choice',
|
||||||
|
'choices': ['left', 'center', 'right'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'option_strings': ('--text_type',),
|
||||||
|
'help': 'Text properties',
|
||||||
|
'choices': ['B', 'U', 'U2', 'BU', 'BU2', 'NORMAL'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'option_strings': ('--width',),
|
||||||
|
'help': 'Width multiplier',
|
||||||
|
'type': int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'option_strings': ('--height',),
|
||||||
|
'help': 'Height multiplier',
|
||||||
|
'type': int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'option_strings': ('--density',),
|
||||||
|
'help': 'Print density',
|
||||||
|
'type': int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'option_strings': ('--invert',),
|
||||||
|
'help': 'White on black printing',
|
||||||
|
'type': str_to_bool,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'option_strings': ('--smooth',),
|
||||||
|
'help': 'Text smoothing. Effective on >: 4x4 text',
|
||||||
|
'type': str_to_bool,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'option_strings': ('--flip',),
|
||||||
|
'help': 'Text smoothing. Effective on >: 4x4 text',
|
||||||
|
'type': str_to_bool,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'parser': {
|
||||||
|
'name': 'hw',
|
||||||
|
'help': 'Hardware operations',
|
||||||
|
},
|
||||||
|
'defaults': {
|
||||||
|
'func': 'hw',
|
||||||
|
},
|
||||||
|
'arguments': [
|
||||||
|
{
|
||||||
|
'option_strings': ('--hw',),
|
||||||
|
'help': 'Operation',
|
||||||
|
'choices': ['INIT', 'SELECT', 'RESET'],
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'parser': {
|
||||||
|
'name': 'control',
|
||||||
|
'help': 'Control sequences',
|
||||||
|
},
|
||||||
|
'defaults': {
|
||||||
|
'func': 'control',
|
||||||
|
},
|
||||||
|
'arguments': [
|
||||||
|
{
|
||||||
|
'option_strings': ('--ctl',),
|
||||||
|
'help': 'Control sequence',
|
||||||
|
'choices': ['LF', 'FF', 'CR', 'HT', 'VT'],
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'option_strings': ('--pos',),
|
||||||
|
'help': 'Horizontal tab position (1-4)',
|
||||||
|
'type': int,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'parser': {
|
||||||
|
'name': 'panel_buttons',
|
||||||
|
'help': 'Controls panel buttons',
|
||||||
|
},
|
||||||
|
'defaults': {
|
||||||
|
'func': 'panel_buttons',
|
||||||
|
},
|
||||||
|
'arguments': [
|
||||||
|
{
|
||||||
|
'option_strings': ('--enable',),
|
||||||
|
'help': 'Feed button enabled',
|
||||||
|
'type': str_to_bool,
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'parser': {
|
||||||
|
'name': 'raw',
|
||||||
|
'help': 'Raw data',
|
||||||
|
},
|
||||||
|
'defaults': {
|
||||||
|
'func': '_raw',
|
||||||
|
},
|
||||||
|
'arguments': [
|
||||||
|
{
|
||||||
|
'option_strings': ('--msg',),
|
||||||
|
'help': 'Raw data to send',
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Wrapped in a function for import and entry point compatibility"""
|
"""
|
||||||
# pylint: disable=bad-continuation
|
|
||||||
|
|
||||||
import argparse
|
Handles loading of configuration and creating and processing of command
|
||||||
|
line arguments. Called when run from a CLI.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Command-line interface to python-escpos")
|
description='CLI for python-escpos',
|
||||||
subparsers = parser.add_subparsers(title='subcommands')
|
epilog='Printer configuration is defined in the python-escpos config'
|
||||||
|
'file. See documentation for details.',
|
||||||
|
)
|
||||||
|
|
||||||
echo_parser = subparsers.add_parser('echo', help='Echo the keyboard to '
|
parser.register('type', 'bool', str_to_bool)
|
||||||
'the printer line-by-line (Exit with Ctrl+C)')
|
|
||||||
echo_parser.set_defaults(func=echo)
|
|
||||||
|
|
||||||
print_parser = subparsers.add_parser('print', help='Print the given files')
|
# Allow config file location to be passed
|
||||||
print_parser.add_argument('--images', action='store_true',
|
parser.add_argument(
|
||||||
help="Provided files are images rather than text files.")
|
'-c', '--config',
|
||||||
print_parser.add_argument('paths', metavar='path', nargs='+')
|
help='Altnerate path to the configuration file',
|
||||||
print_parser.set_defaults(func=print_files)
|
)
|
||||||
|
|
||||||
test_parser = subparsers.add_parser('test', help='Print test patterns')
|
# Everything interesting runs off of a subparser so we can use the format
|
||||||
test_modes = test_parser.add_mutually_exclusive_group()
|
# cli [subparser] -args
|
||||||
test_modes.add_argument('--barcodes', action='store_true',
|
command_subparsers = parser.add_subparsers(
|
||||||
help="Test supported barcode types (Warning: Some printers must be "
|
title='ESCPOS Command',
|
||||||
"reset after attempting an unsupported barcode type.)")
|
)
|
||||||
test_modes.add_argument('--patterns', action='store_true',
|
|
||||||
help="Print test patterns")
|
|
||||||
test_parser.set_defaults(func=test)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
# Build the ESCPOS command arguments
|
||||||
args.func(args)
|
for command in ESCPOS_COMMANDS:
|
||||||
|
parser_command = command_subparsers.add_parser(**command['parser'])
|
||||||
|
parser_command.set_defaults(**command['defaults'])
|
||||||
|
for argument in command['arguments']:
|
||||||
|
option_strings = argument.pop('option_strings')
|
||||||
|
parser_command.add_argument(*option_strings, **argument)
|
||||||
|
|
||||||
|
# Build any custom arguments
|
||||||
|
parser_command_demo = command_subparsers.add_parser('demo',
|
||||||
|
help='Demonstrates various functions')
|
||||||
|
parser_command_demo.set_defaults(func='demo')
|
||||||
|
demo_group = parser_command_demo.add_mutually_exclusive_group()
|
||||||
|
demo_group.add_argument(
|
||||||
|
'--barcodes-a',
|
||||||
|
help='Print demo barcodes for function type A',
|
||||||
|
action='store_true',
|
||||||
|
)
|
||||||
|
demo_group.add_argument(
|
||||||
|
'--barcodes-b',
|
||||||
|
help='Print demo barcodes for function type B',
|
||||||
|
action='store_true',
|
||||||
|
)
|
||||||
|
demo_group.add_argument(
|
||||||
|
'--qr',
|
||||||
|
help='Print some demo QR codes',
|
||||||
|
action='store_true',
|
||||||
|
)
|
||||||
|
demo_group.add_argument(
|
||||||
|
'--text',
|
||||||
|
help='Print some demo text',
|
||||||
|
action='store_true',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get only arguments actually passed
|
||||||
|
args_dict = vars(parser.parse_args())
|
||||||
|
if not args_dict:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit()
|
||||||
|
command_arguments = dict([k, v] for k, v in six.iteritems(args_dict) if v is not None)
|
||||||
|
|
||||||
|
# If there was a config path passed, grab it
|
||||||
|
config_path = command_arguments.pop('config', None)
|
||||||
|
|
||||||
|
# Load the configuration and defined printer
|
||||||
|
saved_config = config.Config()
|
||||||
|
saved_config.load(config_path)
|
||||||
|
printer = saved_config.printer()
|
||||||
|
|
||||||
|
|
||||||
|
if not printer:
|
||||||
|
raise Exception('No printers loaded from config')
|
||||||
|
|
||||||
|
target_command = command_arguments.pop('func')
|
||||||
|
|
||||||
|
if hasattr(printer, target_command):
|
||||||
|
# print command with args
|
||||||
|
getattr(printer, target_command)(**command_arguments)
|
||||||
|
if target_command in REQUIRES_NEWLINE:
|
||||||
|
printer.text("\n")
|
||||||
|
else:
|
||||||
|
command_arguments['printer'] = printer
|
||||||
|
globals()[target_command](**command_arguments)
|
||||||
|
|
||||||
|
def demo(printer, **kwargs):
|
||||||
|
"""
|
||||||
|
Prints specificed demos. Called when CLI is passed `demo`. This function
|
||||||
|
uses the DEMO_FUNCTIONS dictionary.
|
||||||
|
|
||||||
|
:param printer: A printer from escpos.printer
|
||||||
|
:param kwargs: A dict with a key for each function you want to test. It's
|
||||||
|
in this format since it usually comes from argparse.
|
||||||
|
"""
|
||||||
|
for demo_choice in kwargs.keys():
|
||||||
|
command = getattr(
|
||||||
|
printer,
|
||||||
|
demo_choice
|
||||||
|
.replace('barcodes_a', 'barcode')
|
||||||
|
.replace('barcodes_b', 'barcode')
|
||||||
|
)
|
||||||
|
for params in DEMO_FUNCTIONS[demo_choice]:
|
||||||
|
command(**params)
|
||||||
|
printer.cut()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
||||||
# vim: set sw=4 sts=4 :
|
|
||||||
|
|
|
@ -0,0 +1,118 @@
|
||||||
|
""" ESC/POS configuration manager.
|
||||||
|
|
||||||
|
This module contains the implentations of abstract base class :py:class:`Config`.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import absolute_import
|
||||||
|
from __future__ import division
|
||||||
|
from __future__ import print_function
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import os
|
||||||
|
import appdirs
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from . import printer
|
||||||
|
from . import exceptions
|
||||||
|
|
||||||
|
class Config(object):
|
||||||
|
""" Configuration handler class.
|
||||||
|
|
||||||
|
This class loads configuration from a default or specificed directory. It
|
||||||
|
can create your defined printer and return it to you.
|
||||||
|
"""
|
||||||
|
_app_name = 'python-escpos'
|
||||||
|
_config_file = 'config.yaml'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
""" Initialize configuration.
|
||||||
|
|
||||||
|
Remember to add anything that needs to be reset between configurations
|
||||||
|
to self._reset_config
|
||||||
|
"""
|
||||||
|
self._has_loaded = False
|
||||||
|
self._printer = None
|
||||||
|
|
||||||
|
self._printer_name = None
|
||||||
|
self._printer_config = None
|
||||||
|
|
||||||
|
def _reset_config(self):
|
||||||
|
""" Clear the loaded configuration.
|
||||||
|
|
||||||
|
If we are loading a changed config, we don't want to have leftover
|
||||||
|
data.
|
||||||
|
"""
|
||||||
|
self._has_loaded = False
|
||||||
|
self._printer = None
|
||||||
|
|
||||||
|
self._printer_name = None
|
||||||
|
self._printer_config = None
|
||||||
|
|
||||||
|
def load(self, config_path=None):
|
||||||
|
""" Load and parse the configuration file using pyyaml
|
||||||
|
|
||||||
|
:param config_path: An optional file path, file handle, or byte string
|
||||||
|
for the configuration file.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
self._reset_config()
|
||||||
|
|
||||||
|
if not config_path:
|
||||||
|
config_path = os.path.join(
|
||||||
|
appdirs.user_config_dir(self._app_name),
|
||||||
|
self._config_file
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# First check if it's file like. If it is, pyyaml can load it.
|
||||||
|
# I'm checking type instead of catching exceptions to keep the
|
||||||
|
# exception handling simple
|
||||||
|
if hasattr(config_path, 'read'):
|
||||||
|
config = yaml.safe_load(config_path)
|
||||||
|
else:
|
||||||
|
# If it isn't, it's a path. We have to open it first, otherwise
|
||||||
|
# pyyaml will try to read it as yaml
|
||||||
|
with open(config_path, 'rb') as config_file:
|
||||||
|
config = yaml.safe_load(config_file)
|
||||||
|
except EnvironmentError:
|
||||||
|
raise exceptions.ConfigNotFoundError('Couldn\'t read config at {config_path}'.format(
|
||||||
|
config_path=str(config_path),
|
||||||
|
))
|
||||||
|
except yaml.YAMLError:
|
||||||
|
raise exceptions.ConfigSyntaxError('Error parsing YAML')
|
||||||
|
|
||||||
|
if 'printer' in config:
|
||||||
|
self._printer_config = config['printer']
|
||||||
|
self._printer_name = self._printer_config.pop('type').title()
|
||||||
|
|
||||||
|
if not self._printer_name or not hasattr(printer, self._printer_name):
|
||||||
|
raise exceptions.ConfigSyntaxError(
|
||||||
|
'Printer type "{printer_name}" is invalid'.format(
|
||||||
|
printer_name=self._printer_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._has_loaded = True
|
||||||
|
|
||||||
|
def printer(self):
|
||||||
|
""" Returns a printer that was defined in the config, or throws an
|
||||||
|
exception.
|
||||||
|
|
||||||
|
This method loads the default config if one hasn't beeen already loaded.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not self._has_loaded:
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
if not self._printer_name:
|
||||||
|
raise exceptions.ConfigSectionMissingError('printer')
|
||||||
|
|
||||||
|
if not self._printer:
|
||||||
|
# We could catch init errors and make them a ConfigSyntaxError,
|
||||||
|
# but I'll just let them pass
|
||||||
|
self._printer = getattr(printer, self._printer_name)(**self._printer_config)
|
||||||
|
|
||||||
|
return self._printer
|
||||||
|
|
|
@ -442,7 +442,7 @@ class Escpos(object):
|
||||||
self._raw(bc_types[bc.upper()])
|
self._raw(bc_types[bc.upper()])
|
||||||
|
|
||||||
if function_type.upper() == "B":
|
if function_type.upper() == "B":
|
||||||
self._raw(chr(len(code)))
|
self._raw(six.int2byte(len(code)))
|
||||||
|
|
||||||
# Print Code
|
# Print Code
|
||||||
if code:
|
if code:
|
||||||
|
|
|
@ -13,6 +13,9 @@ Result/Exit codes:
|
||||||
- `80` = Invalid char code :py:exc:`~escpos.exceptions.CharCodeError`
|
- `80` = Invalid char code :py:exc:`~escpos.exceptions.CharCodeError`
|
||||||
- `90` = USB device not found :py:exc:`~escpos.exceptions.USBNotFoundError`
|
- `90` = USB device not found :py:exc:`~escpos.exceptions.USBNotFoundError`
|
||||||
- `100` = Set variable out of range :py:exc:`~escpos.exceptions.SetVariableError`
|
- `100` = Set variable out of range :py:exc:`~escpos.exceptions.SetVariableError`
|
||||||
|
- `200` = Configuration not found :py:exc:`~escpos.exceptions.ConfigNotFoundError`
|
||||||
|
- `210` = Configuration syntax error :py:exc:`~escpos.exceptions.ConfigSyntaxError`
|
||||||
|
- `220` = Configuration section not found :py:exc:`~escpos.exceptions.ConfigSectionMissingError`
|
||||||
|
|
||||||
:author: `Manuel F Martinez <manpaz@bashlinux.com>`_ and others
|
:author: `Manuel F Martinez <manpaz@bashlinux.com>`_ and others
|
||||||
:organization: Bashlinux and `python-escpos <https://github.com/python-escpos>`_
|
:organization: Bashlinux and `python-escpos <https://github.com/python-escpos>`_
|
||||||
|
@ -188,3 +191,48 @@ class SetVariableError(Error):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "Set variable out of range"
|
return "Set variable out of range"
|
||||||
|
|
||||||
|
|
||||||
|
# Configuration errors
|
||||||
|
|
||||||
|
class ConfigNotFoundError(Error):
|
||||||
|
""" The configuration file was not found
|
||||||
|
|
||||||
|
The default or passed configuration file could not be read
|
||||||
|
Ths returncode for this exception is `200`.
|
||||||
|
"""
|
||||||
|
def __init__(self, msg=""):
|
||||||
|
Error.__init__(self, msg)
|
||||||
|
self.msg = msg
|
||||||
|
self.resultcode = 200
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "Configuration not found ({msg})".format(msg=self.msg)
|
||||||
|
|
||||||
|
class ConfigSyntaxError(Error):
|
||||||
|
""" The configuration file is invalid
|
||||||
|
|
||||||
|
The syntax is incorrect
|
||||||
|
Ths returncode for this exception is `210`.
|
||||||
|
"""
|
||||||
|
def __init__(self, msg=""):
|
||||||
|
Error.__init__(self, msg)
|
||||||
|
self.msg = msg
|
||||||
|
self.resultcode = 210
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "Configuration syntax is invalid ({msg})".format(msg=self.msg)
|
||||||
|
|
||||||
|
class ConfigSectionMissingError(Error):
|
||||||
|
""" The configuration file is missing a section
|
||||||
|
|
||||||
|
The part of the config asked for doesn't exist in the loaded configuration
|
||||||
|
Ths returncode for this exception is `220`.
|
||||||
|
"""
|
||||||
|
def __init__(self, msg=""):
|
||||||
|
Error.__init__(self, msg)
|
||||||
|
self.msg = msg
|
||||||
|
self.resultcode = 220
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "Configuration section is missing ({msg})".format(msg=self.msg)
|
||||||
|
|
|
@ -151,6 +151,7 @@ class Serial(Escpos):
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
""" Close Serial interface """
|
""" Close Serial interface """
|
||||||
if self.device is not None:
|
if self.device is not None:
|
||||||
|
self.device.flush()
|
||||||
self.device.close()
|
self.device.close()
|
||||||
|
|
||||||
|
|
||||||
|
@ -209,6 +210,7 @@ class Network(Escpos):
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
""" Close TCP connection """
|
""" Close TCP connection """
|
||||||
|
self.device.shutdown(socket.SHUT_RDWR)
|
||||||
self.device.close()
|
self.device.close()
|
||||||
|
|
||||||
|
|
||||||
|
@ -256,4 +258,5 @@ class File(Escpos):
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
""" Close system file """
|
""" Close system file """
|
||||||
|
self.device.flush()
|
||||||
self.device.close()
|
self.device.close()
|
||||||
|
|
4
setup.py
4
setup.py
|
@ -72,10 +72,12 @@ setup(
|
||||||
'qrcode>=4.0',
|
'qrcode>=4.0',
|
||||||
'pyserial',
|
'pyserial',
|
||||||
'six',
|
'six',
|
||||||
|
'appdirs',
|
||||||
|
'pyyaml',
|
||||||
],
|
],
|
||||||
setup_requires=[
|
setup_requires=[
|
||||||
'setuptools_scm',
|
'setuptools_scm',
|
||||||
],
|
],
|
||||||
tests_require=['tox', 'nose'],
|
tests_require=['tox', 'nose', 'scripttest'],
|
||||||
cmdclass={'test': Tox},
|
cmdclass={'test': Tox},
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""Test for the CLI
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import absolute_import
|
||||||
|
from __future__ import division
|
||||||
|
from __future__ import print_function
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from scripttest import TestFileEnvironment
|
||||||
|
from nose.tools import assert_equals
|
||||||
|
|
||||||
|
TEST_DIR = os.path.abspath('test/test-cli-output')
|
||||||
|
|
||||||
|
DEVFILE_NAME = 'testfile'
|
||||||
|
|
||||||
|
DEVFILE = os.path.join(TEST_DIR, DEVFILE_NAME)
|
||||||
|
CONFIGFILE = 'testconfig.yaml'
|
||||||
|
CONFIG_YAML = '''
|
||||||
|
---
|
||||||
|
|
||||||
|
printer:
|
||||||
|
type: file
|
||||||
|
devfile: {testfile}
|
||||||
|
'''.format(
|
||||||
|
testfile=DEVFILE,
|
||||||
|
)
|
||||||
|
|
||||||
|
class TestCLI:
|
||||||
|
""" Contains setups, teardowns, and tests for CLI
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
""" Initalize the tests.
|
||||||
|
Just define some vars here since most of them get set during
|
||||||
|
setup_class and teardown_class
|
||||||
|
"""
|
||||||
|
self.env = None
|
||||||
|
self.default_args = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setup_class():
|
||||||
|
""" Create a config file to read from """
|
||||||
|
with open(CONFIGFILE, 'w') as config:
|
||||||
|
config.write(CONFIG_YAML)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def teardown_class():
|
||||||
|
""" Remove config file """
|
||||||
|
os.remove(CONFIGFILE)
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
""" Create a file to print to and set up env"""
|
||||||
|
self.env = TestFileEnvironment(
|
||||||
|
base_path=TEST_DIR,
|
||||||
|
cwd=os.getcwd(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.default_args = (
|
||||||
|
sys.executable,
|
||||||
|
'-mescpos.cli',
|
||||||
|
'-c',
|
||||||
|
CONFIGFILE,
|
||||||
|
)
|
||||||
|
|
||||||
|
fhandle = open(DEVFILE, 'a')
|
||||||
|
try:
|
||||||
|
os.utime(DEVFILE, None)
|
||||||
|
finally:
|
||||||
|
fhandle.close()
|
||||||
|
|
||||||
|
def teardown(self):
|
||||||
|
""" Destroy printer file and env """
|
||||||
|
os.remove(DEVFILE)
|
||||||
|
self.env.clear()
|
||||||
|
|
||||||
|
def test_cli_help(self):
|
||||||
|
""" Test getting help from cli """
|
||||||
|
result = self.env.run(sys.executable, '-mescpos.cli', '-h')
|
||||||
|
assert not result.stderr
|
||||||
|
assert 'usage' in result.stdout
|
||||||
|
|
||||||
|
def test_cli_text(self):
|
||||||
|
""" Make sure text returns what we sent it """
|
||||||
|
test_text = 'this is some text'
|
||||||
|
result = self.env.run(
|
||||||
|
*(self.default_args + (
|
||||||
|
'text',
|
||||||
|
'--txt',
|
||||||
|
test_text,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
assert not result.stderr
|
||||||
|
assert DEVFILE_NAME in result.files_updated.keys()
|
||||||
|
assert_equals(
|
||||||
|
result.files_updated[DEVFILE_NAME].bytes,
|
||||||
|
test_text + '\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cli_text_inavlid_args(self):
|
||||||
|
""" Test a failure to send valid arguments """
|
||||||
|
result = self.env.run(
|
||||||
|
*(self.default_args + (
|
||||||
|
'text',
|
||||||
|
'--invalid-param',
|
||||||
|
'some data'
|
||||||
|
)),
|
||||||
|
expect_error=True,
|
||||||
|
expect_stderr=True
|
||||||
|
)
|
||||||
|
assert_equals(result.returncode, 2)
|
||||||
|
assert 'error:' in result.stderr
|
||||||
|
assert not result.files_updated
|
Loading…
Reference in New Issue