diff --git a/.gitignore b/.gitignore index c2841b4..925ae40 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,10 @@ temp # packaging and testing .tox/ *.egg-info/ +.eggs/ build/ dist/ +.coverage + +# testing temporary directories +test/test-cli-output/ diff --git a/doc/api/config.rst b/doc/api/config.rst new file mode 100644 index 0000000..c5e3691 --- /dev/null +++ b/doc/api/config.rst @@ -0,0 +1,10 @@ +Config +--------- +Module :py:mod:`escpos.config` + +.. automodule:: escpos.config + :members: + :inherited-members: + :undoc-members: + :show-inheritance: + :member-order: bysource diff --git a/doc/index.rst b/doc/index.rst index 4997e51..29d0439 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -35,6 +35,7 @@ Content api/printer api/constants api/exceptions + api/config Indices and tables ================== diff --git a/doc/requirements.txt b/doc/requirements.txt index fd5f86f..acd8cd8 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -3,4 +3,4 @@ Pillow>=2.0 qrcode>=4.0 pyserial sphinx-rtd-theme -setuptools-scm \ No newline at end of file +setuptools-scm diff --git a/doc/user/usage.rst b/doc/user/usage.rst index 074e8a0..23f38e7 100644 --- a/doc/user/usage.rst +++ b/doc/user/usage.rst @@ -120,6 +120,63 @@ on USB interface # Cut paper 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 ---------------------------------------- diff --git a/escpos/cli.py b/escpos/cli.py old mode 100755 new mode 100644 index 4b52381..d250852 --- a/escpos/cli.py +++ b/escpos/cli.py @@ -1,213 +1,527 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- -"""A simple command-line interface for common python-escpos functionality +#!/usr/bin/env python +""" CLI -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: -- DavisGoglin/python-escpos or better -- A file named weather.png (for the 'test' subcommand) +It requires you to have a configuration file. See documentation for details. -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 division +from __future__ import print_function +from __future__ import unicode_literals -__author__ = "Stephan Sokolow (deitarion/SSokolow)" -__license__ = "MIT" +import argparse +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) -# TODO: Un-hardcode this +# Used in demo method +# 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'}, + ], +} - -def _print_text_file(path): - """Print the given text file""" - epson.set(align='left') - with open(path, 'rU') as fobj: - for line in fobj: - epson.text(line) - - -def _print_image_file(path): - """Print the given image file.""" - epson.fullimage(path, histeq=False, width=384) - - -def print_files(args): - """The 'print' subcommand""" - for path in args.paths: - if args.images: - _print_image_file(path) - else: - _print_text_file(path) - epson.cut() - -# {{{ 'echo' Subcommand - -KNOWN_BARCODE_TYPES = ['UPC-A', 'UPC-E', 'EAN13', 'ITF'] -re_barcode_escape = re.compile(r'^%(?P\S+)\s(?P[0-9X]+)$') - - -def echo(args): # pylint: disable=unused-argument - """TTY-like line-by-line keyboard-to-printer echo loop.""" - try: - while True: - line = raw_input() - match = re_barcode_escape.match(line) - if match and match.group('type') in KNOWN_BARCODE_TYPES: - bctype, data = match.groups() - epson.barcode(data, bctype, 48, 2, '', '') - epson.set(align='left') - else: - epson.text('{0}\n'.format(line)) - except KeyboardInterrupt: - epson.cut() - -# }}} -# {{{ 'test' Subcommand - -from PIL import Image, ImageDraw - - -def _stall_test(width, height): - """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)]: - img.putpixel(pos, not sum(pos) % 10) - return img - - -def _test_basic(): - """The original test code from python-escpos's Usage wiki page""" - epson.set(align='left') - # Print text - epson.text("TODO:\n") # pylint: disable=fixme - epson.text("[ ] Task 1\n") - epson.text("[ ] Task 2\n") - # Print image - # TODO: Bundle an image so this can be used - # epson.image("weather.png") - # Print QR Code (must have a white border to be scanned) - epson.set(align='center') - epson.text("Scan to recall TODO list") # pylint: disable=fixme - epson.qr("http://www.example.com/") - # Print barcode - epson.barcode('1234567890128', 'EAN13', 32, 2, '', '') - # Cut paper - epson.cut() - - -def _test_barcodes(): - """Print test barcodes for all ESCPOS-specified formats.""" - for name, data in ( - # pylint: disable=bad-continuation - ('UPC-A', '123456789012\x00'), - ('UPC-E', '02345036\x00'), - ('EAN13', '1234567890128\x00'), - ('EAN8', '12345670\x00'), - ('CODE39', 'BARCODE12345678\x00'), - ('ITF', '123456\x00'), - ('CODABAR', 'A40156B'), - # TODO: CODE93 and CODE128 - ): - # TODO: Fix the library to restore old alignment somehow - epson.set(align='center') - epson.text('\n{0}\n'.format(name)) - epson.barcode(data, name, 64, 2, '', '') - - -def _test_patterns(width=384, height=255): - """Print a set of test patterns for raster image output.""" - # Test our guess of the paper width - img = Image.new('1', (width, height), color=1) - draw = ImageDraw.Draw(img) - draw.polygon(((0, 0), img.size, (0, img.size[1])), fill=0) - epson.image(img) - del draw, img - - # Test the consistency of printing large data and whether stall rate is - # affected by data rate - epson.image(_stall_test(width, height)) - epson.image(_stall_test(width / 2, height)) - - -def test(args): - """The 'test' subcommand""" - if args.barcodes: - _test_barcodes() - elif args.patterns: - _test_patterns() - else: - _test_basic() - - -# }}} +# Used to build the CLI +# A list of dictionaries. Each dict is a CLI argument. +# Keys: +# parser: A dict of args for command_parsers.add_parser +# defaults: A dict of args for subparser.set_defaults +# arguments: A list of dicts of args for subparser.add_argument +ESCPOS_COMMANDS = [ + { + 'parser': { + 'name': 'qr', + 'help': 'Print a QR code', + }, + 'defaults': { + 'func': 'qr', + }, + 'arguments': [ + { + 'option_strings': ('--text',), + 'help': 'Text to print as a qr code', + 'required': True, + } + ], + }, + { + 'parser': { + 'name': 'barcode', + 'help': 'Print a barcode', + }, + 'defaults': { + 'func': 'barcode', + }, + 'arguments': [ + { + 'option_strings': ('--code',), + 'help': 'Barcode data to print', + 'required': True, + }, + { + 'option_strings': ('--bc',), + 'help': 'Barcode format', + 'required': True, + }, + { + 'option_strings': ('--height',), + 'help': 'Barcode height in px', + 'type': int, + }, + { + 'option_strings': ('--width',), + 'help': 'Barcode width', + 'type': int, + }, + { + 'option_strings': ('--pos',), + 'help': 'Label position', + 'choices': ['BELOW', 'ABOVE', 'BOTH', 'OFF'], + }, + { + 'option_strings': ('--font',), + 'help': 'Label font', + 'choices': ['A', 'B'], + }, + { + 'option_strings': ('--align_ct',), + 'help': 'Align barcode center', + 'type': str_to_bool, + }, + { + 'option_strings': ('--function_type',), + 'help': 'ESCPOS function type', + 'choices': ['A', 'B'], + }, + ], + }, + { + 'parser': { + 'name': 'text', + 'help': 'Print plain text', + }, + 'defaults': { + 'func': 'text', + }, + 'arguments': [ + { + 'option_strings': ('--txt',), + 'help': 'Plain text to print', + 'required': True, + } + ], + }, + { + 'parser': { + 'name': 'block_text', + 'help': 'Print wrapped text', + }, + 'defaults': { + 'func': 'block_text', + }, + 'arguments': [ + { + 'option_strings': ('--txt',), + 'help': 'block_text to print', + 'required': True, + }, + { + 'option_strings': ('--columns',), + 'help': 'Number of columns', + 'type': int, + }, + ], + }, + { + 'parser': { + 'name': 'cut', + 'help': 'Cut the paper', + }, + 'defaults': { + 'func': 'cut', + }, + '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(): - """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( - description="Command-line interface to python-escpos") - subparsers = parser.add_subparsers(title='subcommands') + description='CLI for python-escpos', + 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 ' - 'the printer line-by-line (Exit with Ctrl+C)') - echo_parser.set_defaults(func=echo) + parser.register('type', 'bool', str_to_bool) - print_parser = subparsers.add_parser('print', help='Print the given files') - print_parser.add_argument('--images', action='store_true', - help="Provided files are images rather than text files.") - print_parser.add_argument('paths', metavar='path', nargs='+') - print_parser.set_defaults(func=print_files) + # Allow config file location to be passed + parser.add_argument( + '-c', '--config', + help='Altnerate path to the configuration file', + ) - test_parser = subparsers.add_parser('test', help='Print test patterns') - test_modes = test_parser.add_mutually_exclusive_group() - test_modes.add_argument('--barcodes', action='store_true', - help="Test supported barcode types (Warning: Some printers must be " - "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) + # Everything interesting runs off of a subparser so we can use the format + # cli [subparser] -args + command_subparsers = parser.add_subparsers( + title='ESCPOS Command', + ) - args = parser.parse_args() - args.func(args) + # Build the ESCPOS command arguments + 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__': main() - -# vim: set sw=4 sts=4 : diff --git a/escpos/config.py b/escpos/config.py new file mode 100644 index 0000000..4a5d077 --- /dev/null +++ b/escpos/config.py @@ -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 + diff --git a/escpos/escpos.py b/escpos/escpos.py index 467cf4d..59ce938 100644 --- a/escpos/escpos.py +++ b/escpos/escpos.py @@ -442,7 +442,7 @@ class Escpos(object): self._raw(bc_types[bc.upper()]) if function_type.upper() == "B": - self._raw(chr(len(code))) + self._raw(six.int2byte(len(code))) # Print Code if code: diff --git a/escpos/exceptions.py b/escpos/exceptions.py index ce2667a..0a51782 100644 --- a/escpos/exceptions.py +++ b/escpos/exceptions.py @@ -13,6 +13,9 @@ Result/Exit codes: - `80` = Invalid char code :py:exc:`~escpos.exceptions.CharCodeError` - `90` = USB device not found :py:exc:`~escpos.exceptions.USBNotFoundError` - `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 `_ and others :organization: Bashlinux and `python-escpos `_ @@ -188,3 +191,48 @@ class SetVariableError(Error): def __str__(self): 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) diff --git a/escpos/printer.py b/escpos/printer.py index baff92e..5a4d126 100644 --- a/escpos/printer.py +++ b/escpos/printer.py @@ -151,6 +151,7 @@ class Serial(Escpos): def __del__(self): """ Close Serial interface """ if self.device is not None: + self.device.flush() self.device.close() @@ -209,6 +210,7 @@ class Network(Escpos): def __del__(self): """ Close TCP connection """ + self.device.shutdown(socket.SHUT_RDWR) self.device.close() @@ -256,4 +258,5 @@ class File(Escpos): def __del__(self): """ Close system file """ + self.device.flush() self.device.close() diff --git a/setup.py b/setup.py index d8b9f58..2eddb08 100755 --- a/setup.py +++ b/setup.py @@ -72,10 +72,12 @@ setup( 'qrcode>=4.0', 'pyserial', 'six', + 'appdirs', + 'pyyaml', ], setup_requires=[ 'setuptools_scm', ], - tests_require=['tox', 'nose'], + tests_require=['tox', 'nose', 'scripttest'], cmdclass={'test': Tox}, ) diff --git a/test/test_cli.py b/test/test_cli.py new file mode 100644 index 0000000..331eff6 --- /dev/null +++ b/test/test_cli.py @@ -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 diff --git a/tox.ini b/tox.ini index 0cee432..6c1b7bc 100644 --- a/tox.ini +++ b/tox.ini @@ -4,4 +4,5 @@ envlist = py27, py34, py35 [testenv] deps = nose coverage + scripttest commands = nosetests --with-coverage --cover-erase --cover-branches