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