1"""An NNTP client class based on RFC 977: Network News Transfer Protocol.
2
3Example:
4
5>>> from nntplib import NNTP
6>>> s = NNTP('news')
7>>> resp, count, first, last, name = s.group('comp.lang.python')
8>>> print 'Group', name, 'has', count, 'articles, range', first, 'to', last
9Group comp.lang.python has 51 articles, range 5770 to 5821
10>>> resp, subs = s.xhdr('subject', first + '-' + last)
11>>> resp = s.quit()
12>>>
13
14Here 'resp' is the server response line.
15Error responses are turned into exceptions.
16
17To post an article from a file:
18>>> f = open(filename, 'r') # file containing article, including header
19>>> resp = s.post(f)
20>>>
21
22For descriptions of all methods, read the comments in the code below.
23Note that all arguments and return values representing article numbers
24are strings, not numbers, since they are rarely used for calculations.
25"""
26
27# RFC 977 by Brian Kantor and Phil Lapsley.
28# xover, xgtitle, xpath, date methods by Kevan Heydon
29
30
31# Imports
32import re
33import socket
34
35__all__ = ["NNTP","NNTPReplyError","NNTPTemporaryError",
36           "NNTPPermanentError","NNTPProtocolError","NNTPDataError",
37           "error_reply","error_temp","error_perm","error_proto",
38           "error_data",]
39
40# Exceptions raised when an error or invalid response is received
41class NNTPError(Exception):
42    """Base class for all nntplib exceptions"""
43    def __init__(self, *args):
44        Exception.__init__(self, *args)
45        try:
46            self.response = args[0]
47        except IndexError:
48            self.response = 'No response given'
49
50class NNTPReplyError(NNTPError):
51    """Unexpected [123]xx reply"""
52    pass
53
54class NNTPTemporaryError(NNTPError):
55    """4xx errors"""
56    pass
57
58class NNTPPermanentError(NNTPError):
59    """5xx errors"""
60    pass
61
62class NNTPProtocolError(NNTPError):
63    """Response does not begin with [1-5]"""
64    pass
65
66class NNTPDataError(NNTPError):
67    """Error in response data"""
68    pass
69
70# for backwards compatibility
71error_reply = NNTPReplyError
72error_temp = NNTPTemporaryError
73error_perm = NNTPPermanentError
74error_proto = NNTPProtocolError
75error_data = NNTPDataError
76
77
78
79# Standard port used by NNTP servers
80NNTP_PORT = 119
81
82
83# Response numbers that are followed by additional text (e.g. article)
84LONGRESP = ['100', '215', '220', '221', '222', '224', '230', '231', '282']
85
86
87# Line terminators (we always output CRLF, but accept any of CRLF, CR, LF)
88CRLF = '\r\n'
89
90
91
92# The class itself
93class NNTP:
94    def __init__(self, host, port=NNTP_PORT, user=None, password=None,
95                 readermode=None, usenetrc=True):
96        """Initialize an instance.  Arguments:
97        - host: hostname to connect to
98        - port: port to connect to (default the standard NNTP port)
99        - user: username to authenticate with
100        - password: password to use with username
101        - readermode: if true, send 'mode reader' command after
102                      connecting.
103
104        readermode is sometimes necessary if you are connecting to an
105        NNTP server on the local machine and intend to call
106        reader-specific commands, such as `group'.  If you get
107        unexpected NNTPPermanentErrors, you might need to set
108        readermode.
109        """
110        self.host = host
111        self.port = port
112        self.sock = socket.create_connection((host, port))
113        self.file = self.sock.makefile('rb')
114        self.debugging = 0
115        self.welcome = self.getresp()
116
117        # 'mode reader' is sometimes necessary to enable 'reader' mode.
118        # However, the order in which 'mode reader' and 'authinfo' need to
119        # arrive differs between some NNTP servers. Try to send
120        # 'mode reader', and if it fails with an authorization failed
121        # error, try again after sending authinfo.
122        readermode_afterauth = 0
123        if readermode:
124            try:
125                self.welcome = self.shortcmd('mode reader')
126            except NNTPPermanentError:
127                # error 500, probably 'not implemented'
128                pass
129            except NNTPTemporaryError, e:
130                if user and e.response[:3] == '480':
131                    # Need authorization before 'mode reader'
132                    readermode_afterauth = 1
133                else:
134                    raise
135        # If no login/password was specified, try to get them from ~/.netrc
136        # Presume that if .netc has an entry, NNRP authentication is required.
137        try:
138            if usenetrc and not user:
139                import netrc
140                credentials = netrc.netrc()
141                auth = credentials.authenticators(host)
142                if auth:
143                    user = auth[0]
144                    password = auth[2]
145        except IOError:
146            pass
147        # Perform NNRP authentication if needed.
148        if user:
149            resp = self.shortcmd('authinfo user '+user)
150            if resp[:3] == '381':
151                if not password:
152                    raise NNTPReplyError(resp)
153                else:
154                    resp = self.shortcmd(
155                            'authinfo pass '+password)
156                    if resp[:3] != '281':
157                        raise NNTPPermanentError(resp)
158            if readermode_afterauth:
159                try:
160                    self.welcome = self.shortcmd('mode reader')
161                except NNTPPermanentError:
162                    # error 500, probably 'not implemented'
163                    pass
164
165
166    # Get the welcome message from the server
167    # (this is read and squirreled away by __init__()).
168    # If the response code is 200, posting is allowed;
169    # if it 201, posting is not allowed
170
171    def getwelcome(self):
172        """Get the welcome message from the server
173        (this is read and squirreled away by __init__()).
174        If the response code is 200, posting is allowed;
175        if it 201, posting is not allowed."""
176
177        if self.debugging: print '*welcome*', repr(self.welcome)
178        return self.welcome
179
180    def set_debuglevel(self, level):
181        """Set the debugging level.  Argument 'level' means:
182        0: no debugging output (default)
183        1: print commands and responses but not body text etc.
184        2: also print raw lines read and sent before stripping CR/LF"""
185
186        self.debugging = level
187    debug = set_debuglevel
188
189    def putline(self, line):
190        """Internal: send one line to the server, appending CRLF."""
191        line = line + CRLF
192        if self.debugging > 1: print '*put*', repr(line)
193        self.sock.sendall(line)
194
195    def putcmd(self, line):
196        """Internal: send one command to the server (through putline())."""
197        if self.debugging: print '*cmd*', repr(line)
198        self.putline(line)
199
200    def getline(self):
201        """Internal: return one line from the server, stripping CRLF.
202        Raise EOFError if the connection is closed."""
203        line = self.file.readline()
204        if self.debugging > 1:
205            print '*get*', repr(line)
206        if not line: raise EOFError
207        if line[-2:] == CRLF: line = line[:-2]
208        elif line[-1:] in CRLF: line = line[:-1]
209        return line
210
211    def getresp(self):
212        """Internal: get a response from the server.
213        Raise various errors if the response indicates an error."""
214        resp = self.getline()
215        if self.debugging: print '*resp*', repr(resp)
216        c = resp[:1]
217        if c == '4':
218            raise NNTPTemporaryError(resp)
219        if c == '5':
220            raise NNTPPermanentError(resp)
221        if c not in '123':
222            raise NNTPProtocolError(resp)
223        return resp
224
225    def getlongresp(self, file=None):
226        """Internal: get a response plus following text from the server.
227        Raise various errors if the response indicates an error."""
228
229        openedFile = None
230        try:
231            # If a string was passed then open a file with that name
232            if isinstance(file, str):
233                openedFile = file = open(file, "w")
234
235            resp = self.getresp()
236            if resp[:3] not in LONGRESP:
237                raise NNTPReplyError(resp)
238            list = []
239            while 1:
240                line = self.getline()
241                if line == '.':
242                    break
243                if line[:2] == '..':
244                    line = line[1:]
245                if file:
246                    file.write(line + "\n")
247                else:
248                    list.append(line)
249        finally:
250            # If this method created the file, then it must close it
251            if openedFile:
252                openedFile.close()
253
254        return resp, list
255
256    def shortcmd(self, line):
257        """Internal: send a command and get the response."""
258        self.putcmd(line)
259        return self.getresp()
260
261    def longcmd(self, line, file=None):
262        """Internal: send a command and get the response plus following text."""
263        self.putcmd(line)
264        return self.getlongresp(file)
265
266    def newgroups(self, date, time, file=None):
267        """Process a NEWGROUPS command.  Arguments:
268        - date: string 'yymmdd' indicating the date
269        - time: string 'hhmmss' indicating the time
270        Return:
271        - resp: server response if successful
272        - list: list of newsgroup names"""
273
274        return self.longcmd('NEWGROUPS ' + date + ' ' + time, file)
275
276    def newnews(self, group, date, time, file=None):
277        """Process a NEWNEWS command.  Arguments:
278        - group: group name or '*'
279        - date: string 'yymmdd' indicating the date
280        - time: string 'hhmmss' indicating the time
281        Return:
282        - resp: server response if successful
283        - list: list of message ids"""
284
285        cmd = 'NEWNEWS ' + group + ' ' + date + ' ' + time
286        return self.longcmd(cmd, file)
287
288    def list(self, file=None):
289        """Process a LIST command.  Return:
290        - resp: server response if successful
291        - list: list of (group, last, first, flag) (strings)"""
292
293        resp, list = self.longcmd('LIST', file)
294        for i in range(len(list)):
295            # Parse lines into "group last first flag"
296            list[i] = tuple(list[i].split())
297        return resp, list
298
299    def description(self, group):
300
301        """Get a description for a single group.  If more than one
302        group matches ('group' is a pattern), return the first.  If no
303        group matches, return an empty string.
304
305        This elides the response code from the server, since it can
306        only be '215' or '285' (for xgtitle) anyway.  If the response
307        code is needed, use the 'descriptions' method.
308
309        NOTE: This neither checks for a wildcard in 'group' nor does
310        it check whether the group actually exists."""
311
312        resp, lines = self.descriptions(group)
313        if len(lines) == 0:
314            return ""
315        else:
316            return lines[0][1]
317
318    def descriptions(self, group_pattern):
319        """Get descriptions for a range of groups."""
320        line_pat = re.compile("^(?P<group>[^ \t]+)[ \t]+(.*)$")
321        # Try the more std (acc. to RFC2980) LIST NEWSGROUPS first
322        resp, raw_lines = self.longcmd('LIST NEWSGROUPS ' + group_pattern)
323        if resp[:3] != "215":
324            # Now the deprecated XGTITLE.  This either raises an error
325            # or succeeds with the same output structure as LIST
326            # NEWSGROUPS.
327            resp, raw_lines = self.longcmd('XGTITLE ' + group_pattern)
328        lines = []
329        for raw_line in raw_lines:
330            match = line_pat.search(raw_line.strip())
331            if match:
332                lines.append(match.group(1, 2))
333        return resp, lines
334
335    def group(self, name):
336        """Process a GROUP command.  Argument:
337        - group: the group name
338        Returns:
339        - resp: server response if successful
340        - count: number of articles (string)
341        - first: first article number (string)
342        - last: last article number (string)
343        - name: the group name"""
344
345        resp = self.shortcmd('GROUP ' + name)
346        if resp[:3] != '211':
347            raise NNTPReplyError(resp)
348        words = resp.split()
349        count = first = last = 0
350        n = len(words)
351        if n > 1:
352            count = words[1]
353            if n > 2:
354                first = words[2]
355                if n > 3:
356                    last = words[3]
357                    if n > 4:
358                        name = words[4].lower()
359        return resp, count, first, last, name
360
361    def help(self, file=None):
362        """Process a HELP command.  Returns:
363        - resp: server response if successful
364        - list: list of strings"""
365
366        return self.longcmd('HELP',file)
367
368    def statparse(self, resp):
369        """Internal: parse the response of a STAT, NEXT or LAST command."""
370        if resp[:2] != '22':
371            raise NNTPReplyError(resp)
372        words = resp.split()
373        nr = 0
374        id = ''
375        n = len(words)
376        if n > 1:
377            nr = words[1]
378            if n > 2:
379                id = words[2]
380        return resp, nr, id
381
382    def statcmd(self, line):
383        """Internal: process a STAT, NEXT or LAST command."""
384        resp = self.shortcmd(line)
385        return self.statparse(resp)
386
387    def stat(self, id):
388        """Process a STAT command.  Argument:
389        - id: article number or message id
390        Returns:
391        - resp: server response if successful
392        - nr:   the article number
393        - id:   the message id"""
394
395        return self.statcmd('STAT ' + id)
396
397    def next(self):
398        """Process a NEXT command.  No arguments.  Return as for STAT."""
399        return self.statcmd('NEXT')
400
401    def last(self):
402        """Process a LAST command.  No arguments.  Return as for STAT."""
403        return self.statcmd('LAST')
404
405    def artcmd(self, line, file=None):
406        """Internal: process a HEAD, BODY or ARTICLE command."""
407        resp, list = self.longcmd(line, file)
408        resp, nr, id = self.statparse(resp)
409        return resp, nr, id, list
410
411    def head(self, id):
412        """Process a HEAD command.  Argument:
413        - id: article number or message id
414        Returns:
415        - resp: server response if successful
416        - nr: article number
417        - id: message id
418        - list: the lines of the article's header"""
419
420        return self.artcmd('HEAD ' + id)
421
422    def body(self, id, file=None):
423        """Process a BODY command.  Argument:
424        - id: article number or message id
425        - file: Filename string or file object to store the article in
426        Returns:
427        - resp: server response if successful
428        - nr: article number
429        - id: message id
430        - list: the lines of the article's body or an empty list
431                if file was used"""
432
433        return self.artcmd('BODY ' + id, file)
434
435    def article(self, id):
436        """Process an ARTICLE command.  Argument:
437        - id: article number or message id
438        Returns:
439        - resp: server response if successful
440        - nr: article number
441        - id: message id
442        - list: the lines of the article"""
443
444        return self.artcmd('ARTICLE ' + id)
445
446    def slave(self):
447        """Process a SLAVE command.  Returns:
448        - resp: server response if successful"""
449
450        return self.shortcmd('SLAVE')
451
452    def xhdr(self, hdr, str, file=None):
453        """Process an XHDR command (optional server extension).  Arguments:
454        - hdr: the header type (e.g. 'subject')
455        - str: an article nr, a message id, or a range nr1-nr2
456        Returns:
457        - resp: server response if successful
458        - list: list of (nr, value) strings"""
459
460        pat = re.compile('^([0-9]+) ?(.*)\n?')
461        resp, lines = self.longcmd('XHDR ' + hdr + ' ' + str, file)
462        for i in range(len(lines)):
463            line = lines[i]
464            m = pat.match(line)
465            if m:
466                lines[i] = m.group(1, 2)
467        return resp, lines
468
469    def xover(self, start, end, file=None):
470        """Process an XOVER command (optional server extension) Arguments:
471        - start: start of range
472        - end: end of range
473        Returns:
474        - resp: server response if successful
475        - list: list of (art-nr, subject, poster, date,
476                         id, references, size, lines)"""
477
478        resp, lines = self.longcmd('XOVER ' + start + '-' + end, file)
479        xover_lines = []
480        for line in lines:
481            elem = line.split("\t")
482            try:
483                xover_lines.append((elem[0],
484                                    elem[1],
485                                    elem[2],
486                                    elem[3],
487                                    elem[4],
488                                    elem[5].split(),
489                                    elem[6],
490                                    elem[7]))
491            except IndexError:
492                raise NNTPDataError(line)
493        return resp,xover_lines
494
495    def xgtitle(self, group, file=None):
496        """Process an XGTITLE command (optional server extension) Arguments:
497        - group: group name wildcard (i.e. news.*)
498        Returns:
499        - resp: server response if successful
500        - list: list of (name,title) strings"""
501
502        line_pat = re.compile("^([^ \t]+)[ \t]+(.*)$")
503        resp, raw_lines = self.longcmd('XGTITLE ' + group, file)
504        lines = []
505        for raw_line in raw_lines:
506            match = line_pat.search(raw_line.strip())
507            if match:
508                lines.append(match.group(1, 2))
509        return resp, lines
510
511    def xpath(self,id):
512        """Process an XPATH command (optional server extension) Arguments:
513        - id: Message id of article
514        Returns:
515        resp: server response if successful
516        path: directory path to article"""
517
518        resp = self.shortcmd("XPATH " + id)
519        if resp[:3] != '223':
520            raise NNTPReplyError(resp)
521        try:
522            [resp_num, path] = resp.split()
523        except ValueError:
524            raise NNTPReplyError(resp)
525        else:
526            return resp, path
527
528    def date (self):
529        """Process the DATE command. Arguments:
530        None
531        Returns:
532        resp: server response if successful
533        date: Date suitable for newnews/newgroups commands etc.
534        time: Time suitable for newnews/newgroups commands etc."""
535
536        resp = self.shortcmd("DATE")
537        if resp[:3] != '111':
538            raise NNTPReplyError(resp)
539        elem = resp.split()
540        if len(elem) != 2:
541            raise NNTPDataError(resp)
542        date = elem[1][2:8]
543        time = elem[1][-6:]
544        if len(date) != 6 or len(time) != 6:
545            raise NNTPDataError(resp)
546        return resp, date, time
547
548
549    def post(self, f):
550        """Process a POST command.  Arguments:
551        - f: file containing the article
552        Returns:
553        - resp: server response if successful"""
554
555        resp = self.shortcmd('POST')
556        # Raises error_??? if posting is not allowed
557        if resp[0] != '3':
558            raise NNTPReplyError(resp)
559        while 1:
560            line = f.readline()
561            if not line:
562                break
563            if line[-1] == '\n':
564                line = line[:-1]
565            if line[:1] == '.':
566                line = '.' + line
567            self.putline(line)
568        self.putline('.')
569        return self.getresp()
570
571    def ihave(self, id, f):
572        """Process an IHAVE command.  Arguments:
573        - id: message-id of the article
574        - f:  file containing the article
575        Returns:
576        - resp: server response if successful
577        Note that if the server refuses the article an exception is raised."""
578
579        resp = self.shortcmd('IHAVE ' + id)
580        # Raises error_??? if the server already has it
581        if resp[0] != '3':
582            raise NNTPReplyError(resp)
583        while 1:
584            line = f.readline()
585            if not line:
586                break
587            if line[-1] == '\n':
588                line = line[:-1]
589            if line[:1] == '.':
590                line = '.' + line
591            self.putline(line)
592        self.putline('.')
593        return self.getresp()
594
595    def quit(self):
596        """Process a QUIT command and close the socket.  Returns:
597        - resp: server response if successful"""
598
599        resp = self.shortcmd('QUIT')
600        self.file.close()
601        self.sock.close()
602        del self.file, self.sock
603        return resp
604
605
606# Test retrieval when run as a script.
607# Assumption: if there's a local news server, it's called 'news'.
608# Assumption: if user queries a remote news server, it's named
609# in the environment variable NNTPSERVER (used by slrn and kin)
610# and we want readermode off.
611if __name__ == '__main__':
612    import os
613    newshost = 'news' and os.environ["NNTPSERVER"]
614    if newshost.find('.') == -1:
615        mode = 'readermode'
616    else:
617        mode = None
618    s = NNTP(newshost, readermode=mode)
619    resp, count, first, last, name = s.group('comp.lang.python')
620    print resp
621    print 'Group', name, 'has', count, 'articles, range', first, 'to', last
622    resp, subs = s.xhdr('subject', first + '-' + last)
623    print resp
624    for item in subs:
625        print "%7s %s" % item
626    resp = s.quit()
627    print resp
628