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