1"""An NNTP client class based on: 2- RFC 977: Network News Transfer Protocol 3- RFC 2980: Common NNTP Extensions 4- RFC 3977: Network News Transfer Protocol (version 2) 5 6Example: 7 8>>> from nntplib import NNTP 9>>> s = NNTP('news') 10>>> resp, count, first, last, name = s.group('comp.lang.python') 11>>> print('Group', name, 'has', count, 'articles, range', first, 'to', last) 12Group comp.lang.python has 51 articles, range 5770 to 5821 13>>> resp, subs = s.xhdr('subject', '{0}-{1}'.format(first, last)) 14>>> resp = s.quit() 15>>> 16 17Here 'resp' is the server response line. 18Error responses are turned into exceptions. 19 20To post an article from a file: 21>>> f = open(filename, 'rb') # file containing article, including header 22>>> resp = s.post(f) 23>>> 24 25For descriptions of all methods, read the comments in the code below. 26Note that all arguments and return values representing article numbers 27are strings, not numbers, since they are rarely used for calculations. 28""" 29 30# RFC 977 by Brian Kantor and Phil Lapsley. 31# xover, xgtitle, xpath, date methods by Kevan Heydon 32 33# Incompatible changes from the 2.x nntplib: 34# - all commands are encoded as UTF-8 data (using the "surrogateescape" 35# error handler), except for raw message data (POST, IHAVE) 36# - all responses are decoded as UTF-8 data (using the "surrogateescape" 37# error handler), except for raw message data (ARTICLE, HEAD, BODY) 38# - the `file` argument to various methods is keyword-only 39# 40# - NNTP.date() returns a datetime object 41# - NNTP.newgroups() and NNTP.newnews() take a datetime (or date) object, 42# rather than a pair of (date, time) strings. 43# - NNTP.newgroups() and NNTP.list() return a list of GroupInfo named tuples 44# - NNTP.descriptions() returns a dict mapping group names to descriptions 45# - NNTP.xover() returns a list of dicts mapping field names (header or metadata) 46# to field values; each dict representing a message overview. 47# - NNTP.article(), NNTP.head() and NNTP.body() return a (response, ArticleInfo) 48# tuple. 49# - the "internal" methods have been marked private (they now start with 50# an underscore) 51 52# Other changes from the 2.x/3.1 nntplib: 53# - automatic querying of capabilities at connect 54# - New method NNTP.getcapabilities() 55# - New method NNTP.over() 56# - New helper function decode_header() 57# - NNTP.post() and NNTP.ihave() accept file objects, bytes-like objects and 58# arbitrary iterables yielding lines. 59# - An extensive test suite :-) 60 61# TODO: 62# - return structured data (GroupInfo etc.) everywhere 63# - support HDR 64 65# Imports 66import re 67import socket 68import collections 69import datetime 70import warnings 71import sys 72 73try: 74 import ssl 75except ImportError: 76 _have_ssl = False 77else: 78 _have_ssl = True 79 80from email.header import decode_header as _email_decode_header 81from socket import _GLOBAL_DEFAULT_TIMEOUT 82 83__all__ = ["NNTP", 84 "NNTPError", "NNTPReplyError", "NNTPTemporaryError", 85 "NNTPPermanentError", "NNTPProtocolError", "NNTPDataError", 86 "decode_header", 87 ] 88 89# maximal line length when calling readline(). This is to prevent 90# reading arbitrary length lines. RFC 3977 limits NNTP line length to 91# 512 characters, including CRLF. We have selected 2048 just to be on 92# the safe side. 93_MAXLINE = 2048 94 95 96# Exceptions raised when an error or invalid response is received 97class NNTPError(Exception): 98 """Base class for all nntplib exceptions""" 99 def __init__(self, *args): 100 Exception.__init__(self, *args) 101 try: 102 self.response = args[0] 103 except IndexError: 104 self.response = 'No response given' 105 106class NNTPReplyError(NNTPError): 107 """Unexpected [123]xx reply""" 108 pass 109 110class NNTPTemporaryError(NNTPError): 111 """4xx errors""" 112 pass 113 114class NNTPPermanentError(NNTPError): 115 """5xx errors""" 116 pass 117 118class NNTPProtocolError(NNTPError): 119 """Response does not begin with [1-5]""" 120 pass 121 122class NNTPDataError(NNTPError): 123 """Error in response data""" 124 pass 125 126 127# Standard port used by NNTP servers 128NNTP_PORT = 119 129NNTP_SSL_PORT = 563 130 131# Response numbers that are followed by additional text (e.g. article) 132_LONGRESP = { 133 '100', # HELP 134 '101', # CAPABILITIES 135 '211', # LISTGROUP (also not multi-line with GROUP) 136 '215', # LIST 137 '220', # ARTICLE 138 '221', # HEAD, XHDR 139 '222', # BODY 140 '224', # OVER, XOVER 141 '225', # HDR 142 '230', # NEWNEWS 143 '231', # NEWGROUPS 144 '282', # XGTITLE 145} 146 147# Default decoded value for LIST OVERVIEW.FMT if not supported 148_DEFAULT_OVERVIEW_FMT = [ 149 "subject", "from", "date", "message-id", "references", ":bytes", ":lines"] 150 151# Alternative names allowed in LIST OVERVIEW.FMT response 152_OVERVIEW_FMT_ALTERNATIVES = { 153 'bytes': ':bytes', 154 'lines': ':lines', 155} 156 157# Line terminators (we always output CRLF, but accept any of CRLF, CR, LF) 158_CRLF = b'\r\n' 159 160GroupInfo = collections.namedtuple('GroupInfo', 161 ['group', 'last', 'first', 'flag']) 162 163ArticleInfo = collections.namedtuple('ArticleInfo', 164 ['number', 'message_id', 'lines']) 165 166 167# Helper function(s) 168def decode_header(header_str): 169 """Takes a unicode string representing a munged header value 170 and decodes it as a (possibly non-ASCII) readable value.""" 171 parts = [] 172 for v, enc in _email_decode_header(header_str): 173 if isinstance(v, bytes): 174 parts.append(v.decode(enc or 'ascii')) 175 else: 176 parts.append(v) 177 return ''.join(parts) 178 179def _parse_overview_fmt(lines): 180 """Parse a list of string representing the response to LIST OVERVIEW.FMT 181 and return a list of header/metadata names. 182 Raises NNTPDataError if the response is not compliant 183 (cf. RFC 3977, section 8.4).""" 184 fmt = [] 185 for line in lines: 186 if line[0] == ':': 187 # Metadata name (e.g. ":bytes") 188 name, _, suffix = line[1:].partition(':') 189 name = ':' + name 190 else: 191 # Header name (e.g. "Subject:" or "Xref:full") 192 name, _, suffix = line.partition(':') 193 name = name.lower() 194 name = _OVERVIEW_FMT_ALTERNATIVES.get(name, name) 195 # Should we do something with the suffix? 196 fmt.append(name) 197 defaults = _DEFAULT_OVERVIEW_FMT 198 if len(fmt) < len(defaults): 199 raise NNTPDataError("LIST OVERVIEW.FMT response too short") 200 if fmt[:len(defaults)] != defaults: 201 raise NNTPDataError("LIST OVERVIEW.FMT redefines default fields") 202 return fmt 203 204def _parse_overview(lines, fmt, data_process_func=None): 205 """Parse the response to an OVER or XOVER command according to the 206 overview format `fmt`.""" 207 n_defaults = len(_DEFAULT_OVERVIEW_FMT) 208 overview = [] 209 for line in lines: 210 fields = {} 211 article_number, *tokens = line.split('\t') 212 article_number = int(article_number) 213 for i, token in enumerate(tokens): 214 if i >= len(fmt): 215 # XXX should we raise an error? Some servers might not 216 # support LIST OVERVIEW.FMT and still return additional 217 # headers. 218 continue 219 field_name = fmt[i] 220 is_metadata = field_name.startswith(':') 221 if i >= n_defaults and not is_metadata: 222 # Non-default header names are included in full in the response 223 # (unless the field is totally empty) 224 h = field_name + ": " 225 if token and token[:len(h)].lower() != h: 226 raise NNTPDataError("OVER/XOVER response doesn't include " 227 "names of additional headers") 228 token = token[len(h):] if token else None 229 fields[fmt[i]] = token 230 overview.append((article_number, fields)) 231 return overview 232 233def _parse_datetime(date_str, time_str=None): 234 """Parse a pair of (date, time) strings, and return a datetime object. 235 If only the date is given, it is assumed to be date and time 236 concatenated together (e.g. response to the DATE command). 237 """ 238 if time_str is None: 239 time_str = date_str[-6:] 240 date_str = date_str[:-6] 241 hours = int(time_str[:2]) 242 minutes = int(time_str[2:4]) 243 seconds = int(time_str[4:]) 244 year = int(date_str[:-4]) 245 month = int(date_str[-4:-2]) 246 day = int(date_str[-2:]) 247 # RFC 3977 doesn't say how to interpret 2-char years. Assume that 248 # there are no dates before 1970 on Usenet. 249 if year < 70: 250 year += 2000 251 elif year < 100: 252 year += 1900 253 return datetime.datetime(year, month, day, hours, minutes, seconds) 254 255def _unparse_datetime(dt, legacy=False): 256 """Format a date or datetime object as a pair of (date, time) strings 257 in the format required by the NEWNEWS and NEWGROUPS commands. If a 258 date object is passed, the time is assumed to be midnight (00h00). 259 260 The returned representation depends on the legacy flag: 261 * if legacy is False (the default): 262 date has the YYYYMMDD format and time the HHMMSS format 263 * if legacy is True: 264 date has the YYMMDD format and time the HHMMSS format. 265 RFC 3977 compliant servers should understand both formats; therefore, 266 legacy is only needed when talking to old servers. 267 """ 268 if not isinstance(dt, datetime.datetime): 269 time_str = "000000" 270 else: 271 time_str = "{0.hour:02d}{0.minute:02d}{0.second:02d}".format(dt) 272 y = dt.year 273 if legacy: 274 y = y % 100 275 date_str = "{0:02d}{1.month:02d}{1.day:02d}".format(y, dt) 276 else: 277 date_str = "{0:04d}{1.month:02d}{1.day:02d}".format(y, dt) 278 return date_str, time_str 279 280 281if _have_ssl: 282 283 def _encrypt_on(sock, context, hostname): 284 """Wrap a socket in SSL/TLS. Arguments: 285 - sock: Socket to wrap 286 - context: SSL context to use for the encrypted connection 287 Returns: 288 - sock: New, encrypted socket. 289 """ 290 # Generate a default SSL context if none was passed. 291 if context is None: 292 context = ssl._create_stdlib_context() 293 return context.wrap_socket(sock, server_hostname=hostname) 294 295 296# The classes themselves 297class _NNTPBase: 298 # UTF-8 is the character set for all NNTP commands and responses: they 299 # are automatically encoded (when sending) and decoded (and receiving) 300 # by this class. 301 # However, some multi-line data blocks can contain arbitrary bytes (for 302 # example, latin-1 or utf-16 data in the body of a message). Commands 303 # taking (POST, IHAVE) or returning (HEAD, BODY, ARTICLE) raw message 304 # data will therefore only accept and produce bytes objects. 305 # Furthermore, since there could be non-compliant servers out there, 306 # we use 'surrogateescape' as the error handler for fault tolerance 307 # and easy round-tripping. This could be useful for some applications 308 # (e.g. NNTP gateways). 309 310 encoding = 'utf-8' 311 errors = 'surrogateescape' 312 313 def __init__(self, file, host, 314 readermode=None, timeout=_GLOBAL_DEFAULT_TIMEOUT): 315 """Initialize an instance. Arguments: 316 - file: file-like object (open for read/write in binary mode) 317 - host: hostname of the server 318 - readermode: if true, send 'mode reader' command after 319 connecting. 320 - timeout: timeout (in seconds) used for socket connections 321 322 readermode is sometimes necessary if you are connecting to an 323 NNTP server on the local machine and intend to call 324 reader-specific commands, such as `group'. If you get 325 unexpected NNTPPermanentErrors, you might need to set 326 readermode. 327 """ 328 self.host = host 329 self.file = file 330 self.debugging = 0 331 self.welcome = self._getresp() 332 333 # Inquire about capabilities (RFC 3977). 334 self._caps = None 335 self.getcapabilities() 336 337 # 'MODE READER' is sometimes necessary to enable 'reader' mode. 338 # However, the order in which 'MODE READER' and 'AUTHINFO' need to 339 # arrive differs between some NNTP servers. If _setreadermode() fails 340 # with an authorization failed error, it will set this to True; 341 # the login() routine will interpret that as a request to try again 342 # after performing its normal function. 343 # Enable only if we're not already in READER mode anyway. 344 self.readermode_afterauth = False 345 if readermode and 'READER' not in self._caps: 346 self._setreadermode() 347 if not self.readermode_afterauth: 348 # Capabilities might have changed after MODE READER 349 self._caps = None 350 self.getcapabilities() 351 352 # RFC 4642 2.2.2: Both the client and the server MUST know if there is 353 # a TLS session active. A client MUST NOT attempt to start a TLS 354 # session if a TLS session is already active. 355 self.tls_on = False 356 357 # Log in and encryption setup order is left to subclasses. 358 self.authenticated = False 359 360 def __enter__(self): 361 return self 362 363 def __exit__(self, *args): 364 is_connected = lambda: hasattr(self, "file") 365 if is_connected(): 366 try: 367 self.quit() 368 except (OSError, EOFError): 369 pass 370 finally: 371 if is_connected(): 372 self._close() 373 374 def getwelcome(self): 375 """Get the welcome message from the server 376 (this is read and squirreled away by __init__()). 377 If the response code is 200, posting is allowed; 378 if it 201, posting is not allowed.""" 379 380 if self.debugging: print('*welcome*', repr(self.welcome)) 381 return self.welcome 382 383 def getcapabilities(self): 384 """Get the server capabilities, as read by __init__(). 385 If the CAPABILITIES command is not supported, an empty dict is 386 returned.""" 387 if self._caps is None: 388 self.nntp_version = 1 389 self.nntp_implementation = None 390 try: 391 resp, caps = self.capabilities() 392 except (NNTPPermanentError, NNTPTemporaryError): 393 # Server doesn't support capabilities 394 self._caps = {} 395 else: 396 self._caps = caps 397 if 'VERSION' in caps: 398 # The server can advertise several supported versions, 399 # choose the highest. 400 self.nntp_version = max(map(int, caps['VERSION'])) 401 if 'IMPLEMENTATION' in caps: 402 self.nntp_implementation = ' '.join(caps['IMPLEMENTATION']) 403 return self._caps 404 405 def set_debuglevel(self, level): 406 """Set the debugging level. Argument 'level' means: 407 0: no debugging output (default) 408 1: print commands and responses but not body text etc. 409 2: also print raw lines read and sent before stripping CR/LF""" 410 411 self.debugging = level 412 debug = set_debuglevel 413 414 def _putline(self, line): 415 """Internal: send one line to the server, appending CRLF. 416 The `line` must be a bytes-like object.""" 417 sys.audit("nntplib.putline", self, line) 418 line = line + _CRLF 419 if self.debugging > 1: print('*put*', repr(line)) 420 self.file.write(line) 421 self.file.flush() 422 423 def _putcmd(self, line): 424 """Internal: send one command to the server (through _putline()). 425 The `line` must be a unicode string.""" 426 if self.debugging: print('*cmd*', repr(line)) 427 line = line.encode(self.encoding, self.errors) 428 self._putline(line) 429 430 def _getline(self, strip_crlf=True): 431 """Internal: return one line from the server, stripping _CRLF. 432 Raise EOFError if the connection is closed. 433 Returns a bytes object.""" 434 line = self.file.readline(_MAXLINE +1) 435 if len(line) > _MAXLINE: 436 raise NNTPDataError('line too long') 437 if self.debugging > 1: 438 print('*get*', repr(line)) 439 if not line: raise EOFError 440 if strip_crlf: 441 if line[-2:] == _CRLF: 442 line = line[:-2] 443 elif line[-1:] in _CRLF: 444 line = line[:-1] 445 return line 446 447 def _getresp(self): 448 """Internal: get a response from the server. 449 Raise various errors if the response indicates an error. 450 Returns a unicode string.""" 451 resp = self._getline() 452 if self.debugging: print('*resp*', repr(resp)) 453 resp = resp.decode(self.encoding, self.errors) 454 c = resp[:1] 455 if c == '4': 456 raise NNTPTemporaryError(resp) 457 if c == '5': 458 raise NNTPPermanentError(resp) 459 if c not in '123': 460 raise NNTPProtocolError(resp) 461 return resp 462 463 def _getlongresp(self, file=None): 464 """Internal: get a response plus following text from the server. 465 Raise various errors if the response indicates an error. 466 467 Returns a (response, lines) tuple where `response` is a unicode 468 string and `lines` is a list of bytes objects. 469 If `file` is a file-like object, it must be open in binary mode. 470 """ 471 472 openedFile = None 473 try: 474 # If a string was passed then open a file with that name 475 if isinstance(file, (str, bytes)): 476 openedFile = file = open(file, "wb") 477 478 resp = self._getresp() 479 if resp[:3] not in _LONGRESP: 480 raise NNTPReplyError(resp) 481 482 lines = [] 483 if file is not None: 484 # XXX lines = None instead? 485 terminators = (b'.' + _CRLF, b'.\n') 486 while 1: 487 line = self._getline(False) 488 if line in terminators: 489 break 490 if line.startswith(b'..'): 491 line = line[1:] 492 file.write(line) 493 else: 494 terminator = b'.' 495 while 1: 496 line = self._getline() 497 if line == terminator: 498 break 499 if line.startswith(b'..'): 500 line = line[1:] 501 lines.append(line) 502 finally: 503 # If this method created the file, then it must close it 504 if openedFile: 505 openedFile.close() 506 507 return resp, lines 508 509 def _shortcmd(self, line): 510 """Internal: send a command and get the response. 511 Same return value as _getresp().""" 512 self._putcmd(line) 513 return self._getresp() 514 515 def _longcmd(self, line, file=None): 516 """Internal: send a command and get the response plus following text. 517 Same return value as _getlongresp().""" 518 self._putcmd(line) 519 return self._getlongresp(file) 520 521 def _longcmdstring(self, line, file=None): 522 """Internal: send a command and get the response plus following text. 523 Same as _longcmd() and _getlongresp(), except that the returned `lines` 524 are unicode strings rather than bytes objects. 525 """ 526 self._putcmd(line) 527 resp, list = self._getlongresp(file) 528 return resp, [line.decode(self.encoding, self.errors) 529 for line in list] 530 531 def _getoverviewfmt(self): 532 """Internal: get the overview format. Queries the server if not 533 already done, else returns the cached value.""" 534 try: 535 return self._cachedoverviewfmt 536 except AttributeError: 537 pass 538 try: 539 resp, lines = self._longcmdstring("LIST OVERVIEW.FMT") 540 except NNTPPermanentError: 541 # Not supported by server? 542 fmt = _DEFAULT_OVERVIEW_FMT[:] 543 else: 544 fmt = _parse_overview_fmt(lines) 545 self._cachedoverviewfmt = fmt 546 return fmt 547 548 def _grouplist(self, lines): 549 # Parse lines into "group last first flag" 550 return [GroupInfo(*line.split()) for line in lines] 551 552 def capabilities(self): 553 """Process a CAPABILITIES command. Not supported by all servers. 554 Return: 555 - resp: server response if successful 556 - caps: a dictionary mapping capability names to lists of tokens 557 (for example {'VERSION': ['2'], 'OVER': [], LIST: ['ACTIVE', 'HEADERS'] }) 558 """ 559 caps = {} 560 resp, lines = self._longcmdstring("CAPABILITIES") 561 for line in lines: 562 name, *tokens = line.split() 563 caps[name] = tokens 564 return resp, caps 565 566 def newgroups(self, date, *, file=None): 567 """Process a NEWGROUPS command. Arguments: 568 - date: a date or datetime object 569 Return: 570 - resp: server response if successful 571 - list: list of newsgroup names 572 """ 573 if not isinstance(date, (datetime.date, datetime.date)): 574 raise TypeError( 575 "the date parameter must be a date or datetime object, " 576 "not '{:40}'".format(date.__class__.__name__)) 577 date_str, time_str = _unparse_datetime(date, self.nntp_version < 2) 578 cmd = 'NEWGROUPS {0} {1}'.format(date_str, time_str) 579 resp, lines = self._longcmdstring(cmd, file) 580 return resp, self._grouplist(lines) 581 582 def newnews(self, group, date, *, file=None): 583 """Process a NEWNEWS command. Arguments: 584 - group: group name or '*' 585 - date: a date or datetime object 586 Return: 587 - resp: server response if successful 588 - list: list of message ids 589 """ 590 if not isinstance(date, (datetime.date, datetime.date)): 591 raise TypeError( 592 "the date parameter must be a date or datetime object, " 593 "not '{:40}'".format(date.__class__.__name__)) 594 date_str, time_str = _unparse_datetime(date, self.nntp_version < 2) 595 cmd = 'NEWNEWS {0} {1} {2}'.format(group, date_str, time_str) 596 return self._longcmdstring(cmd, file) 597 598 def list(self, group_pattern=None, *, file=None): 599 """Process a LIST or LIST ACTIVE command. Arguments: 600 - group_pattern: a pattern indicating which groups to query 601 - file: Filename string or file object to store the result in 602 Returns: 603 - resp: server response if successful 604 - list: list of (group, last, first, flag) (strings) 605 """ 606 if group_pattern is not None: 607 command = 'LIST ACTIVE ' + group_pattern 608 else: 609 command = 'LIST' 610 resp, lines = self._longcmdstring(command, file) 611 return resp, self._grouplist(lines) 612 613 def _getdescriptions(self, group_pattern, return_all): 614 line_pat = re.compile('^(?P<group>[^ \t]+)[ \t]+(.*)$') 615 # Try the more std (acc. to RFC2980) LIST NEWSGROUPS first 616 resp, lines = self._longcmdstring('LIST NEWSGROUPS ' + group_pattern) 617 if not resp.startswith('215'): 618 # Now the deprecated XGTITLE. This either raises an error 619 # or succeeds with the same output structure as LIST 620 # NEWSGROUPS. 621 resp, lines = self._longcmdstring('XGTITLE ' + group_pattern) 622 groups = {} 623 for raw_line in lines: 624 match = line_pat.search(raw_line.strip()) 625 if match: 626 name, desc = match.group(1, 2) 627 if not return_all: 628 return desc 629 groups[name] = desc 630 if return_all: 631 return resp, groups 632 else: 633 # Nothing found 634 return '' 635 636 def description(self, group): 637 """Get a description for a single group. If more than one 638 group matches ('group' is a pattern), return the first. If no 639 group matches, return an empty string. 640 641 This elides the response code from the server, since it can 642 only be '215' or '285' (for xgtitle) anyway. If the response 643 code is needed, use the 'descriptions' method. 644 645 NOTE: This neither checks for a wildcard in 'group' nor does 646 it check whether the group actually exists.""" 647 return self._getdescriptions(group, False) 648 649 def descriptions(self, group_pattern): 650 """Get descriptions for a range of groups.""" 651 return self._getdescriptions(group_pattern, True) 652 653 def group(self, name): 654 """Process a GROUP command. Argument: 655 - group: the group name 656 Returns: 657 - resp: server response if successful 658 - count: number of articles 659 - first: first article number 660 - last: last article number 661 - name: the group name 662 """ 663 resp = self._shortcmd('GROUP ' + name) 664 if not resp.startswith('211'): 665 raise NNTPReplyError(resp) 666 words = resp.split() 667 count = first = last = 0 668 n = len(words) 669 if n > 1: 670 count = words[1] 671 if n > 2: 672 first = words[2] 673 if n > 3: 674 last = words[3] 675 if n > 4: 676 name = words[4].lower() 677 return resp, int(count), int(first), int(last), name 678 679 def help(self, *, file=None): 680 """Process a HELP command. Argument: 681 - file: Filename string or file object to store the result in 682 Returns: 683 - resp: server response if successful 684 - list: list of strings returned by the server in response to the 685 HELP command 686 """ 687 return self._longcmdstring('HELP', file) 688 689 def _statparse(self, resp): 690 """Internal: parse the response line of a STAT, NEXT, LAST, 691 ARTICLE, HEAD or BODY command.""" 692 if not resp.startswith('22'): 693 raise NNTPReplyError(resp) 694 words = resp.split() 695 art_num = int(words[1]) 696 message_id = words[2] 697 return resp, art_num, message_id 698 699 def _statcmd(self, line): 700 """Internal: process a STAT, NEXT or LAST command.""" 701 resp = self._shortcmd(line) 702 return self._statparse(resp) 703 704 def stat(self, message_spec=None): 705 """Process a STAT command. Argument: 706 - message_spec: article number or message id (if not specified, 707 the current article is selected) 708 Returns: 709 - resp: server response if successful 710 - art_num: the article number 711 - message_id: the message id 712 """ 713 if message_spec: 714 return self._statcmd('STAT {0}'.format(message_spec)) 715 else: 716 return self._statcmd('STAT') 717 718 def next(self): 719 """Process a NEXT command. No arguments. Return as for STAT.""" 720 return self._statcmd('NEXT') 721 722 def last(self): 723 """Process a LAST command. No arguments. Return as for STAT.""" 724 return self._statcmd('LAST') 725 726 def _artcmd(self, line, file=None): 727 """Internal: process a HEAD, BODY or ARTICLE command.""" 728 resp, lines = self._longcmd(line, file) 729 resp, art_num, message_id = self._statparse(resp) 730 return resp, ArticleInfo(art_num, message_id, lines) 731 732 def head(self, message_spec=None, *, file=None): 733 """Process a HEAD command. Argument: 734 - message_spec: article number or message id 735 - file: filename string or file object to store the headers in 736 Returns: 737 - resp: server response if successful 738 - ArticleInfo: (article number, message id, list of header lines) 739 """ 740 if message_spec is not None: 741 cmd = 'HEAD {0}'.format(message_spec) 742 else: 743 cmd = 'HEAD' 744 return self._artcmd(cmd, file) 745 746 def body(self, message_spec=None, *, file=None): 747 """Process a BODY command. Argument: 748 - message_spec: article number or message id 749 - file: filename string or file object to store the body in 750 Returns: 751 - resp: server response if successful 752 - ArticleInfo: (article number, message id, list of body lines) 753 """ 754 if message_spec is not None: 755 cmd = 'BODY {0}'.format(message_spec) 756 else: 757 cmd = 'BODY' 758 return self._artcmd(cmd, file) 759 760 def article(self, message_spec=None, *, file=None): 761 """Process an ARTICLE command. Argument: 762 - message_spec: article number or message id 763 - file: filename string or file object to store the article in 764 Returns: 765 - resp: server response if successful 766 - ArticleInfo: (article number, message id, list of article lines) 767 """ 768 if message_spec is not None: 769 cmd = 'ARTICLE {0}'.format(message_spec) 770 else: 771 cmd = 'ARTICLE' 772 return self._artcmd(cmd, file) 773 774 def slave(self): 775 """Process a SLAVE command. Returns: 776 - resp: server response if successful 777 """ 778 return self._shortcmd('SLAVE') 779 780 def xhdr(self, hdr, str, *, file=None): 781 """Process an XHDR command (optional server extension). Arguments: 782 - hdr: the header type (e.g. 'subject') 783 - str: an article nr, a message id, or a range nr1-nr2 784 - file: Filename string or file object to store the result in 785 Returns: 786 - resp: server response if successful 787 - list: list of (nr, value) strings 788 """ 789 pat = re.compile('^([0-9]+) ?(.*)\n?') 790 resp, lines = self._longcmdstring('XHDR {0} {1}'.format(hdr, str), file) 791 def remove_number(line): 792 m = pat.match(line) 793 return m.group(1, 2) if m else line 794 return resp, [remove_number(line) for line in lines] 795 796 def xover(self, start, end, *, file=None): 797 """Process an XOVER command (optional server extension) Arguments: 798 - start: start of range 799 - end: end of range 800 - file: Filename string or file object to store the result in 801 Returns: 802 - resp: server response if successful 803 - list: list of dicts containing the response fields 804 """ 805 resp, lines = self._longcmdstring('XOVER {0}-{1}'.format(start, end), 806 file) 807 fmt = self._getoverviewfmt() 808 return resp, _parse_overview(lines, fmt) 809 810 def over(self, message_spec, *, file=None): 811 """Process an OVER command. If the command isn't supported, fall 812 back to XOVER. Arguments: 813 - message_spec: 814 - either a message id, indicating the article to fetch 815 information about 816 - or a (start, end) tuple, indicating a range of article numbers; 817 if end is None, information up to the newest message will be 818 retrieved 819 - or None, indicating the current article number must be used 820 - file: Filename string or file object to store the result in 821 Returns: 822 - resp: server response if successful 823 - list: list of dicts containing the response fields 824 825 NOTE: the "message id" form isn't supported by XOVER 826 """ 827 cmd = 'OVER' if 'OVER' in self._caps else 'XOVER' 828 if isinstance(message_spec, (tuple, list)): 829 start, end = message_spec 830 cmd += ' {0}-{1}'.format(start, end or '') 831 elif message_spec is not None: 832 cmd = cmd + ' ' + message_spec 833 resp, lines = self._longcmdstring(cmd, file) 834 fmt = self._getoverviewfmt() 835 return resp, _parse_overview(lines, fmt) 836 837 def xgtitle(self, group, *, file=None): 838 """Process an XGTITLE command (optional server extension) Arguments: 839 - group: group name wildcard (i.e. news.*) 840 Returns: 841 - resp: server response if successful 842 - list: list of (name,title) strings""" 843 warnings.warn("The XGTITLE extension is not actively used, " 844 "use descriptions() instead", 845 DeprecationWarning, 2) 846 line_pat = re.compile('^([^ \t]+)[ \t]+(.*)$') 847 resp, raw_lines = self._longcmdstring('XGTITLE ' + group, file) 848 lines = [] 849 for raw_line in raw_lines: 850 match = line_pat.search(raw_line.strip()) 851 if match: 852 lines.append(match.group(1, 2)) 853 return resp, lines 854 855 def xpath(self, id): 856 """Process an XPATH command (optional server extension) Arguments: 857 - id: Message id of article 858 Returns: 859 resp: server response if successful 860 path: directory path to article 861 """ 862 warnings.warn("The XPATH extension is not actively used", 863 DeprecationWarning, 2) 864 865 resp = self._shortcmd('XPATH {0}'.format(id)) 866 if not resp.startswith('223'): 867 raise NNTPReplyError(resp) 868 try: 869 [resp_num, path] = resp.split() 870 except ValueError: 871 raise NNTPReplyError(resp) from None 872 else: 873 return resp, path 874 875 def date(self): 876 """Process the DATE command. 877 Returns: 878 - resp: server response if successful 879 - date: datetime object 880 """ 881 resp = self._shortcmd("DATE") 882 if not resp.startswith('111'): 883 raise NNTPReplyError(resp) 884 elem = resp.split() 885 if len(elem) != 2: 886 raise NNTPDataError(resp) 887 date = elem[1] 888 if len(date) != 14: 889 raise NNTPDataError(resp) 890 return resp, _parse_datetime(date, None) 891 892 def _post(self, command, f): 893 resp = self._shortcmd(command) 894 # Raises a specific exception if posting is not allowed 895 if not resp.startswith('3'): 896 raise NNTPReplyError(resp) 897 if isinstance(f, (bytes, bytearray)): 898 f = f.splitlines() 899 # We don't use _putline() because: 900 # - we don't want additional CRLF if the file or iterable is already 901 # in the right format 902 # - we don't want a spurious flush() after each line is written 903 for line in f: 904 if not line.endswith(_CRLF): 905 line = line.rstrip(b"\r\n") + _CRLF 906 if line.startswith(b'.'): 907 line = b'.' + line 908 self.file.write(line) 909 self.file.write(b".\r\n") 910 self.file.flush() 911 return self._getresp() 912 913 def post(self, data): 914 """Process a POST command. Arguments: 915 - data: bytes object, iterable or file containing the article 916 Returns: 917 - resp: server response if successful""" 918 return self._post('POST', data) 919 920 def ihave(self, message_id, data): 921 """Process an IHAVE command. Arguments: 922 - message_id: message-id of the article 923 - data: file containing the article 924 Returns: 925 - resp: server response if successful 926 Note that if the server refuses the article an exception is raised.""" 927 return self._post('IHAVE {0}'.format(message_id), data) 928 929 def _close(self): 930 self.file.close() 931 del self.file 932 933 def quit(self): 934 """Process a QUIT command and close the socket. Returns: 935 - resp: server response if successful""" 936 try: 937 resp = self._shortcmd('QUIT') 938 finally: 939 self._close() 940 return resp 941 942 def login(self, user=None, password=None, usenetrc=True): 943 if self.authenticated: 944 raise ValueError("Already logged in.") 945 if not user and not usenetrc: 946 raise ValueError( 947 "At least one of `user` and `usenetrc` must be specified") 948 # If no login/password was specified but netrc was requested, 949 # try to get them from ~/.netrc 950 # Presume that if .netrc has an entry, NNRP authentication is required. 951 try: 952 if usenetrc and not user: 953 import netrc 954 credentials = netrc.netrc() 955 auth = credentials.authenticators(self.host) 956 if auth: 957 user = auth[0] 958 password = auth[2] 959 except OSError: 960 pass 961 # Perform NNTP authentication if needed. 962 if not user: 963 return 964 resp = self._shortcmd('authinfo user ' + user) 965 if resp.startswith('381'): 966 if not password: 967 raise NNTPReplyError(resp) 968 else: 969 resp = self._shortcmd('authinfo pass ' + password) 970 if not resp.startswith('281'): 971 raise NNTPPermanentError(resp) 972 # Capabilities might have changed after login 973 self._caps = None 974 self.getcapabilities() 975 # Attempt to send mode reader if it was requested after login. 976 # Only do so if we're not in reader mode already. 977 if self.readermode_afterauth and 'READER' not in self._caps: 978 self._setreadermode() 979 # Capabilities might have changed after MODE READER 980 self._caps = None 981 self.getcapabilities() 982 983 def _setreadermode(self): 984 try: 985 self.welcome = self._shortcmd('mode reader') 986 except NNTPPermanentError: 987 # Error 5xx, probably 'not implemented' 988 pass 989 except NNTPTemporaryError as e: 990 if e.response.startswith('480'): 991 # Need authorization before 'mode reader' 992 self.readermode_afterauth = True 993 else: 994 raise 995 996 if _have_ssl: 997 def starttls(self, context=None): 998 """Process a STARTTLS command. Arguments: 999 - context: SSL context to use for the encrypted connection 1000 """ 1001 # Per RFC 4642, STARTTLS MUST NOT be sent after authentication or if 1002 # a TLS session already exists. 1003 if self.tls_on: 1004 raise ValueError("TLS is already enabled.") 1005 if self.authenticated: 1006 raise ValueError("TLS cannot be started after authentication.") 1007 resp = self._shortcmd('STARTTLS') 1008 if resp.startswith('382'): 1009 self.file.close() 1010 self.sock = _encrypt_on(self.sock, context, self.host) 1011 self.file = self.sock.makefile("rwb") 1012 self.tls_on = True 1013 # Capabilities may change after TLS starts up, so ask for them 1014 # again. 1015 self._caps = None 1016 self.getcapabilities() 1017 else: 1018 raise NNTPError("TLS failed to start.") 1019 1020 1021class NNTP(_NNTPBase): 1022 1023 def __init__(self, host, port=NNTP_PORT, user=None, password=None, 1024 readermode=None, usenetrc=False, 1025 timeout=_GLOBAL_DEFAULT_TIMEOUT): 1026 """Initialize an instance. Arguments: 1027 - host: hostname to connect to 1028 - port: port to connect to (default the standard NNTP port) 1029 - user: username to authenticate with 1030 - password: password to use with username 1031 - readermode: if true, send 'mode reader' command after 1032 connecting. 1033 - usenetrc: allow loading username and password from ~/.netrc file 1034 if not specified explicitly 1035 - timeout: timeout (in seconds) used for socket connections 1036 1037 readermode is sometimes necessary if you are connecting to an 1038 NNTP server on the local machine and intend to call 1039 reader-specific commands, such as `group'. If you get 1040 unexpected NNTPPermanentErrors, you might need to set 1041 readermode. 1042 """ 1043 self.host = host 1044 self.port = port 1045 sys.audit("nntplib.connect", self, host, port) 1046 self.sock = socket.create_connection((host, port), timeout) 1047 file = None 1048 try: 1049 file = self.sock.makefile("rwb") 1050 _NNTPBase.__init__(self, file, host, 1051 readermode, timeout) 1052 if user or usenetrc: 1053 self.login(user, password, usenetrc) 1054 except: 1055 if file: 1056 file.close() 1057 self.sock.close() 1058 raise 1059 1060 def _close(self): 1061 try: 1062 _NNTPBase._close(self) 1063 finally: 1064 self.sock.close() 1065 1066 1067if _have_ssl: 1068 class NNTP_SSL(_NNTPBase): 1069 1070 def __init__(self, host, port=NNTP_SSL_PORT, 1071 user=None, password=None, ssl_context=None, 1072 readermode=None, usenetrc=False, 1073 timeout=_GLOBAL_DEFAULT_TIMEOUT): 1074 """This works identically to NNTP.__init__, except for the change 1075 in default port and the `ssl_context` argument for SSL connections. 1076 """ 1077 sys.audit("nntplib.connect", self, host, port) 1078 self.sock = socket.create_connection((host, port), timeout) 1079 file = None 1080 try: 1081 self.sock = _encrypt_on(self.sock, ssl_context, host) 1082 file = self.sock.makefile("rwb") 1083 _NNTPBase.__init__(self, file, host, 1084 readermode=readermode, timeout=timeout) 1085 if user or usenetrc: 1086 self.login(user, password, usenetrc) 1087 except: 1088 if file: 1089 file.close() 1090 self.sock.close() 1091 raise 1092 1093 def _close(self): 1094 try: 1095 _NNTPBase._close(self) 1096 finally: 1097 self.sock.close() 1098 1099 __all__.append("NNTP_SSL") 1100 1101 1102# Test retrieval when run as a script. 1103if __name__ == '__main__': 1104 import argparse 1105 1106 parser = argparse.ArgumentParser(description="""\ 1107 nntplib built-in demo - display the latest articles in a newsgroup""") 1108 parser.add_argument('-g', '--group', default='gmane.comp.python.general', 1109 help='group to fetch messages from (default: %(default)s)') 1110 parser.add_argument('-s', '--server', default='news.gmane.org', 1111 help='NNTP server hostname (default: %(default)s)') 1112 parser.add_argument('-p', '--port', default=-1, type=int, 1113 help='NNTP port number (default: %s / %s)' % (NNTP_PORT, NNTP_SSL_PORT)) 1114 parser.add_argument('-n', '--nb-articles', default=10, type=int, 1115 help='number of articles to fetch (default: %(default)s)') 1116 parser.add_argument('-S', '--ssl', action='store_true', default=False, 1117 help='use NNTP over SSL') 1118 args = parser.parse_args() 1119 1120 port = args.port 1121 if not args.ssl: 1122 if port == -1: 1123 port = NNTP_PORT 1124 s = NNTP(host=args.server, port=port) 1125 else: 1126 if port == -1: 1127 port = NNTP_SSL_PORT 1128 s = NNTP_SSL(host=args.server, port=port) 1129 1130 caps = s.getcapabilities() 1131 if 'STARTTLS' in caps: 1132 s.starttls() 1133 resp, count, first, last, name = s.group(args.group) 1134 print('Group', name, 'has', count, 'articles, range', first, 'to', last) 1135 1136 def cut(s, lim): 1137 if len(s) > lim: 1138 s = s[:lim - 4] + "..." 1139 return s 1140 1141 first = str(int(last) - args.nb_articles + 1) 1142 resp, overviews = s.xover(first, last) 1143 for artnum, over in overviews: 1144 author = decode_header(over['from']).split('<', 1)[0] 1145 subject = decode_header(over['subject']) 1146 lines = int(over[':lines']) 1147 print("{:7} {:20} {:42} ({})".format( 1148 artnum, cut(author, 20), cut(subject, 42), lines) 1149 ) 1150 1151 s.quit() 1152