nntplib.py 20.7 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
"""An NNTP client class based on RFC 977: Network News Transfer Protocol.

Example:

>>> from nntplib import NNTP
>>> s = NNTP('news')
>>> resp, count, first, last, name = s.group('comp.lang.python')
>>> print 'Group', name, 'has', count, 'articles, range', first, 'to', last
Group comp.lang.python has 51 articles, range 5770 to 5821
>>> resp, subs = s.xhdr('subject', first + '-' + last)
>>> resp = s.quit()
>>>

Here 'resp' is the server response line.
Error responses are turned into exceptions.

To post an article from a file:
>>> f = open(filename, 'r') # file containing article, including header
>>> resp = s.post(f)
>>>

For descriptions of all methods, read the comments in the code below.
Note that all arguments and return values representing article numbers
are strings, not numbers, since they are rarely used for calculations.
"""

# RFC 977 by Brian Kantor and Phil Lapsley.
# xover, xgtitle, xpath, date methods by Kevan Heydon
29

30 31

# Imports
32
import re
33 34
import socket

35 36 37 38
__all__ = ["NNTP","NNTPReplyError","NNTPTemporaryError",
           "NNTPPermanentError","NNTPProtocolError","NNTPDataError",
           "error_reply","error_temp","error_perm","error_proto",
           "error_data",]
Tim Peters's avatar
Tim Peters committed
39

40 41
# Exceptions raised when an error or invalid response is received
class NNTPError(Exception):
Tim Peters's avatar
Tim Peters committed
42 43
    """Base class for all nntplib exceptions"""
    def __init__(self, *args):
44
        Exception.__init__(self, *args)
Tim Peters's avatar
Tim Peters committed
45 46 47 48
        try:
            self.response = args[0]
        except IndexError:
            self.response = 'No response given'
49 50

class NNTPReplyError(NNTPError):
Tim Peters's avatar
Tim Peters committed
51 52
    """Unexpected [123]xx reply"""
    pass
53 54

class NNTPTemporaryError(NNTPError):
Tim Peters's avatar
Tim Peters committed
55 56
    """4xx errors"""
    pass
57 58

class NNTPPermanentError(NNTPError):
Tim Peters's avatar
Tim Peters committed
59 60
    """5xx errors"""
    pass
61 62

class NNTPProtocolError(NNTPError):
Tim Peters's avatar
Tim Peters committed
63 64
    """Response does not begin with [1-5]"""
    pass
65 66

class NNTPDataError(NNTPError):
Tim Peters's avatar
Tim Peters committed
67 68
    """Error in response data"""
    pass
69

70 71 72 73 74 75
# for backwards compatibility
error_reply = NNTPReplyError
error_temp = NNTPTemporaryError
error_perm = NNTPPermanentError
error_proto = NNTPProtocolError
error_data = NNTPDataError
76

77

Tim Peters's avatar
Tim Peters committed
78

79 80 81 82 83
# Standard port used by NNTP servers
NNTP_PORT = 119


# Response numbers that are followed by additional text (e.g. article)
84
LONGRESP = ['100', '215', '220', '221', '222', '224', '230', '231', '282']
85 86 87 88 89 90


# Line terminators (we always output CRLF, but accept any of CRLF, CR, LF)
CRLF = '\r\n'


Tim Peters's avatar
Tim Peters committed
91

92 93
# The class itself
class NNTP:
Tim Peters's avatar
Tim Peters committed
94
    def __init__(self, host, port=NNTP_PORT, user=None, password=None,
95
                 readermode=None, usenetrc=True):
Tim Peters's avatar
Tim Peters committed
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116
        """Initialize an instance.  Arguments:
        - host: hostname to connect to
        - port: port to connect to (default the standard NNTP port)
        - user: username to authenticate with
        - password: password to use with username
        - readermode: if true, send 'mode reader' command after
                      connecting.

        readermode is sometimes necessary if you are connecting to an
        NNTP server on the local machine and intend to call
        reader-specific comamnds, such as `group'.  If you get
        unexpected NNTPPermanentErrors, you might need to set
        readermode.
        """
        self.host = host
        self.port = port
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.connect((self.host, self.port))
        self.file = self.sock.makefile('rb')
        self.debugging = 0
        self.welcome = self.getresp()
Tim Peters's avatar
Tim Peters committed
117

118
        # 'mode reader' is sometimes necessary to enable 'reader' mode.
Tim Peters's avatar
Tim Peters committed
119
        # However, the order in which 'mode reader' and 'authinfo' need to
120 121 122 123
        # arrive differs between some NNTP servers. Try to send
        # 'mode reader', and if it fails with an authorization failed
        # error, try again after sending authinfo.
        readermode_afterauth = 0
Tim Peters's avatar
Tim Peters committed
124 125 126 127 128 129
        if readermode:
            try:
                self.welcome = self.shortcmd('mode reader')
            except NNTPPermanentError:
                # error 500, probably 'not implemented'
                pass
130 131 132 133 134 135
            except NNTPTemporaryError, e:
                if user and e.response[:3] == '480':
                    # Need authorization before 'mode reader'
                    readermode_afterauth = 1
                else:
                    raise
136 137
        # If no login/password was specified, try to get them from ~/.netrc
        # Presume that if .netc has an entry, NNRP authentication is required.
138
        try:
139
            if usenetrc and not user:
140 141 142 143 144 145 146 147
                import netrc
                credentials = netrc.netrc()
                auth = credentials.authenticators(host)
                if auth:
                    user = auth[0]
                    password = auth[2]
        except IOError:
            pass
148
        # Perform NNRP authentication if needed.
Tim Peters's avatar
Tim Peters committed
149 150 151 152 153 154 155 156 157 158
        if user:
            resp = self.shortcmd('authinfo user '+user)
            if resp[:3] == '381':
                if not password:
                    raise NNTPReplyError(resp)
                else:
                    resp = self.shortcmd(
                            'authinfo pass '+password)
                    if resp[:3] != '281':
                        raise NNTPPermanentError(resp)
159 160 161 162 163 164
            if readermode_afterauth:
                try:
                    self.welcome = self.shortcmd('mode reader')
                except NNTPPermanentError:
                    # error 500, probably 'not implemented'
                    pass
Tim Peters's avatar
Tim Peters committed
165

Tim Peters's avatar
Tim Peters committed
166 167 168 169 170 171 172 173 174 175 176 177

    # Get the welcome message from the server
    # (this is read and squirreled away by __init__()).
    # If the response code is 200, posting is allowed;
    # if it 201, posting is not allowed

    def getwelcome(self):
        """Get the welcome message from the server
        (this is read and squirreled away by __init__()).
        If the response code is 200, posting is allowed;
        if it 201, posting is not allowed."""

178
        if self.debugging: print '*welcome*', repr(self.welcome)
Tim Peters's avatar
Tim Peters committed
179 180 181 182 183 184 185 186 187 188 189 190 191 192
        return self.welcome

    def set_debuglevel(self, level):
        """Set the debugging level.  Argument 'level' means:
        0: no debugging output (default)
        1: print commands and responses but not body text etc.
        2: also print raw lines read and sent before stripping CR/LF"""

        self.debugging = level
    debug = set_debuglevel

    def putline(self, line):
        """Internal: send one line to the server, appending CRLF."""
        line = line + CRLF
193
        if self.debugging > 1: print '*put*', repr(line)
194
        self.sock.sendall(line)
Tim Peters's avatar
Tim Peters committed
195 196 197

    def putcmd(self, line):
        """Internal: send one command to the server (through putline())."""
198
        if self.debugging: print '*cmd*', repr(line)
Tim Peters's avatar
Tim Peters committed
199 200 201 202 203 204 205
        self.putline(line)

    def getline(self):
        """Internal: return one line from the server, stripping CRLF.
        Raise EOFError if the connection is closed."""
        line = self.file.readline()
        if self.debugging > 1:
206
            print '*get*', repr(line)
Tim Peters's avatar
Tim Peters committed
207 208 209 210 211 212 213 214 215
        if not line: raise EOFError
        if line[-2:] == CRLF: line = line[:-2]
        elif line[-1:] in CRLF: line = line[:-1]
        return line

    def getresp(self):
        """Internal: get a response from the server.
        Raise various errors if the response indicates an error."""
        resp = self.getline()
216
        if self.debugging: print '*resp*', repr(resp)
Tim Peters's avatar
Tim Peters committed
217 218 219 220 221 222 223 224 225
        c = resp[:1]
        if c == '4':
            raise NNTPTemporaryError(resp)
        if c == '5':
            raise NNTPPermanentError(resp)
        if c not in '123':
            raise NNTPProtocolError(resp)
        return resp

226
    def getlongresp(self, file=None):
Tim Peters's avatar
Tim Peters committed
227 228
        """Internal: get a response plus following text from the server.
        Raise various errors if the response indicates an error."""
229 230 231 232

        openedFile = None
        try:
            # If a string was passed then open a file with that name
233
            if isinstance(file, str):
234
                openedFile = file = open(file, "w")
235 236 237 238 239 240 241 242 243 244 245

            resp = self.getresp()
            if resp[:3] not in LONGRESP:
                raise NNTPReplyError(resp)
            list = []
            while 1:
                line = self.getline()
                if line == '.':
                    break
                if line[:2] == '..':
                    line = line[1:]
246 247
                if file:
                    file.write(line + "\n")
248 249 250 251 252 253 254
                else:
                    list.append(line)
        finally:
            # If this method created the file, then it must close it
            if openedFile:
                openedFile.close()

Tim Peters's avatar
Tim Peters committed
255 256 257 258 259 260 261
        return resp, list

    def shortcmd(self, line):
        """Internal: send a command and get the response."""
        self.putcmd(line)
        return self.getresp()

262
    def longcmd(self, line, file=None):
Tim Peters's avatar
Tim Peters committed
263 264
        """Internal: send a command and get the response plus following text."""
        self.putcmd(line)
265
        return self.getlongresp(file)
Tim Peters's avatar
Tim Peters committed
266

267
    def newgroups(self, date, time, file=None):
Tim Peters's avatar
Tim Peters committed
268 269 270 271 272 273 274
        """Process a NEWGROUPS command.  Arguments:
        - date: string 'yymmdd' indicating the date
        - time: string 'hhmmss' indicating the time
        Return:
        - resp: server response if successful
        - list: list of newsgroup names"""

275
        return self.longcmd('NEWGROUPS ' + date + ' ' + time, file)
Tim Peters's avatar
Tim Peters committed
276

277
    def newnews(self, group, date, time, file=None):
Tim Peters's avatar
Tim Peters committed
278 279 280 281 282 283
        """Process a NEWNEWS command.  Arguments:
        - group: group name or '*'
        - date: string 'yymmdd' indicating the date
        - time: string 'hhmmss' indicating the time
        Return:
        - resp: server response if successful
284
        - list: list of message ids"""
Tim Peters's avatar
Tim Peters committed
285 286

        cmd = 'NEWNEWS ' + group + ' ' + date + ' ' + time
287
        return self.longcmd(cmd, file)
Tim Peters's avatar
Tim Peters committed
288

289
    def list(self, file=None):
Tim Peters's avatar
Tim Peters committed
290 291 292 293
        """Process a LIST command.  Return:
        - resp: server response if successful
        - list: list of (group, last, first, flag) (strings)"""

294
        resp, list = self.longcmd('LIST', file)
Tim Peters's avatar
Tim Peters committed
295 296
        for i in range(len(list)):
            # Parse lines into "group last first flag"
297
            list[i] = tuple(list[i].split())
Tim Peters's avatar
Tim Peters committed
298 299
        return resp, list

300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335
    def description(self, group):

        """Get a description for a single group.  If more than one
        group matches ('group' is a pattern), return the first.  If no
        group matches, return an empty string.

        This elides the response code from the server, since it can
        only be '215' or '285' (for xgtitle) anyway.  If the response
        code is needed, use the 'descriptions' method.

        NOTE: This neither checks for a wildcard in 'group' nor does
        it check whether the group actually exists."""

        resp, lines = self.descriptions(group)
        if len(lines) == 0:
            return ""
        else:
            return lines[0][1]

    def descriptions(self, group_pattern):
        """Get descriptions for a range of groups."""
        line_pat = re.compile("^(?P<group>[^ \t]+)[ \t]+(.*)$")
        # Try the more std (acc. to RFC2980) LIST NEWSGROUPS first
        resp, raw_lines = self.longcmd('LIST NEWSGROUPS ' + group_pattern)
        if resp[:3] != "215":
            # Now the deprecated XGTITLE.  This either raises an error
            # or succeeds with the same output structure as LIST
            # NEWSGROUPS.
            resp, raw_lines = self.longcmd('XGTITLE ' + group_pattern)
        lines = []
        for raw_line in raw_lines:
            match = line_pat.search(raw_line.strip())
            if match:
                lines.append(match.group(1, 2))
        return resp, lines

Tim Peters's avatar
Tim Peters committed
336 337 338 339 340 341 342 343 344 345 346 347 348
    def group(self, name):
        """Process a GROUP command.  Argument:
        - group: the group name
        Returns:
        - resp: server response if successful
        - count: number of articles (string)
        - first: first article number (string)
        - last: last article number (string)
        - name: the group name"""

        resp = self.shortcmd('GROUP ' + name)
        if resp[:3] != '211':
            raise NNTPReplyError(resp)
349
        words = resp.split()
Tim Peters's avatar
Tim Peters committed
350 351 352 353 354 355 356 357 358
        count = first = last = 0
        n = len(words)
        if n > 1:
            count = words[1]
            if n > 2:
                first = words[2]
                if n > 3:
                    last = words[3]
                    if n > 4:
359
                        name = words[4].lower()
Tim Peters's avatar
Tim Peters committed
360 361
        return resp, count, first, last, name

362
    def help(self, file=None):
Tim Peters's avatar
Tim Peters committed
363 364 365 366
        """Process a HELP command.  Returns:
        - resp: server response if successful
        - list: list of strings"""

367
        return self.longcmd('HELP',file)
Tim Peters's avatar
Tim Peters committed
368 369 370 371 372

    def statparse(self, resp):
        """Internal: parse the response of a STAT, NEXT or LAST command."""
        if resp[:2] != '22':
            raise NNTPReplyError(resp)
373
        words = resp.split()
Tim Peters's avatar
Tim Peters committed
374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393
        nr = 0
        id = ''
        n = len(words)
        if n > 1:
            nr = words[1]
            if n > 2:
                id = words[2]
        return resp, nr, id

    def statcmd(self, line):
        """Internal: process a STAT, NEXT or LAST command."""
        resp = self.shortcmd(line)
        return self.statparse(resp)

    def stat(self, id):
        """Process a STAT command.  Argument:
        - id: article number or message id
        Returns:
        - resp: server response if successful
        - nr:   the article number
394
        - id:   the message id"""
Tim Peters's avatar
Tim Peters committed
395 396 397 398 399 400 401 402 403 404 405

        return self.statcmd('STAT ' + id)

    def next(self):
        """Process a NEXT command.  No arguments.  Return as for STAT."""
        return self.statcmd('NEXT')

    def last(self):
        """Process a LAST command.  No arguments.  Return as for STAT."""
        return self.statcmd('LAST')

406
    def artcmd(self, line, file=None):
Tim Peters's avatar
Tim Peters committed
407
        """Internal: process a HEAD, BODY or ARTICLE command."""
408
        resp, list = self.longcmd(line, file)
Tim Peters's avatar
Tim Peters committed
409 410 411 412 413 414 415 416 417 418 419 420 421 422
        resp, nr, id = self.statparse(resp)
        return resp, nr, id, list

    def head(self, id):
        """Process a HEAD command.  Argument:
        - id: article number or message id
        Returns:
        - resp: server response if successful
        - nr: article number
        - id: message id
        - list: the lines of the article's header"""

        return self.artcmd('HEAD ' + id)

423
    def body(self, id, file=None):
Tim Peters's avatar
Tim Peters committed
424 425
        """Process a BODY command.  Argument:
        - id: article number or message id
426
        - file: Filename string or file object to store the article in
Tim Peters's avatar
Tim Peters committed
427 428 429 430
        Returns:
        - resp: server response if successful
        - nr: article number
        - id: message id
431
        - list: the lines of the article's body or an empty list
432
                if file was used"""
Tim Peters's avatar
Tim Peters committed
433

434
        return self.artcmd('BODY ' + id, file)
Tim Peters's avatar
Tim Peters committed
435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452

    def article(self, id):
        """Process an ARTICLE command.  Argument:
        - id: article number or message id
        Returns:
        - resp: server response if successful
        - nr: article number
        - id: message id
        - list: the lines of the article"""

        return self.artcmd('ARTICLE ' + id)

    def slave(self):
        """Process a SLAVE command.  Returns:
        - resp: server response if successful"""

        return self.shortcmd('SLAVE')

453
    def xhdr(self, hdr, str, file=None):
Tim Peters's avatar
Tim Peters committed
454 455 456 457 458 459 460 461
        """Process an XHDR command (optional server extension).  Arguments:
        - hdr: the header type (e.g. 'subject')
        - str: an article nr, a message id, or a range nr1-nr2
        Returns:
        - resp: server response if successful
        - list: list of (nr, value) strings"""

        pat = re.compile('^([0-9]+) ?(.*)\n?')
462
        resp, lines = self.longcmd('XHDR ' + hdr + ' ' + str, file)
Tim Peters's avatar
Tim Peters committed
463 464 465 466 467 468 469
        for i in range(len(lines)):
            line = lines[i]
            m = pat.match(line)
            if m:
                lines[i] = m.group(1, 2)
        return resp, lines

470
    def xover(self, start, end, file=None):
Tim Peters's avatar
Tim Peters committed
471 472 473 474 475 476 477 478
        """Process an XOVER command (optional server extension) Arguments:
        - start: start of range
        - end: end of range
        Returns:
        - resp: server response if successful
        - list: list of (art-nr, subject, poster, date,
                         id, references, size, lines)"""

479
        resp, lines = self.longcmd('XOVER ' + start + '-' + end, file)
Tim Peters's avatar
Tim Peters committed
480 481
        xover_lines = []
        for line in lines:
482
            elem = line.split("\t")
Tim Peters's avatar
Tim Peters committed
483 484 485 486 487 488
            try:
                xover_lines.append((elem[0],
                                    elem[1],
                                    elem[2],
                                    elem[3],
                                    elem[4],
489
                                    elem[5].split(),
Tim Peters's avatar
Tim Peters committed
490 491 492 493 494 495
                                    elem[6],
                                    elem[7]))
            except IndexError:
                raise NNTPDataError(line)
        return resp,xover_lines

496
    def xgtitle(self, group, file=None):
Tim Peters's avatar
Tim Peters committed
497 498 499 500 501 502 503
        """Process an XGTITLE command (optional server extension) Arguments:
        - group: group name wildcard (i.e. news.*)
        Returns:
        - resp: server response if successful
        - list: list of (name,title) strings"""

        line_pat = re.compile("^([^ \t]+)[ \t]+(.*)$")
504
        resp, raw_lines = self.longcmd('XGTITLE ' + group, file)
Tim Peters's avatar
Tim Peters committed
505 506
        lines = []
        for raw_line in raw_lines:
507
            match = line_pat.search(raw_line.strip())
Tim Peters's avatar
Tim Peters committed
508 509 510 511 512 513 514 515 516 517 518 519 520 521 522
            if match:
                lines.append(match.group(1, 2))
        return resp, lines

    def xpath(self,id):
        """Process an XPATH command (optional server extension) Arguments:
        - id: Message id of article
        Returns:
        resp: server response if successful
        path: directory path to article"""

        resp = self.shortcmd("XPATH " + id)
        if resp[:3] != '223':
            raise NNTPReplyError(resp)
        try:
523
            [resp_num, path] = resp.split()
Tim Peters's avatar
Tim Peters committed
524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539
        except ValueError:
            raise NNTPReplyError(resp)
        else:
            return resp, path

    def date (self):
        """Process the DATE command. Arguments:
        None
        Returns:
        resp: server response if successful
        date: Date suitable for newnews/newgroups commands etc.
        time: Time suitable for newnews/newgroups commands etc."""

        resp = self.shortcmd("DATE")
        if resp[:3] != '111':
            raise NNTPReplyError(resp)
540
        elem = resp.split()
Tim Peters's avatar
Tim Peters committed
541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604
        if len(elem) != 2:
            raise NNTPDataError(resp)
        date = elem[1][2:8]
        time = elem[1][-6:]
        if len(date) != 6 or len(time) != 6:
            raise NNTPDataError(resp)
        return resp, date, time


    def post(self, f):
        """Process a POST command.  Arguments:
        - f: file containing the article
        Returns:
        - resp: server response if successful"""

        resp = self.shortcmd('POST')
        # Raises error_??? if posting is not allowed
        if resp[0] != '3':
            raise NNTPReplyError(resp)
        while 1:
            line = f.readline()
            if not line:
                break
            if line[-1] == '\n':
                line = line[:-1]
            if line[:1] == '.':
                line = '.' + line
            self.putline(line)
        self.putline('.')
        return self.getresp()

    def ihave(self, id, f):
        """Process an IHAVE command.  Arguments:
        - id: message-id of the article
        - f:  file containing the article
        Returns:
        - resp: server response if successful
        Note that if the server refuses the article an exception is raised."""

        resp = self.shortcmd('IHAVE ' + id)
        # Raises error_??? if the server already has it
        if resp[0] != '3':
            raise NNTPReplyError(resp)
        while 1:
            line = f.readline()
            if not line:
                break
            if line[-1] == '\n':
                line = line[:-1]
            if line[:1] == '.':
                line = '.' + line
            self.putline(line)
        self.putline('.')
        return self.getresp()

    def quit(self):
        """Process a QUIT command and close the socket.  Returns:
        - resp: server response if successful"""

        resp = self.shortcmd('QUIT')
        self.file.close()
        self.sock.close()
        del self.file, self.sock
        return resp
605 606


Neal Norwitz's avatar
Neal Norwitz committed
607
# Test retrieval when run as a script.
608 609 610 611 612 613 614 615 616 617 618 619
# Assumption: if there's a local news server, it's called 'news'.
# Assumption: if user queries a remote news server, it's named
# in the environment variable NNTPSERVER (used by slrn and kin)
# and we want readermode off.
if __name__ == '__main__':
    import os
    newshost = 'news' and os.environ["NNTPSERVER"]
    if newshost.find('.') == -1:
        mode = 'readermode'
    else:
        mode = None
    s = NNTP(newshost, readermode=mode)
Tim Peters's avatar
Tim Peters committed
620 621 622 623 624 625 626 627 628
    resp, count, first, last, name = s.group('comp.lang.python')
    print resp
    print 'Group', name, 'has', count, 'articles, range', first, 'to', last
    resp, subs = s.xhdr('subject', first + '-' + last)
    print resp
    for item in subs:
        print "%7s %s" % item
    resp = s.quit()
    print resp