Merge pull request #165 from miracle2k/capabilities

Capabilities profiles
This commit is contained in:
Patrick Kanzler 2016-09-02 14:02:10 +02:00 committed by GitHub
commit 9662ca6efe
12 changed files with 263 additions and 67 deletions

2
.coveragerc Normal file
View File

@ -0,0 +1,2 @@
[run]
branch = True

View File

@ -83,7 +83,8 @@ setup(
platforms='any', platforms='any',
package_dir={"": "src"}, package_dir={"": "src"},
packages=find_packages(where="src", exclude=["tests", "tests.*"]), packages=find_packages(where="src", exclude=["tests", "tests.*"]),
package_data={'': ['COPYING']}, package_data={'': ['COPYING', 'src/escpos/capabilities.json']},
include_package_data=True,
classifiers=[ classifiers=[
'Development Status :: 4 - Beta', 'Development Status :: 4 - Beta',
'Environment :: Console', 'Environment :: Console',
@ -116,7 +117,7 @@ setup(
setup_requires=[ setup_requires=[
'setuptools_scm', 'setuptools_scm',
], ],
tests_require=['tox', 'nose', 'scripttest', 'mock', 'hypothesis'], tests_require=['tox', 'pytest', 'pytest-cov', 'nose', 'scripttest', 'mock', 'hypothesis'],
cmdclass={'test': Tox}, cmdclass={'test': Tox},
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [

File diff suppressed because one or more lines are too long

112
src/escpos/capabilities.py Normal file
View File

@ -0,0 +1,112 @@
import re
import six
from os import path
import yaml
# Load external printer database
with open(path.join(path.dirname(__file__), 'capabilities.json')) as f:
CAPABILITIES = yaml.load(f)
PROFILES = CAPABILITIES['profiles']
ENCODINGS = CAPABILITIES['encodings']
class NotSupported(Exception):
"""Raised if a requested feature is not suppored by the
printer profile.
"""
pass
BARCODE_B = 'barcodeB'
class BaseProfile(object):
"""This respresents a printer profile.
A printer profile knows about the number of columns, supported
features, colors and more.
"""
profile_data = {}
def __getattr__(self, name):
return self.profile_data[name]
def get_font(self, font):
"""Return the escpos index for `font`. Makes sure that
the requested `font` is valid.
"""
font = {'a': 0, 'b': 1}.get(font, font)
if not six.text_type(font) in self.fonts:
raise NotSupported(
'"{}" is not a valid font in the current profile'.format(font))
return font
def get_columns(self, font):
""" Return the number of columns for the given font.
"""
font = self.get_font(font)
return self.fonts[six.text_type(font)]['columns']
def supports(self, feature):
"""Return true/false for the given feature.
"""
return self.features.get(feature)
def get_profile(name=None, **kwargs):
"""Get the profile by name; if no name is given, return the
default profile.
"""
if isinstance(name, Profile):
return name
clazz = get_profile_class(name or 'default')
return clazz(**kwargs)
CLASS_CACHE = {}
def get_profile_class(name):
"""For the given profile name, load the data from the external
database, then generate dynamically a class.
"""
if not name in CLASS_CACHE:
profile_data = PROFILES[name]
profile_name = clean(name)
class_name = '{}{}Profile'.format(
profile_name[0].upper(), profile_name[1:])
new_class = type(class_name, (BaseProfile,), {'profile_data': profile_data})
CLASS_CACHE[name] = new_class
return CLASS_CACHE[name]
def clean(s):
# Remove invalid characters
s = re.sub('[^0-9a-zA-Z_]', '', s)
# Remove leading characters until we find a letter or underscore
s = re.sub('^[^a-zA-Z_]+', '', s)
return str(s)
# For users, who want to provide their profile
class Profile(get_profile_class('default')):
def __init__(self, columns=None, features=None):
super(Profile, self).__init__()
self.columns = columns
self.features = features or {}
def get_columns(self, font):
if self.columns is not None:
return self.columns
return super(Profile, self).get_columns(font)

View File

@ -100,14 +100,17 @@ TXT_UNDERL_ON = ESC + b'\x2d\x01' # Underline font 1-dot ON
TXT_UNDERL2_ON = ESC + b'\x2d\x02' # Underline font 2-dot ON TXT_UNDERL2_ON = ESC + b'\x2d\x02' # Underline font 2-dot ON
TXT_BOLD_OFF = ESC + b'\x45\x00' # Bold font OFF TXT_BOLD_OFF = ESC + b'\x45\x00' # Bold font OFF
TXT_BOLD_ON = ESC + b'\x45\x01' # Bold font ON TXT_BOLD_ON = ESC + b'\x45\x01' # Bold font ON
TXT_FONT_A = ESC + b'\x4d\x00' # Font type A
TXT_FONT_B = ESC + b'\x4d\x01' # Font type B
TXT_ALIGN_LT = ESC + b'\x61\x00' # Left justification TXT_ALIGN_LT = ESC + b'\x61\x00' # Left justification
TXT_ALIGN_CT = ESC + b'\x61\x01' # Centering TXT_ALIGN_CT = ESC + b'\x61\x01' # Centering
TXT_ALIGN_RT = ESC + b'\x61\x02' # Right justification TXT_ALIGN_RT = ESC + b'\x61\x02' # Right justification
TXT_INVERT_ON = GS + b'\x42\x01' # Inverse Printing ON TXT_INVERT_ON = GS + b'\x42\x01' # Inverse Printing ON
TXT_INVERT_OFF = GS + b'\x42\x00' # Inverse Printing OFF TXT_INVERT_OFF = GS + b'\x42\x00' # Inverse Printing OFF
# Fonts
SET_FONT = lambda n: ESC + b'\x4d' + n
TXT_FONT_A = SET_FONT(b'\x00') # Font type A
TXT_FONT_B = SET_FONT(b'\x01') # Font type B
# Text colors # Text colors
TXT_COLOR_BLACK = ESC + b'\x72\x00' # Default Color TXT_COLOR_BLACK = ESC + b'\x72\x00' # Default Color
TXT_COLOR_RED = ESC + b'\x72\x01' # Alternative Color (Usually Red) TXT_COLOR_RED = ESC + b'\x72\x01' # Alternative Color (Usually Red)

View File

@ -23,6 +23,7 @@ from .exceptions import *
from abc import ABCMeta, abstractmethod # abstract base class support from abc import ABCMeta, abstractmethod # abstract base class support
from escpos.image import EscposImage from escpos.image import EscposImage
from escpos.capabilities import get_profile, BARCODE_B
@six.add_metaclass(ABCMeta) @six.add_metaclass(ABCMeta)
@ -35,11 +36,11 @@ class Escpos(object):
device = None device = None
codepage = None codepage = None
def __init__(self, columns=32): def __init__(self, profile=None):
""" Initialize ESCPOS Printer """ Initialize ESCPOS Printer
:param columns: Text columns used by the printer. Defaults to 32.""" :param profile: Printer profile"""
self.columns = columns self.profile = get_profile(profile)
def __del__(self): def __del__(self):
""" call self.close upon deletion """ """ call self.close upon deletion """
@ -292,7 +293,8 @@ class Escpos(object):
else: else:
raise CharCodeError() raise CharCodeError()
def barcode(self, code, bc, height=64, width=3, pos="BELOW", font="A", align_ct=True, function_type="A"): def barcode(self, code, bc, height=64, width=3, pos="BELOW", font="A",
align_ct=True, function_type=None):
""" Print Barcode """ Print Barcode
This method allows to print barcodes. The rendering of the barcode is done by the printer and therefore has to This method allows to print barcodes. The rendering of the barcode is done by the printer and therefore has to
@ -363,14 +365,40 @@ class Escpos(object):
issued. issued.
:type align_ct: bool :type align_ct: bool
:param function_type: Choose between ESCPOS function type A or B, depending on printer support and desired :param function_type: Choose between ESCPOS function type A or B,
barcode. depending on printer support and desired barcode. If not given,
the printer will attempt to automatically choose the correct
function based on the current profile.
*default*: A *default*: A
:raises: :py:exc:`~escpos.exceptions.BarcodeSizeError`, :raises: :py:exc:`~escpos.exceptions.BarcodeSizeError`,
:py:exc:`~escpos.exceptions.BarcodeTypeError`, :py:exc:`~escpos.exceptions.BarcodeTypeError`,
:py:exc:`~escpos.exceptions.BarcodeCodeError` :py:exc:`~escpos.exceptions.BarcodeCodeError`
""" """
if function_type is None:
# Choose the function type automatically.
if bc in BARCODE_TYPES['A']:
function_type = 'A'
else:
if bc in BARCODE_TYPES['B']:
if not self.profile.supports(BARCODE_B):
raise BarcodeTypeError((
"Barcode type '{bc} not supported for "
"the current printer profile").format(bc=bc))
function_type = 'B'
else:
raise BarcodeTypeError((
"Barcode type '{bc} is not valid").format(bc=bc))
bc_types = BARCODE_TYPES[function_type.upper()]
if bc.upper() not in bc_types.keys():
raise BarcodeTypeError((
"Barcode type '{bc}' not valid for barcode function type "
"{function_type}").format(
bc=bc,
function_type=function_type,
))
# Align Bar Code() # Align Bar Code()
if align_ct: if align_ct:
self._raw(TXT_ALIGN_CT) self._raw(TXT_ALIGN_CT)
@ -399,14 +427,6 @@ class Escpos(object):
else: # DEFAULT POSITION: BELOW else: # DEFAULT POSITION: BELOW
self._raw(BARCODE_TXT_BLW) self._raw(BARCODE_TXT_BLW)
bc_types = BARCODE_TYPES[function_type.upper()]
if bc.upper() not in bc_types.keys():
# TODO: Raise a better error, or fix the message of this error type
raise BarcodeTypeError("Barcode type {bc} not valid for barcode function type {function_type}".format(
bc=bc,
function_type=function_type,
))
self._raw(bc_types[bc.upper()]) self._raw(bc_types[bc.upper()])
if function_type.upper() == "B": if function_type.upper() == "B":
@ -439,7 +459,7 @@ class Escpos(object):
# TODO: why is it problematic to print an empty string? # TODO: why is it problematic to print an empty string?
raise TextError() raise TextError()
def block_text(self, txt, columns=None): def block_text(self, txt, font=None, columns=None):
""" Text is printed wrapped to specified columns """ Text is printed wrapped to specified columns
Text has to be encoded in unicode. Text has to be encoded in unicode.
@ -448,11 +468,11 @@ class Escpos(object):
:param columns: amount of columns :param columns: amount of columns
:return: None :return: None
""" """
col_count = self.columns if columns is None else columns col_count = self.profile.get_columns(font) if columns is None else columns
self.text(textwrap.fill(txt, col_count)) self.text(textwrap.fill(txt, col_count))
def set(self, align='left', font='a', text_type='normal', width=1, height=1, density=9, invert=False, smooth=False, def set(self, align='left', font='a', text_type='normal', width=1,
flip=False): height=1, density=9, invert=False, smooth=False, flip=False):
""" Set text properties by sending them to the printer """ Set text properties by sending them to the printer
:param align: horizontal position for text, possible values are: :param align: horizontal position for text, possible values are:
@ -462,7 +482,9 @@ class Escpos(object):
* RIGHT * RIGHT
*default*: LEFT *default*: LEFT
:param font: font type, possible values are A or B, *default*: A
:param font: font given as an index, a name, or one of the
special values 'a' or 'b', refering to fonts 0 and 1.
:param text_type: text type, possible values are: :param text_type: text type, possible values are:
* B for bold * B for bold
@ -527,10 +549,8 @@ class Escpos(object):
self._raw(TXT_BOLD_OFF) self._raw(TXT_BOLD_OFF)
self._raw(TXT_UNDERL_OFF) self._raw(TXT_UNDERL_OFF)
# Font # Font
if font.upper() == "B": self._raw(SET_FONT(six.int2byte(self.profile.get_font(font))))
self._raw(TXT_FONT_B)
else: # DEFAULT FONT: A
self._raw(TXT_FONT_A)
# Align # Align
if align.upper() == "CENTER": if align.upper() == "CENTER":
self._raw(TXT_ALIGN_CT) self._raw(TXT_ALIGN_CT)

View File

@ -1 +0,0 @@
Dies ist ein Test.

View File

@ -34,27 +34,22 @@ class TestCLI():
""" Contains setups, teardowns, and tests for CLI """ Contains setups, teardowns, and tests for CLI
""" """
def __init__(self): @classmethod
""" Initalize the tests. def setup_class(cls):
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 """ """ Create a config file to read from """
with open(CONFIGFILE, 'w') as config: with open(CONFIGFILE, 'w') as config:
config.write(CONFIG_YAML) config.write(CONFIG_YAML)
@staticmethod @classmethod
def teardown_class(): def teardown_class(cls):
""" Remove config file """ """ Remove config file """
os.remove(CONFIGFILE) os.remove(CONFIGFILE)
def setup(self): def setup(self):
""" Create a file to print to and set up env""" """ Create a file to print to and set up env"""
self.env = None
self.default_args = None
self.env = TestFileEnvironment( self.env = TestFileEnvironment(
base_path=TEST_DIR, base_path=TEST_DIR,
cwd=os.getcwd(), cwd=os.getcwd(),

View File

@ -0,0 +1,38 @@
#!/usr/bin/python
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
import escpos.printer as printer
from escpos.constants import BARCODE_TYPE_A, BARCODE_TYPE_B
from escpos.capabilities import Profile, BARCODE_B
from escpos.exceptions import BarcodeTypeError
import pytest
@pytest.mark.parametrize("bctype,data,expected", [
('EAN13', '4006381333931',
b'\x1ba\x01\x1dh@\x1dw\x03\x1df\x00\x1dH\x02\x1dk\x024006381333931\x00')
])
def test_barcode(bctype, data, expected):
"""should generate different barcode types correctly.
"""
instance = printer.Dummy()
instance.barcode(data, bctype)
assert instance.output == expected
@pytest.mark.parametrize("bctype,supports_b", [
('invalid', True),
('CODE128', False),
])
def test_lacks_support(bctype, supports_b):
"""should raise an error if the barcode type is not supported.
"""
profile = Profile(features={BARCODE_B: supports_b})
instance = printer.Dummy(profile=profile)
with pytest.raises(BarcodeTypeError):
instance.barcode('test', bctype)
assert instance.output == b''

View File

@ -12,34 +12,19 @@ from __future__ import division
from __future__ import print_function from __future__ import print_function
from __future__ import unicode_literals from __future__ import unicode_literals
from nose.tools import with_setup from escpos.printer import Dummy
import escpos.printer as printer
import os
import filecmp
devfile = 'testfile'
def setup_testfile():
"""create a testfile as devfile"""
fhandle = open(devfile, 'a')
try:
os.utime(devfile, None)
finally:
fhandle.close()
def teardown_testfile():
"""destroy testfile again"""
os.remove(devfile)
@with_setup(setup_testfile, teardown_testfile)
def test_function_text_dies_ist_ein_test_lf(): def test_function_text_dies_ist_ein_test_lf():
"""test the text printing function with simple string and compare output""" """test the text printing function with simple string and compare output"""
instance = printer.File(devfile=devfile) instance = Dummy()
instance.text('Dies ist ein Test.\n') instance.text('Dies ist ein Test.\n')
instance.flush() assert instance.output == b'Dies ist ein Test.\n'
assert(filecmp.cmp('test/Dies ist ein Test.LF.txt', devfile))
def test_block_text():
printer = Dummy()
printer.block_text(
"All the presidents men were eating falafel for breakfast.", font='a')
assert printer.output == \
b'All the presidents men were eating falafel\nfor breakfast.'

38
test/test_profile.py Normal file
View File

@ -0,0 +1,38 @@
import pytest
from escpos.capabilities import get_profile, NotSupported, BARCODE_B, Profile
@pytest.fixture
def profile():
return get_profile('default')
class TestBaseProfile:
"""Test the `BaseProfile` class.
"""
def test_get_font(self, profile):
with pytest.raises(NotSupported):
assert profile.get_font('3')
assert profile.get_font(1) == 1
assert profile.get_font('a') == 0
def test_supports(self, profile):
assert not profile.supports('asdf asdf')
assert profile.supports(BARCODE_B)
def test_get_columns(self, profile):
assert profile.get_columns('a') > 5
with pytest.raises(NotSupported):
assert profile.get_columns('asdfasdf')
class TestCustomProfile:
"""Test custom profile options with the `Profile` class.
"""
def test_columns(self):
assert Profile(columns=10).get_columns('sdfasdf') == 10
def test_features(self):
assert Profile(features={'foo': True}).supports('foo')

View File

@ -6,8 +6,10 @@ deps = nose
coverage coverage
scripttest scripttest
mock mock
pytest
pytest-cov
hypothesis hypothesis
commands = nosetests --with-coverage --cover-erase --cover-branches commands = py.test --cov escpos
[testenv:docs] [testenv:docs]
basepython = python basepython = python