diff options
author | Matěj Cepl <mcepl@cepl.eu> | 2023-05-25 10:15:47 +0200 |
---|---|---|
committer | Matěj Cepl <mcepl@cepl.eu> | 2023-05-25 11:01:18 +0200 |
commit | ec4b49d843e67b31b33ac81bef55346353f1d04c (patch) | |
tree | 5f60ffae4d6ebe180c5ee4d51d468bf154535251 /src | |
parent | 8006d981ce26fe8c1140e33b9476c08470d59f30 (diff) | |
download | pygn-ec4b49d843e67b31b33ac81bef55346353f1d04c.tar.gz |
refactor: rearrange the project to the src/ layout.0.10.3
Fix also pyproject.toml to generate what seems right.
Add the explicit dependency on nntplib for Python >= 3.12
(gh#python/cpython!104894).
Fixes: https://todo.sr.ht/~mcepl/pygn/7
Diffstat (limited to 'src')
-rw-r--r-- | src/mail2news.py | 195 | ||||
-rw-r--r-- | src/news2mail.py | 174 | ||||
-rwxr-xr-x | src/pygm2n | 99 | ||||
-rwxr-xr-x | src/pygn2m | 126 | ||||
-rw-r--r-- | src/whitelist.py | 99 | ||||
-rw-r--r-- | src/wlp.py | 22 | ||||
-rw-r--r-- | src/wlp_parser.py | 69 |
7 files changed, 784 insertions, 0 deletions
diff --git a/src/mail2news.py b/src/mail2news.py new file mode 100644 index 0000000..6e8fe53 --- /dev/null +++ b/src/mail2news.py @@ -0,0 +1,195 @@ +"""Mail to news gateway script. Copyright 2000 Cosimo Alfarano + +Author: Cosimo Alfarano +Date: September 16 2000 + +mail2news.py - (C) 2000 by Cosimo Alfarano <Alfarano@Students.CS.UniBo.It> +You can use this software under the terms of the GPL. If we meet some day, +and you think this stuff is worth it, you can buy me a beer in return. + +Thanks to md for this useful formula. Beer is beer. + +Gets news email and sends it via SMTP. + +class mail2news is hopefully conform to rfc850. + +""" +import io +from collections import OrderedDict +import email +import email.policy +import logging +import nntplib +import os +from re import findall +from socket import gethostbyaddr, gethostname +import sys +import tempfile + + +#logging.basicConfig(level=logging.DEBUG) +# This is the single source of Truth +# Yes, it is awkward to have it assymetrically here +# and not in news2mail as well. +__version__ = '0.10.3' +__description__ = 'The Python Gateway Script: news2mail mail2news gateway' + + +class mail2news(object): + """news to mail gateway class""" + + def __init__(self, options): + # newsgroups = None # Newsgroups: local.test,local.moderated... + # approved = None # Approved: kame@aragorn.lorien.org + if 'NNTPHOST' in os.environ: + self.newsserver = os.environ['NNTPHOST'] + else: + self.newsserver = 'localhost' + + self.port = 119 + self.user = None + self.password = None + self.verbose = options.verbose + logging.debug('self.verbose = %s', self.verbose) + + self.hostname = gethostbyaddr(gethostname())[0] + + self.heads_dict, self.smtpheads, self.nntpheads = {}, {}, {} + self.message = self.__readfile(options) + + self.message['X-Gateway'] = 'pyg {0} {1}'.format(VERSION, DESC) + + def __add_header(self, header, value, msg=None): + if msg is None: + msg = self.message + if value: + msg[header] = value.strip() + + def __readfile(self, opt): + message = email.message_from_file(sys.stdin, policy=email.policy.SMTP) + + if (len(message) == 0) \ + and message.get_payload().startswith('/'): + msg_file_name = message.get_payload().strip() + del message + with open(msg_file_name, 'r') as msg_file: + message = email.message_from_file(msg_file, policy=email.policy.SMTP) + + # introduce nntpheads + self.__add_header('Newsgroups', opt.newsgroup, message) + self.__add_header('Approved', opt.approver, message) + + return message + + def renameheads(self): + """rename headers such as Resent-*: to X-Resent-*: + + headers renamed are useless or not rfc 977/850 copliant + handles References/In-Reply-To headers + """ + try: + + for key in list(self.message.keys()): + if key.startswith('Resent-'): + if ('X-' + key) in self.message: + self.message['X-Original-' + key] = \ + self.message['X-' + key] + self.message['X-' + key] = self.message[key] + del self.message[key] + + # In rfc822 References: is considered, but many MUA doen't put it. + if ('References' not in self.message) and \ + ('In-Reply-To' in self.message): + print(self.message['In-Reply-To']) + + # some MUA uses msgid without '<' '>' +# ref = findall('([^\s<>\']+@[^\s<>;:\']+)', \ + # but I prefer use RFC standards + ref = findall('(<[^<>]+@[^<>]+>)', + self.message['In-Reply-To']) + + # if found, keep first element that seems a Msg-ID. + if (ref and len(ref)): + self.message['References'] = '%s\n' % ref[0] + + except KeyError as message: + print(message) + + def removeheads(self, heads=None): + """remove headers like Xref: Path: Lines: + """ + + try: + # removing some others useless headers .... (From is not From:) + + rmheads = ['Received', 'From ', 'NNTP-Posting-Host', + 'X-Trace', 'X-Compliants-To', 'NNTP-Posting-Date'] + if heads: + rmheads.append(heads) + + for head in rmheads: + if head in self.message: + del self.message[head] + + if 'Message-Id' in self.message: + msgid = self.message['Message-Id'] + del self.message['Message-Id'] + self.message['Message-Id'] = msgid + else: + msgid = '<pyg.%d@tuchailepuppapera.org>\n' % (os.getpid()) + self.message['Message-Id'] = msgid + + except KeyError as message: + print(message) + + def sortheads(self): + """make list sorted by heads: From: To: Subject: first, + others, X-*, X-Resent-* last""" + + heads_dict = OrderedDict(self.message) + for hdr in list(self.message.keys()): + del self.message[hdr] + + # put at top + head_set = ('Newsgroups', 'From', 'To', 'X-To', 'Cc', 'Subject', + 'Date', 'Approved', 'References', 'Message-Id') + + logging.debug('heads_dict = %s', heads_dict) + for k in head_set: + if k in heads_dict: + self.__add_header(k, heads_dict[k]) + + for k in heads_dict: + if not k.startswith('X-') and not k.startswith('X-Resent-') \ + and k not in head_set: + self.__add_header(k, heads_dict[k]) + + for k in heads_dict: + if k.startswith('X-'): + self.__add_header(k, heads_dict[k]) + + for k in heads_dict: + if k.startswith('X-Resent-'): + self.__add_header(k, heads_dict[k]) + + def sendemail(self): + "Talk to NNTP server and try to send email." + # readermode must be True, otherwise we don't have POST command. + server = nntplib.NNTP(self.newsserver, self.port, self.user, + self.password, readermode=True) + + logging.debug('self.verbose = %s', self.verbose) + if self.verbose: + server.set_debuglevel(2) + + msg_bytes = self.message.as_bytes() + try: + server.post(io.BytesIO(msg_bytes)) + except UnicodeEncodeError: + with tempfile.NamedTemporaryFile(suffix="eml", prefix="failed_msg", + delete=False) as tmpf: + tmpf.write(msg_bytes) + logging.info(f"failed file name = {tmpf.name}") + logging.exception("Failed to convert message!") + + server.quit() diff --git a/src/news2mail.py b/src/news2mail.py new file mode 100644 index 0000000..33d2590 --- /dev/null +++ b/src/news2mail.py @@ -0,0 +1,174 @@ +"""News to mail gateway script. Copyright 2000 Cosimo Alfarano + +Author: Cosimo Alfarano +Date: June 11 2000 + +news2mail.py - (C) 2000 by Cosimo Alfarano <Alfarano@Students.CS.UniBo.It> +You can use this software under the terms of the GPL. If we meet some day, +and you think this stuff is worth it, you can buy me a beer in return. + +Thanks to md for this useful formula. Beer is beer. + +Gets news article and sends it via SMTP. + +class news2mail is hopefully conform to rfc822. + +normal (what pygs does) operations flow is: +1) reads from stdin NNTP article (readfile) +2) divide headers and body (parsearticle) +3) merges NNTP and SMTP heads into a unique heads +4) adds, renames and removes some heads +5) sorts remaining headers starting at top with Received: From: To: Subject: + Date:, normal headers ending with X-* and Resent-* headers. + +""" +from __future__ import absolute_import +from __future__ import print_function +from collections import OrderedDict +import email +import email.policy +import smtplib +from socket import gethostbyaddr, gethostname +import sys +import time +from mail2news import VERSION, DESC + + +# logging.basicConfig(level=logging.DEBUG) +class news2mail(object): + """news to mail gateway class""" + + def __init__(self, verbose=False): + self.wlfile = None + self.logfile = None + self.verbose = verbose + + self.sender = '' + self.rcpt = '' + self.envelope = '' + + self.smtpserver = 'localhost' + + self.hostname = gethostbyaddr(gethostname())[0] + + self.heads_dict = {} + self.article, self.headers, self.body = [], [], [] + self.message = self.__addheads( + email.message_from_file(sys.stdin, policy=email.policy.SMTP)) + + def __addheads(self, msg): + """add new header like X-Gateway: Received: + """ + + msg['X-Gateway'] = 'pyg {0} {1}'.format(VERSION, DESC) + + # to make Received: header + t = time.ctime(time.time()) + + if time.daylight: + tzone = time.tzname[1] + else: + tzone = time.tzname[0] + + # An example from debian-italian: + # Received: from murphy.debian.org (murphy.debian.org [216.234.231.6]) + # by smv04.iname.net (8.9.3/8.9.1SMV2) with SMTP id JAA26407 + # for <kame.primo@innocent.com> sent by + # <debian-italian-request@lists.debian.org + + tmp = 'from GATEWAY by ' + self.hostname + \ + ' with pyg' + \ + '\n\tfor <' + self.rcpt + '> ; ' + \ + t + ' (' + tzone + ')\n' + + msg['Received'] = tmp + + return msg + + def __renameheads(self): + """remove headers like Xref: Path: Lines: + rename headers such as Newsgroups: to X-Newsgroups: + + headers renamed are useless or not rfc 822 copliant + """ + try: + if 'Newsgroups' in self.message: + self.message['X-Newsgroups'] = \ + self.message['Newsgroups'] + del self.message['Newsgroups'] + + if 'NNTP-Posting-Host' in self.message: + self.message['X-NNTP-Posting-Host'] = \ + self.message['NNTP-Posting-Host'] + del self.message['NNTP-Posting-Host'] + except KeyError as ex: + print(ex) + + try: + # removing some others useless headers .... + # that includes BOTH 'From ' and 'From' + # 'Sender is usually set by INN, if ng is moderated... + for key in ('Approved', 'From', 'Xref', 'Path', 'Lines', 'Sender'): + if key in self.message: + del self.message[key] + + if 'Message-id' in self.message: + msgid = self.message['Message-id'] + del self.message['Message-id'] + self.message['Message-Id'] = msgid + else: + # It should put a real user@domain + self.heads_dict['Message-Id'] = 'pyg@puppapera.org' + + if 'References' in self.message and \ + 'In-Reply-To' not in self.message: + refs = self.message['References'].split() + self.message['In-Reply-To'] = refs[-1] + + except KeyError as message: + print(message) + + def __sortheads(self): + """make list sorting heads, Received: From: To: Subject: first, + others, X-*, Resent-* last""" + + # put at top + header_set = ('Received', 'From', 'To', 'Subject', 'Date') + + heads_dict = OrderedDict(self.message) + for hdr in list(self.message.keys()): + del self.message[hdr] + + for k in header_set: + if k in heads_dict: + self.message[k] = heads_dict[k] + + for k in heads_dict: + if not k.startswith('X-') and not k.startswith('Resent-') \ + and k not in header_set: + self.message[k] = heads_dict[k] + + for k in heads_dict: + if k.startswith('X-'): + self.message[k] = heads_dict[k] + + for k in heads_dict: + if k.startswith('Resent-'): + self.message[k] = heads_dict[k] + + def process_message(self): + """phase 3: + format rfc 822 headers from input article + """ + self.__renameheads() # remove other heads + self.__sortheads() + + def sendarticle(self): + """Talk to SMTP server and try to send email.""" + s = smtplib.SMTP(self.smtpserver) + s.set_debuglevel(self.verbose) + + s.sendmail(self.envelope, self.rcpt, self.message.as_bytes()) + + s.quit() + diff --git a/src/pygm2n b/src/pygm2n new file mode 100755 index 0000000..3629857 --- /dev/null +++ b/src/pygm2n @@ -0,0 +1,99 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""News to mail gateway script. Copyright 2000 Cosimo Alfarano + +Author: Cosimo Alfarano +Date: June 11 2000 + +pygs - Copyright 2000 by Cosimo Alfarano <Alfarano@Students.CS.UniBo.It> +You can use this software under the terms of the GPL. If we meet some day, +and you think this stuff is worth it, you can buy me a beer in return. + +Thanks to md for this useful formula. Beer is beer. + +Gets news article and sends it via SMTP. +""" +from __future__ import print_function + +import argparse +import logging +import mail2news +import nntplib +import sys + +# logging.basicConfig(level=logging.DEBUG) + +def parse_cmdline(): + parser = argparse.ArgumentParser( + description='%s version %s - Copyright 2000 Cosimo Alfarano\n%s' % + ('pyg', mail2news.VERSION, mail2news.DESC)) + + parser.add_argument('-s', '--newsserver', default='') + parser.add_argument('-a', '--approver', default='', + help="address of moderator/approver") + parser.add_argument('-n', '--newsgroup', default='', + help='newsgroup[s] (specified as comma separated ' + + 'without spaces list)', required=True) + parser.add_argument('-u', '--user', default='', + help='NNTP server user (for authentication)') + parser.add_argument('-p', '--password', default='', + help='NNTP server password (for authentication)') + parser.add_argument('-P', '--port', default='') + parser.add_argument('-e', '--envellope', default='') + parser.add_argument('-l', '--logfile') + + parser.add_argument('-T', '--test', action='store_true', + help='test mode (not send article via NNTP)') + parser.add_argument('-v', '--verbose', action='store_true', + help='verbose output ' + + '(usefull with -T option for debugging)') + + args = parser.parse_args() + + if not args.newsgroup: + raise argparse.ArgumentError('Error: Missing Newsgroups\n') + + return args + + +"""main is structured in 4 phases: + 1) check and set pyg's internal variables + 2) check whitelist for users' permission + 3) format rfc 822 headers from input article + 4) open smtp connection and send e-mail +""" + + +try: + + """phase 1: + check and set pyg's internal variables + """ + opt = parse_cmdline() + + m2n = mail2news.mail2news(opt) + owner = None + + """phase 3: + format rfc 822 headers from input article + """ + m2n.renameheads() # rename useless heads + m2n.removeheads() # remove other heads + + m2n.sortheads() # sort remaining heads :) + + if opt.verbose: + print(m2n.message.as_string()) + + logging.debug('m2n.payload = len %d', len(m2n.message.get_payload())) + if len(m2n.message.get_payload()) > 0: +# wl.logmsg(m2n.heads_dict,wl.ACCEPT,owner) + if not opt.test: + try: + resp = m2n.sendemail() + except nntplib.NNTPError as ex: + print(ex) + +except KeyboardInterrupt: + print('Keyboard Interrupt') + sys.exit(0) diff --git a/src/pygn2m b/src/pygn2m new file mode 100755 index 0000000..a2c07cb --- /dev/null +++ b/src/pygn2m @@ -0,0 +1,126 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""News to mail gateway script. Copyright 2000 Cosimo Alfarano + +Author: Cosimo Alfarano +Date: June 11 2000 + +pygs - Copyright 2000 by Cosimo Alfarano <Alfarano@Students.CS.UniBo.It> +You can use this software under the terms of the GPL. If we meet some day, +and you think this stuff is worth it, you can buy me a beer in return. + +Thanks to md for this useful formula. Beer is beer. + +Gets news article from stdin and sends it via SMTP. +""" +from __future__ import print_function + +import argparse +from mail2news import VERSION, DESC +import news2mail +import os +import sys +import whitelist + + +def parse_cmdline(): + """ + set a dictionary with smtp new header in gw parameter (gw.smtpheads) + return (test,verbose) boolean tuple + """ + parser = argparse.ArgumentParser( + description='pyg version %s - Copyright 2000 Cosimo Alfarano\n%s' % + (VERSION, DESC)) + + parser.add_argument('-H', '--smtpserver', default='') + parser.add_argument('-s', '--sender', required=True, default='') + parser.add_argument('-e', '--envelope', default='') + parser.add_argument('-t', '--to', dest='rcpt', required=True) + parser.add_argument('-w', '--wlfile') + parser.add_argument('-l', '--logfile') + + parser.add_argument('-T', '--test', + help='test mode (not send article via SMTP)', + action='store_true') + parser.add_argument('-v', '--verbose', help='verbose output', + action='store_true') + + opts = parser.parse_args() + +# By rfc822 [Resent-]Sender: should be ever set, unless == From: +# (not this case). Should be a human, while [Resent-]From: may be a program. + + if opts.rcpt == '' or opts.sender == '': + raise argparse.ArgumentError('missing command line option') + + if opts.envelope == '' and opts.sender != '': + opts.envelope = opts.sender + + return opts + + +"""main is structured in 4 phases: + 1) check and set pyg's internal variables + 2) check whitelist for users' permission + 3) format rfc 822 headers from input article + 4) open smtp connection and send e-mail +""" + +"""phase 1: +check and set pyg's internal variables +""" + +# it returns only test, other parms are set directly in the actual +# parameter +args = parse_cmdline() + +n2m = news2mail.news2mail(verbose=args.verbose) +owner = None + +# check if n2m has some file prefercences set on commandline +if args.wlfile is None: + wl = os.path.expanduser(os.path.join(os.path.dirname(__file__), 'pyg.whitelist')) +else: + wl = args.wlfile + +if args.logfile is None: + log = os.path.expanduser(os.path.join(os.path.dirname(__file__), 'pyg.log')) +else: + log = args.logfile + +wl = whitelist.whitelist(wl, log) + +"""phase 2: +check whitelist for user's permission +""" + +# make a first check of From: address +owner = wl.checkfrom(n2m.message['From']) +if owner is None: + if sys.stdin.isatty() == 1 or args.test: + print ('"%s" is not in whitelist!' % (n2m.message['From'][:-1])) + else: + wl.logmsg(n2m.nntpheads, wl.DENY) + + # if verbose, I want to print out headers, so I can't + # exit now. + if not args.verbose: + sys.exit(1) + +# Reformat the message +n2m.process_message() + +# prints formatted email message only (without send) if user wants +if args.verbose: + print(n2m.message.as_string()) + +if owner is None: + sys.exit(1) + +"""phase 4: +open smtp connection and send e-mail +""" + +wl.logmsg(n2m.heads_dict, wl.ACCEPT, owner) +if not args.test: + n2m.sendarticle() diff --git a/src/whitelist.py b/src/whitelist.py new file mode 100644 index 0000000..4e02f0e --- /dev/null +++ b/src/whitelist.py @@ -0,0 +1,99 @@ +"""News to mail gateway script. Copyright 2000 Cosimo Alfarano + +Author: Cosimo Alfarano +Date: June 11 2000 + +whitelist.py - (C) 2000 by Cosimo Alfarano <Alfarano@Students.CS.UniBo.It> +You can use this software under the terms of the GPL. If we meet some day, +and you think this stuff is worth it, you can buy me a beer in return. + +Thanks to md for this useful formula. Beer is beer. + +whitelist manage a list of trusted user. +""" + +from __future__ import absolute_import +import logging +# logging.basicConfig(level=logging.DEBUG) +import sys +import time + +import wlp + + +class whitelist(object): + """whitelist handling class + + Do you really want anyone can post? Ah ah ah. + """ + wl = {} + + # constants + DENY = 0 + ACCEPT = 1 + + def __init__(self, wlfile, logfile='pyg.log'): + self.logger = logging.getLogger(__name__) + self.logger.setLevel(logging.INFO) + log_fh = logging.FileHandler(logfile) + log_fmt = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s') + log_fh.setFormatter(log_fmt) + self.logger.addHandler(log_fh) + + # dict is a { ownername : {variable: value}} dictionary of dictionaries + with open(wlfile) as inf: + self.wl = wlp.mkdict(inf) + + def checkfrom(self, fromhead): + """have you permission to be here, sir?""" + for owner in self.wl: + # here colon after 'From' IS required, because binary module wl + # expects it. + # TODO: when switching to the python lexxing, remove this + # limitation. + if fromhead.find(self.wl[owner]['From:']) >= 0: + return owner + else: + return None + + def logmsg(self, heads, ok=DENY, owner=None): + """who are walking through my gate? + """ + + ltime = time.ctime(time.time()) + + if time.daylight: + tzone = time.tzname[1] + else: + tzone = time.tzname[0] + + if ok == self.ACCEPT: + self.logger.info('Permission Accorded ') + else: + self.logger.info('Permission Denied ') + + self.logger.info('at %s (%s)', ltime, tzone) + if owner is not None: + self.logger.debug('\tWLOwner: ' + owner + '') + self.logger.debug('\tFrom: ' + heads.get('From', 'NOT PRESENT')) + self.logger.debug('\tSubject: ' + heads.get('Subject', 'NOT PRESENT')) + self.logger.debug('\tSender: ' + heads.get('Sender', 'NOT PRESENT')) + self.logger.debug('\tDate: ' + heads.get('Date', 'NOT PRESENT')) + + # some client create Message-Id other Message-ID. + if 'Message-ID' in heads: + self.logger.debug('\tMessage-ID: ' + heads.get('Message-ID')) + else: + self.logger.debug('\tMessage-Id: ' + heads.get('Message-Id', + 'NOT PRESENT')) + + # X-Newsgroups: and To: are present if user is trusted, else + # Newsgroup: exists since no changes on nntp headers are done. + if 'X-Newsgroups' in heads: + self.logger.debug('\tTo: ' + heads.get('To', 'NOT PRESENT')) + self.logger.debug('\tX-Newsgroups: ' + heads.get('X-Newsgroups', + 'NOT PRESENT')) + else: + self.logger.debug('\tNewsgroups: ' + + heads.get('Newsgroups', 'NOT PRESENT')) diff --git a/src/wlp.py b/src/wlp.py new file mode 100644 index 0000000..e3bcddd --- /dev/null +++ b/src/wlp.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import +import wlp_parser + + +def mkdict(infile): + dict = {} + + tree = wlp_parser.parser.parse( + wlp_parser.lexer.lex(infile.read())) + + for subtree in tree: + current_key = subtree[0].getstr().strip('<>') + if current_key not in dict: + dict[current_key] = {} + + for key, value in subtree[1]: + key = key.getstr() + value = value.getstr().strip("'\"") + dict[current_key][key] = value + + return dict diff --git a/src/wlp_parser.py b/src/wlp_parser.py new file mode 100644 index 0000000..e2e0d19 --- /dev/null +++ b/src/wlp_parser.py @@ -0,0 +1,69 @@ +from __future__ import absolute_import +import rply + +__lg = rply.LexerGenerator() +# Add takes a rule name, and a regular expression that defines the rule. +__lg.add("OWNER", r'<[a-zA-Z0-9_.+-]+@[a-zA-Z0-9._-]+>') +__lg.add("VAL", r'[\'`"][a-zA-Z0-9@_+.<>() -]+[\'`"]') +__lg.add("VAR", r'[a-zA-Z0-9_<>-]+[:]?') + +__lg.ignore(r"\s+") +__lg.ignore(r'[{}=]+') + +lexer = __lg.build() + +__pg = rply.ParserGenerator(['OWNER', 'VAL', 'VAR'], + cache_id='wlp_parser') + +""" + $accept ::= block $end + block ::= blockstatement + | block blockstatement + blockstatement ::= owner '{' commandline '}' + commandline ::= command + | commandline command + command ::= varpart '=' valpart + owner ::= OWNERID + varpart ::= VARID + valpart ::= VALID +""" + + +@__pg.production('main : block') +def main(p): + return p[0] + + +@__pg.production('block : blockstatement') +def block(p): + return p + + +@__pg.production('block : block blockstatement') +def block_blockstatement(p): + p[0].append(p[1]) + return p[0] + + +@__pg.production('blockstatement : OWNER commandline') +def blockstatement(p): + return [p[0], p[1]] + + +@__pg.production('commandline : command') +def commandline(p): + return p + + +@__pg.production('commandline : commandline command') +def commandline_command(p): + p[0].append(p[1]) + return p[0] + + +@__pg.production('command : VAR VAL') +def command(p): + return p + + +parser = __pg.build() |