From 1d4bf4813c32c54d40b4f0700d65b26c206df7ea Mon Sep 17 00:00:00 2001 From: Georg Sauthoff Date: Sat, 24 Jun 2017 13:06:03 +0200 Subject: [PATCH] Add command line frontend 'frank' --- inema/frank.py | 460 +++++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 5 +- 2 files changed, 464 insertions(+), 1 deletion(-) create mode 100755 inema/frank.py diff --git a/inema/frank.py b/inema/frank.py new file mode 100755 index 0000000..f57c4f4 --- /dev/null +++ b/inema/frank.py @@ -0,0 +1,460 @@ +#!/usr/bin/env python3 + +# 2016, Georg Sauthoff , GPLv3+ + +import argparse +import configparser +import csv +import datetime +import json +import logging +import os +import re +import requests +import sys +import zeep + +# to use a developer version of inema if available +if __name__ == '__main__': + import inspect + s = inspect.getsourcefile(lambda:0) + if s and s != '': + d = os.path.dirname(os.path.abspath(s)) + '/python-inema' + if os.path.exists(d): + sys.path.insert(0, d) + from inema import Internetmarke, inema +else: + from . import Internetmarke, inema + +class Fake_IM: + + def checkoutPDF(self, format_id): + pass + + def build_addr(self, street, number, code, city, country): + pass + + def build_pers_addr(self, first, name, address): + pass + + def build_comp_addr(self, first, name, address): + pass + + def build_position(self, product, sender, receiver, layout = "AddressZone", pdf = False, x=1, y=1, page=1): + pass + + def add_position(self, position): + pass + + def retrievePreviewPDF(self, prod_code, page_format, layout = "AddressZone"): + pass + + def compute_total(self): + return 0 + +try: + import colorlog +except ImportError: + pass + +log_format = '%(asctime)s - %(levelname)-8s - %(message)s [%(name)s]' +log_date_format = '%Y-%m-%d %H:%M:%S' + +def setup_logging(): + root = logging.getLogger() + root.setLevel(logging.WARN) + logging.getLogger(__name__).setLevel(logging.INFO) + + if 'colorlog' in sys.modules and os.isatty(2): + cformat = '%(log_color)s' + log_format + cf = colorlog.ColoredFormatter(cformat, log_date_format, + log_colors = { 'DEBUG': 'reset', 'INFO': 'reset', + 'WARNING' : 'bold_yellow' , 'ERROR': 'bold_red', + 'CRITICAL': 'bold_red'}) + + else: + cf = logging.Formatter(log_format, log_date_format) + + ch = logging.StreamHandler() + ch.setFormatter(cf) + root.addHandler(ch) + +log = logging.getLogger(__name__) + +def setup_file_logging(filename): + root = logging.getLogger() + root.setLevel(logging.DEBUG) + logging.getLogger(__name__).setLevel(logging.NOTSET) + + fh = logging.FileHandler(filename) + fh.setLevel(logging.DEBUG) + f = logging.Formatter(log_format, log_date_format) + fh.setFormatter(f) + root.addHandler(fh) + + class Filter: + def filter(self, r): + return r.name == __name__ or r.levelno >= logging.WARNING + + ch = root.handlers[0] + ch.setLevel(logging.INFO) + ch.addFilter(Filter()) + + + +def mk_arg_parser(): + p = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description='buy postage online', + epilog='''The program interfaces with the Deutsche Post +postage service for letters and small packages. + +Account details are read from a config file, by default this is +~/.config/frank.conf. An example config: + + [api] + id = your-partner-id + key = your-api-key + key_phase = 1 + + [account] + user = portokasse-user + password = portokasse-pw + + [a.default] + first = + name = Firma ACME + street = Lindenallee + number = 3 + zip = 12345 + city = Bielefeld + +Examples: + +List all formats that have a height of 297 mm: + + $ frank --list-formats x297 + +List all products that are called 'sendung' or so: + + $ frank --list-products sendung + +Preview a Büchersendung stamp (creates postage_YYYY-MM-DD.pdf): + + $ frank --preview --product 78 --format 1 + +Frank and buy 2 stamps (creates 2 page document postage_YYYY-MM-DD.pdf): + + $ frank ---format 26 --product 79 'Joe User;Street 1;12345 City' \\ + 'Jane User;Fakestreet 2;67890 Fakestadt' + +2016, Georg Sauthoff , GPLv3+ + +''' + ) + p.add_argument('recipients', metavar='RECIPIENT', nargs='*', + help = 'recipients') + p.add_argument('--config', action='append', + metavar='FILENAME', help='user specific config file') + p.add_argument('--csv', metavar='FILENAME', + help='read recipient data from CSV file (1st row is header)') + p.add_argument('--debug', help='store debug message into log file') + p.add_argument('--dry', action='store_true', help='dry run') + p.add_argument('--format' ,'-f', default='1', + help='format id for the resulting pdf') + p.add_argument('--global-conf', default = '/usr/share/frank/frank.conf', + metavar='FILENAME', help='global config file') + p.add_argument('--json', action='store_true', + help='print tablses as json') + p.add_argument('--list-formats', nargs='?', default=None, const='.', + metavar='REGEX', help='list available formats') + p.add_argument('--list-products', nargs='?', default=None, const='.', + metavar='REGEX', help='list available products') + p.add_argument('--manifest', action='store_true', + help='write manifest pdf') + p.add_argument('--output', '-o', default='.', metavar='DIRECTORY', + help='output directory where postage files are created') + p.add_argument('--preview', action='store_true', + help='only retrieve preview documents') + p.add_argument('--product' ,'-p', action='append', + help='product id(s) to use for the recipient(s)') + p.add_argument('--sender', action='append', help='sender(s)') + p.add_argument('--suffix', default='', help='postage basename suffix') + p.add_argument('--sys-conf', default = '/etc/frank.conf', + metavar='FILENAME', help='machine specific config file') + p.add_argument('--update', action='store_true', + help='update internal format list via webservice') + return p + +def parse_args(*xs): + arg_parser = mk_arg_parser() + args = arg_parser.parse_args(*xs) + + if args.debug: + setup_file_logging(args.debug) + if not args.config: + args.config = [ '~/.config/frank.conf' ] + if not args.format: + args.format = ['26'] + if not args.sender: + args.sender = ['$default'] + return args + +def read_config(filenames): + c = configparser.ConfigParser() + c.read(filenames) + return c + +def list_products(expr): + l = [] + e = re.compile(expr, flags=re.IGNORECASE) + for k,v in inema.marke_products.items(): + if not e.search(v['name']): + continue + h = v + h['id'] = int(k) + if not h['max_weight']: + h['max_weight'] = '' + s = h['cost_price'] + t = s.split('.') + if t.__len__() == 1: + h['cost_price'] = s + ' ' * 3 + else: + h['cost_price'] = s + ' ' * (2 - t[1].__len__()) + l.append(h) + l.sort(key=lambda h : h['id']) + fs = '{:>6} {:<70} {:>6} {:>6} {:>5}' + print(fs.format('id', 'name', 'EUR', 'g', 'intl')) + print('-'*(6+70+6+6+5 +4)) + for h in l: + print(fs.format(h['id'], h['name'], h['cost_price'], h['max_weight'], + h['international'])) + +def list_formats(expr): + e = re.compile(expr, flags=re.IGNORECASE) + fs = '{:>6} {:<44} {:>3}*{:>3} {:>5} {:<12} {:>3} {:>3}' + print(fs.format('id', 'name', 'w', 'h', '#ls', 'type', 'adr', 'img')) + print('-'*(6+44+3+3+5+12+3+3 +7)) + for f in inema.formats: + if e.search(f['name']) or e.search(f['pageType']) \ + or e.search('{}x{}'.format(f['pageLayout']['size']['x'], + f['pageLayout']['size']['y'])): + print(fs.format(f['id'], f['name'], int(f['pageLayout']['size']['x']), + int(f['pageLayout']['size']['y']), + int(f['pageLayout']['labelCount']['labelX']) + *int(f['pageLayout']['labelCount']['labelY']), f['pageType'], + f['isAddressPossible'], f['isImagePossible']) ) + +def parse_address(s, conf): + if s.startswith('$'): + h = conf['a.'+s[1:]] + return ( h.get('first', ''), h['name'], h['street'], h['number'], + h['zip'], h['city'], h.get('country', 'DEU') ) + delimiter = None + for d in ['\n', ';']: + if d in s: + delimiter = d + if not delimiter: + raise ValueError('recipient string has no known delimiters') + first = ''; name = ''; street = ''; number = ''; zipcode = ''; city = '' + country = 'DEU' + l = s.split(delimiter) + if l.__len__() > 0: + xs = l[0].split(' ') + if xs.__len__() == 1: + first = '' + name = xs[0] + else: + first = ' '.join(xs[0:-1]) + name = xs[-1] + if l.__len__() > 1: + xs = l[1].split(' ') + if xs.__len__() == 1: + street = xs[0] + number = '' + else: + street = ' '.join(xs[0:-1]) + number = xs[-1] + if l.__len__() > 2: + xs = l[2].split(' ') + if xs.__len__() == 1: + zipcode = '' + city = xs[0] + else: + zipcode = xs[0] + city = ' '.join(xs[1:]) + if l.__len__() > 3: + c = l[3].strip() + if c: + country = c + return (first, name, street, number, zipcode, city, country) + +def parse_csv(filename): + xs = [] + ps = [] + with open(filename, 'r') as f: + rs = csv.reader(f) + next(rs) + for r in rs: + xs.append(r[0:7] + ['']*(7-r.__len__())) + if r.__len__() > 7: + ps.append(r[7]) + return (xs, ps) + + +def parse_addresses(args, conf): + recipients = [] + for r in args.recipients: + recipients.append(parse_address(r, conf)) + args.recipients = recipients + sender = [] + for r in args.sender: + sender.append(parse_address(r, conf)) + args.sender = sender + if args.csv: + t = parse_csv(args.csv) + args.recipients = args.recipients + t[0] + if not args.product: + args.product = [] + args.product = args.product + t[1] + +def apply_config(args, conf): + if not args.manifest and conf.has_section('general'): + args.manifest = conf['general'].get('manifest', False) + +def mk_address(im, x, conf): + a = im.build_addr(x[-5], x[-4], x[-3], x[-2], x[-1]) + if x[0]: + r = im.build_pers_addr(x[0], x[1], a) + else: + r = im.build_comp_addr(x[1], a) + return r; + +def buy(im, sender, recipient, product, i, pi, args, conf): + src = mk_address(im, sender, conf) + dst = mk_address(im, recipient, conf) + page = int(i / (pi[0] * pi[1])) + 1 + column = int(i % pi[0]) + 1 + row = int(int(i / pi[0]) % pi[1]) + 1 + p = im.build_position(product, src, dst, pdf=True, + page=page, x=column, y=row) + im.add_position(p) + + +def mk_filename(args, base='postage'): + filename = '{}/{}_{}{}.pdf'.format(args.output, base, + datetime.datetime.now().strftime('%Y-%m-%d'), args.suffix) + return filename + +def store_files(res, args): + if hasattr(res, 'manifest_pdf_bin') and args.manifest: + with open(mk_filename(base='manifest'), 'wb') as f: + f.write(res.manifest_pdf_bin) + i = 1 + pdf_bin = None + if hasattr(res, 'pdf_bin'): + pdf_bin = res.pdf_bin + elif hasattr(res.shoppingCart.voucherList.voucher[0], 'pdf_bin'): + pdf_bin = res.shoppingCart.voucherList.voucher[0].pdf_bin + if pdf_bin: + filename = mk_filename(args) + log.info('Writing: {}'.format(filename)) + with open(filename, 'wb') as f: + f.write(pdf_bin) + +def get_format(ident): + for f in inema.formats: + if f['id'] == int(ident): + return f + raise ValueError("Couldn't find format id: {}".format(ident)) + +def get_page_info(f): + return ( int(f['pageLayout']['labelCount']['labelX']), + int(f['pageLayout']['labelCount']['labelY']) ) + +def do_list_products(args): + if args.list_products: + list_products(args.list_products) + return True + +def do_list_formats(args): + if args.list_formats: + if args.json: + print(json.dumps(inema.formats, indent=2)) + else: + list_formats(args.list_formats) + return True + +def do_update_list_formats(im, args): + if args.list_formats and args.update: + inema.formats = sorted(zeep.helpers.serialize_object( + im.retrievePageFormats()), key=lambda x:x['id']) + return do_list_formats(args) + +def do_create_preview(im, args): + if args.preview: + link = im.retrievePreviewPDF(args.product[0], args.format) + pdf = requests.get(link, stream=True) + with open(mk_filename(args), 'wb') as f: + f.write(pdf.content) + return True + +def run(args, conf): + if do_list_products(args): + return 0 + if not args.update and do_list_formats(args): + return 0 + ps = args.product + ss = args.sender + if args.dry: + im = Fake_IM() + else: + im = Internetmarke(conf['api']['id'], conf['api']['key'], + conf['api'].get('key_phase', '1')) + im.authenticate(conf['account']['user'], conf['account']['password']) + if do_create_preview(im, args): + return 0 + if do_update_list_formats(im, args): + return 0 + page = get_page_info(get_format(args.format)) + i = 0 + for r in args.recipients: + buy(im, ss[0], r, ps[0], i, page, args, conf) + if ps.__len__() > 1: + ps = ps[1:] + if ss.__len__() > 1: + ss = ss[1:] + i = i + 1 + log.warn('Buying postage for {} €'.format(im.compute_total()/100)) + res = im.checkoutPDF(args.format) + store_files(res, args) + return 0 + +def imain(args): + conf = read_config([args.global_conf, args.sys_conf] + + [os.path.expanduser(x) for x in args.config] ) + parse_addresses(args, conf) + apply_config(args, conf) + try: + return run(args, conf) + except zeep.exceptions.Fault as e: + d = str(e.detail) + try: + d = zeep.wsdl.utils.etree_to_string(e.detail).decode() + ids = e.detail.xpath('//*[name()="id"]') + ms = e.detail.xpath('//*[name()="message"]') + d = " - ".join(", ".join(x.text for x in xs) for xs in (ids, ms)) + except TypeError: + pass + log.error('{} ({})'.format(e.message, d)) + return 1 + +def main(): + setup_logging() + args = parse_args() + log.debug('Starting frank.py') + imain(args) + +if __name__ == '__main__': + sys.exit(main()) + diff --git a/setup.py b/setup.py index a63a0dc..7e5cabe 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ install_requires = [ setup( name='inema', - version='0.2', + version='0.3', description='A Python interface to the Deutsche Post Internetmarke Online Franking', long_description=open('README.rst').read(), author='Harald Welte', @@ -24,4 +24,7 @@ setup( 'Programming Language :: Python :: 3', 'Topic :: Office/Business', ], + entry_points={ + 'console_scripts': [ 'frank = inema.frank:main' ] + }, )