| """An NNTP client class based on: | 
 | - RFC 977: Network News Transfer Protocol | 
 | - RFC 2980: Common NNTP Extensions | 
 | - RFC 3977: Network News Transfer Protocol (version 2) | 
 |  | 
 | 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', '{0}-{1}'.format(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, 'rb') # 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 | 
 |  | 
 | # Incompatible changes from the 2.x nntplib: | 
 | # - all commands are encoded as UTF-8 data (using the "surrogateescape" | 
 | #   error handler), except for raw message data (POST, IHAVE) | 
 | # - all responses are decoded as UTF-8 data (using the "surrogateescape" | 
 | #   error handler), except for raw message data (ARTICLE, HEAD, BODY) | 
 | # - the `file` argument to various methods is keyword-only | 
 | # | 
 | # - NNTP.date() returns a datetime object | 
 | # - NNTP.newgroups() and NNTP.newnews() take a datetime (or date) object, | 
 | #   rather than a pair of (date, time) strings. | 
 | # - NNTP.newgroups() and NNTP.list() return a list of GroupInfo named tuples | 
 | # - NNTP.descriptions() returns a dict mapping group names to descriptions | 
 | # - NNTP.xover() returns a list of dicts mapping field names (header or metadata) | 
 | #   to field values; each dict representing a message overview. | 
 | # - NNTP.article(), NNTP.head() and NNTP.body() return a (response, ArticleInfo) | 
 | #   tuple. | 
 | # - the "internal" methods have been marked private (they now start with | 
 | #   an underscore) | 
 |  | 
 | # Other changes from the 2.x/3.1 nntplib: | 
 | # - automatic querying of capabilities at connect | 
 | # - New method NNTP.getcapabilities() | 
 | # - New method NNTP.over() | 
 | # - New helper function decode_header() | 
 | # - NNTP.post() and NNTP.ihave() accept file objects, bytes-like objects and | 
 | #   arbitrary iterables yielding lines. | 
 | # - An extensive test suite :-) | 
 |  | 
 | # TODO: | 
 | # - return structured data (GroupInfo etc.) everywhere | 
 | # - support HDR | 
 |  | 
 | # Imports | 
 | import re | 
 | import socket | 
 | import collections | 
 | import datetime | 
 | import sys | 
 |  | 
 | try: | 
 |     import ssl | 
 | except ImportError: | 
 |     _have_ssl = False | 
 | else: | 
 |     _have_ssl = True | 
 |  | 
 | from email.header import decode_header as _email_decode_header | 
 | from socket import _GLOBAL_DEFAULT_TIMEOUT | 
 |  | 
 | __all__ = ["NNTP", | 
 |            "NNTPError", "NNTPReplyError", "NNTPTemporaryError", | 
 |            "NNTPPermanentError", "NNTPProtocolError", "NNTPDataError", | 
 |            "decode_header", | 
 |            ] | 
 |  | 
 | # maximal line length when calling readline(). This is to prevent | 
 | # reading arbitrary length lines. RFC 3977 limits NNTP line length to | 
 | # 512 characters, including CRLF. We have selected 2048 just to be on | 
 | # the safe side. | 
 | _MAXLINE = 2048 | 
 |  | 
 |  | 
 | # Exceptions raised when an error or invalid response is received | 
 | class NNTPError(Exception): | 
 |     """Base class for all nntplib exceptions""" | 
 |     def __init__(self, *args): | 
 |         Exception.__init__(self, *args) | 
 |         try: | 
 |             self.response = args[0] | 
 |         except IndexError: | 
 |             self.response = 'No response given' | 
 |  | 
 | class NNTPReplyError(NNTPError): | 
 |     """Unexpected [123]xx reply""" | 
 |     pass | 
 |  | 
 | class NNTPTemporaryError(NNTPError): | 
 |     """4xx errors""" | 
 |     pass | 
 |  | 
 | class NNTPPermanentError(NNTPError): | 
 |     """5xx errors""" | 
 |     pass | 
 |  | 
 | class NNTPProtocolError(NNTPError): | 
 |     """Response does not begin with [1-5]""" | 
 |     pass | 
 |  | 
 | class NNTPDataError(NNTPError): | 
 |     """Error in response data""" | 
 |     pass | 
 |  | 
 |  | 
 | # Standard port used by NNTP servers | 
 | NNTP_PORT = 119 | 
 | NNTP_SSL_PORT = 563 | 
 |  | 
 | # Response numbers that are followed by additional text (e.g. article) | 
 | _LONGRESP = { | 
 |     '100',   # HELP | 
 |     '101',   # CAPABILITIES | 
 |     '211',   # LISTGROUP   (also not multi-line with GROUP) | 
 |     '215',   # LIST | 
 |     '220',   # ARTICLE | 
 |     '221',   # HEAD, XHDR | 
 |     '222',   # BODY | 
 |     '224',   # OVER, XOVER | 
 |     '225',   # HDR | 
 |     '230',   # NEWNEWS | 
 |     '231',   # NEWGROUPS | 
 |     '282',   # XGTITLE | 
 | } | 
 |  | 
 | # Default decoded value for LIST OVERVIEW.FMT if not supported | 
 | _DEFAULT_OVERVIEW_FMT = [ | 
 |     "subject", "from", "date", "message-id", "references", ":bytes", ":lines"] | 
 |  | 
 | # Alternative names allowed in LIST OVERVIEW.FMT response | 
 | _OVERVIEW_FMT_ALTERNATIVES = { | 
 |     'bytes': ':bytes', | 
 |     'lines': ':lines', | 
 | } | 
 |  | 
 | # Line terminators (we always output CRLF, but accept any of CRLF, CR, LF) | 
 | _CRLF = b'\r\n' | 
 |  | 
 | GroupInfo = collections.namedtuple('GroupInfo', | 
 |                                    ['group', 'last', 'first', 'flag']) | 
 |  | 
 | ArticleInfo = collections.namedtuple('ArticleInfo', | 
 |                                      ['number', 'message_id', 'lines']) | 
 |  | 
 |  | 
 | # Helper function(s) | 
 | def decode_header(header_str): | 
 |     """Takes a unicode string representing a munged header value | 
 |     and decodes it as a (possibly non-ASCII) readable value.""" | 
 |     parts = [] | 
 |     for v, enc in _email_decode_header(header_str): | 
 |         if isinstance(v, bytes): | 
 |             parts.append(v.decode(enc or 'ascii')) | 
 |         else: | 
 |             parts.append(v) | 
 |     return ''.join(parts) | 
 |  | 
 | def _parse_overview_fmt(lines): | 
 |     """Parse a list of string representing the response to LIST OVERVIEW.FMT | 
 |     and return a list of header/metadata names. | 
 |     Raises NNTPDataError if the response is not compliant | 
 |     (cf. RFC 3977, section 8.4).""" | 
 |     fmt = [] | 
 |     for line in lines: | 
 |         if line[0] == ':': | 
 |             # Metadata name (e.g. ":bytes") | 
 |             name, _, suffix = line[1:].partition(':') | 
 |             name = ':' + name | 
 |         else: | 
 |             # Header name (e.g. "Subject:" or "Xref:full") | 
 |             name, _, suffix = line.partition(':') | 
 |         name = name.lower() | 
 |         name = _OVERVIEW_FMT_ALTERNATIVES.get(name, name) | 
 |         # Should we do something with the suffix? | 
 |         fmt.append(name) | 
 |     defaults = _DEFAULT_OVERVIEW_FMT | 
 |     if len(fmt) < len(defaults): | 
 |         raise NNTPDataError("LIST OVERVIEW.FMT response too short") | 
 |     if fmt[:len(defaults)] != defaults: | 
 |         raise NNTPDataError("LIST OVERVIEW.FMT redefines default fields") | 
 |     return fmt | 
 |  | 
 | def _parse_overview(lines, fmt, data_process_func=None): | 
 |     """Parse the response to an OVER or XOVER command according to the | 
 |     overview format `fmt`.""" | 
 |     n_defaults = len(_DEFAULT_OVERVIEW_FMT) | 
 |     overview = [] | 
 |     for line in lines: | 
 |         fields = {} | 
 |         article_number, *tokens = line.split('\t') | 
 |         article_number = int(article_number) | 
 |         for i, token in enumerate(tokens): | 
 |             if i >= len(fmt): | 
 |                 # XXX should we raise an error? Some servers might not | 
 |                 # support LIST OVERVIEW.FMT and still return additional | 
 |                 # headers. | 
 |                 continue | 
 |             field_name = fmt[i] | 
 |             is_metadata = field_name.startswith(':') | 
 |             if i >= n_defaults and not is_metadata: | 
 |                 # Non-default header names are included in full in the response | 
 |                 # (unless the field is totally empty) | 
 |                 h = field_name + ": " | 
 |                 if token and token[:len(h)].lower() != h: | 
 |                     raise NNTPDataError("OVER/XOVER response doesn't include " | 
 |                                         "names of additional headers") | 
 |                 token = token[len(h):] if token else None | 
 |             fields[fmt[i]] = token | 
 |         overview.append((article_number, fields)) | 
 |     return overview | 
 |  | 
 | def _parse_datetime(date_str, time_str=None): | 
 |     """Parse a pair of (date, time) strings, and return a datetime object. | 
 |     If only the date is given, it is assumed to be date and time | 
 |     concatenated together (e.g. response to the DATE command). | 
 |     """ | 
 |     if time_str is None: | 
 |         time_str = date_str[-6:] | 
 |         date_str = date_str[:-6] | 
 |     hours = int(time_str[:2]) | 
 |     minutes = int(time_str[2:4]) | 
 |     seconds = int(time_str[4:]) | 
 |     year = int(date_str[:-4]) | 
 |     month = int(date_str[-4:-2]) | 
 |     day = int(date_str[-2:]) | 
 |     # RFC 3977 doesn't say how to interpret 2-char years.  Assume that | 
 |     # there are no dates before 1970 on Usenet. | 
 |     if year < 70: | 
 |         year += 2000 | 
 |     elif year < 100: | 
 |         year += 1900 | 
 |     return datetime.datetime(year, month, day, hours, minutes, seconds) | 
 |  | 
 | def _unparse_datetime(dt, legacy=False): | 
 |     """Format a date or datetime object as a pair of (date, time) strings | 
 |     in the format required by the NEWNEWS and NEWGROUPS commands.  If a | 
 |     date object is passed, the time is assumed to be midnight (00h00). | 
 |  | 
 |     The returned representation depends on the legacy flag: | 
 |     * if legacy is False (the default): | 
 |       date has the YYYYMMDD format and time the HHMMSS format | 
 |     * if legacy is True: | 
 |       date has the YYMMDD format and time the HHMMSS format. | 
 |     RFC 3977 compliant servers should understand both formats; therefore, | 
 |     legacy is only needed when talking to old servers. | 
 |     """ | 
 |     if not isinstance(dt, datetime.datetime): | 
 |         time_str = "000000" | 
 |     else: | 
 |         time_str = "{0.hour:02d}{0.minute:02d}{0.second:02d}".format(dt) | 
 |     y = dt.year | 
 |     if legacy: | 
 |         y = y % 100 | 
 |         date_str = "{0:02d}{1.month:02d}{1.day:02d}".format(y, dt) | 
 |     else: | 
 |         date_str = "{0:04d}{1.month:02d}{1.day:02d}".format(y, dt) | 
 |     return date_str, time_str | 
 |  | 
 |  | 
 | if _have_ssl: | 
 |  | 
 |     def _encrypt_on(sock, context, hostname): | 
 |         """Wrap a socket in SSL/TLS. Arguments: | 
 |         - sock: Socket to wrap | 
 |         - context: SSL context to use for the encrypted connection | 
 |         Returns: | 
 |         - sock: New, encrypted socket. | 
 |         """ | 
 |         # Generate a default SSL context if none was passed. | 
 |         if context is None: | 
 |             context = ssl._create_stdlib_context() | 
 |         return context.wrap_socket(sock, server_hostname=hostname) | 
 |  | 
 |  | 
 | # The classes themselves | 
 | class NNTP: | 
 |     # UTF-8 is the character set for all NNTP commands and responses: they | 
 |     # are automatically encoded (when sending) and decoded (and receiving) | 
 |     # by this class. | 
 |     # However, some multi-line data blocks can contain arbitrary bytes (for | 
 |     # example, latin-1 or utf-16 data in the body of a message). Commands | 
 |     # taking (POST, IHAVE) or returning (HEAD, BODY, ARTICLE) raw message | 
 |     # data will therefore only accept and produce bytes objects. | 
 |     # Furthermore, since there could be non-compliant servers out there, | 
 |     # we use 'surrogateescape' as the error handler for fault tolerance | 
 |     # and easy round-tripping. This could be useful for some applications | 
 |     # (e.g. NNTP gateways). | 
 |  | 
 |     encoding = 'utf-8' | 
 |     errors = 'surrogateescape' | 
 |  | 
 |     def __init__(self, host, port=NNTP_PORT, user=None, password=None, | 
 |                  readermode=None, usenetrc=False, | 
 |                  timeout=_GLOBAL_DEFAULT_TIMEOUT): | 
 |         """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. | 
 |         - usenetrc: allow loading username and password from ~/.netrc file | 
 |                     if not specified explicitly | 
 |         - timeout: timeout (in seconds) used for socket connections | 
 |  | 
 |         readermode is sometimes necessary if you are connecting to an | 
 |         NNTP server on the local machine and intend to call | 
 |         reader-specific commands, such as `group'.  If you get | 
 |         unexpected NNTPPermanentErrors, you might need to set | 
 |         readermode. | 
 |         """ | 
 |         self.host = host | 
 |         self.port = port | 
 |         self.sock = self._create_socket(timeout) | 
 |         self.file = None | 
 |         try: | 
 |             self.file = self.sock.makefile("rwb") | 
 |             self._base_init(readermode) | 
 |             if user or usenetrc: | 
 |                 self.login(user, password, usenetrc) | 
 |         except: | 
 |             if self.file: | 
 |                 self.file.close() | 
 |             self.sock.close() | 
 |             raise | 
 |  | 
 |     def _base_init(self, readermode): | 
 |         """Partial initialization for the NNTP protocol. | 
 |         This instance method is extracted for supporting the test code. | 
 |         """ | 
 |         self.debugging = 0 | 
 |         self.welcome = self._getresp() | 
 |  | 
 |         # Inquire about capabilities (RFC 3977). | 
 |         self._caps = None | 
 |         self.getcapabilities() | 
 |  | 
 |         # 'MODE READER' is sometimes necessary to enable 'reader' mode. | 
 |         # However, the order in which 'MODE READER' and 'AUTHINFO' need to | 
 |         # arrive differs between some NNTP servers. If _setreadermode() fails | 
 |         # with an authorization failed error, it will set this to True; | 
 |         # the login() routine will interpret that as a request to try again | 
 |         # after performing its normal function. | 
 |         # Enable only if we're not already in READER mode anyway. | 
 |         self.readermode_afterauth = False | 
 |         if readermode and 'READER' not in self._caps: | 
 |             self._setreadermode() | 
 |             if not self.readermode_afterauth: | 
 |                 # Capabilities might have changed after MODE READER | 
 |                 self._caps = None | 
 |                 self.getcapabilities() | 
 |  | 
 |         # RFC 4642 2.2.2: Both the client and the server MUST know if there is | 
 |         # a TLS session active.  A client MUST NOT attempt to start a TLS | 
 |         # session if a TLS session is already active. | 
 |         self.tls_on = False | 
 |  | 
 |         # Log in and encryption setup order is left to subclasses. | 
 |         self.authenticated = False | 
 |  | 
 |     def __enter__(self): | 
 |         return self | 
 |  | 
 |     def __exit__(self, *args): | 
 |         is_connected = lambda: hasattr(self, "file") | 
 |         if is_connected(): | 
 |             try: | 
 |                 self.quit() | 
 |             except (OSError, EOFError): | 
 |                 pass | 
 |             finally: | 
 |                 if is_connected(): | 
 |                     self._close() | 
 |  | 
 |     def _create_socket(self, timeout): | 
 |         if timeout is not None and not timeout: | 
 |             raise ValueError('Non-blocking socket (timeout=0) is not supported') | 
 |         sys.audit("nntplib.connect", self, self.host, self.port) | 
 |         return socket.create_connection((self.host, self.port), timeout) | 
 |  | 
 |     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.""" | 
 |  | 
 |         if self.debugging: print('*welcome*', repr(self.welcome)) | 
 |         return self.welcome | 
 |  | 
 |     def getcapabilities(self): | 
 |         """Get the server capabilities, as read by __init__(). | 
 |         If the CAPABILITIES command is not supported, an empty dict is | 
 |         returned.""" | 
 |         if self._caps is None: | 
 |             self.nntp_version = 1 | 
 |             self.nntp_implementation = None | 
 |             try: | 
 |                 resp, caps = self.capabilities() | 
 |             except (NNTPPermanentError, NNTPTemporaryError): | 
 |                 # Server doesn't support capabilities | 
 |                 self._caps = {} | 
 |             else: | 
 |                 self._caps = caps | 
 |                 if 'VERSION' in caps: | 
 |                     # The server can advertise several supported versions, | 
 |                     # choose the highest. | 
 |                     self.nntp_version = max(map(int, caps['VERSION'])) | 
 |                 if 'IMPLEMENTATION' in caps: | 
 |                     self.nntp_implementation = ' '.join(caps['IMPLEMENTATION']) | 
 |         return self._caps | 
 |  | 
 |     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. | 
 |         The `line` must be a bytes-like object.""" | 
 |         sys.audit("nntplib.putline", self, line) | 
 |         line = line + _CRLF | 
 |         if self.debugging > 1: print('*put*', repr(line)) | 
 |         self.file.write(line) | 
 |         self.file.flush() | 
 |  | 
 |     def _putcmd(self, line): | 
 |         """Internal: send one command to the server (through _putline()). | 
 |         The `line` must be a unicode string.""" | 
 |         if self.debugging: print('*cmd*', repr(line)) | 
 |         line = line.encode(self.encoding, self.errors) | 
 |         self._putline(line) | 
 |  | 
 |     def _getline(self, strip_crlf=True): | 
 |         """Internal: return one line from the server, stripping _CRLF. | 
 |         Raise EOFError if the connection is closed. | 
 |         Returns a bytes object.""" | 
 |         line = self.file.readline(_MAXLINE +1) | 
 |         if len(line) > _MAXLINE: | 
 |             raise NNTPDataError('line too long') | 
 |         if self.debugging > 1: | 
 |             print('*get*', repr(line)) | 
 |         if not line: raise EOFError | 
 |         if strip_crlf: | 
 |             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. | 
 |         Returns a unicode string.""" | 
 |         resp = self._getline() | 
 |         if self.debugging: print('*resp*', repr(resp)) | 
 |         resp = resp.decode(self.encoding, self.errors) | 
 |         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 | 
 |  | 
 |     def _getlongresp(self, file=None): | 
 |         """Internal: get a response plus following text from the server. | 
 |         Raise various errors if the response indicates an error. | 
 |  | 
 |         Returns a (response, lines) tuple where `response` is a unicode | 
 |         string and `lines` is a list of bytes objects. | 
 |         If `file` is a file-like object, it must be open in binary mode. | 
 |         """ | 
 |  | 
 |         openedFile = None | 
 |         try: | 
 |             # If a string was passed then open a file with that name | 
 |             if isinstance(file, (str, bytes)): | 
 |                 openedFile = file = open(file, "wb") | 
 |  | 
 |             resp = self._getresp() | 
 |             if resp[:3] not in _LONGRESP: | 
 |                 raise NNTPReplyError(resp) | 
 |  | 
 |             lines = [] | 
 |             if file is not None: | 
 |                 # XXX lines = None instead? | 
 |                 terminators = (b'.' + _CRLF, b'.\n') | 
 |                 while 1: | 
 |                     line = self._getline(False) | 
 |                     if line in terminators: | 
 |                         break | 
 |                     if line.startswith(b'..'): | 
 |                         line = line[1:] | 
 |                     file.write(line) | 
 |             else: | 
 |                 terminator = b'.' | 
 |                 while 1: | 
 |                     line = self._getline() | 
 |                     if line == terminator: | 
 |                         break | 
 |                     if line.startswith(b'..'): | 
 |                         line = line[1:] | 
 |                     lines.append(line) | 
 |         finally: | 
 |             # If this method created the file, then it must close it | 
 |             if openedFile: | 
 |                 openedFile.close() | 
 |  | 
 |         return resp, lines | 
 |  | 
 |     def _shortcmd(self, line): | 
 |         """Internal: send a command and get the response. | 
 |         Same return value as _getresp().""" | 
 |         self._putcmd(line) | 
 |         return self._getresp() | 
 |  | 
 |     def _longcmd(self, line, file=None): | 
 |         """Internal: send a command and get the response plus following text. | 
 |         Same return value as _getlongresp().""" | 
 |         self._putcmd(line) | 
 |         return self._getlongresp(file) | 
 |  | 
 |     def _longcmdstring(self, line, file=None): | 
 |         """Internal: send a command and get the response plus following text. | 
 |         Same as _longcmd() and _getlongresp(), except that the returned `lines` | 
 |         are unicode strings rather than bytes objects. | 
 |         """ | 
 |         self._putcmd(line) | 
 |         resp, list = self._getlongresp(file) | 
 |         return resp, [line.decode(self.encoding, self.errors) | 
 |                       for line in list] | 
 |  | 
 |     def _getoverviewfmt(self): | 
 |         """Internal: get the overview format. Queries the server if not | 
 |         already done, else returns the cached value.""" | 
 |         try: | 
 |             return self._cachedoverviewfmt | 
 |         except AttributeError: | 
 |             pass | 
 |         try: | 
 |             resp, lines = self._longcmdstring("LIST OVERVIEW.FMT") | 
 |         except NNTPPermanentError: | 
 |             # Not supported by server? | 
 |             fmt = _DEFAULT_OVERVIEW_FMT[:] | 
 |         else: | 
 |             fmt = _parse_overview_fmt(lines) | 
 |         self._cachedoverviewfmt = fmt | 
 |         return fmt | 
 |  | 
 |     def _grouplist(self, lines): | 
 |         # Parse lines into "group last first flag" | 
 |         return [GroupInfo(*line.split()) for line in lines] | 
 |  | 
 |     def capabilities(self): | 
 |         """Process a CAPABILITIES command.  Not supported by all servers. | 
 |         Return: | 
 |         - resp: server response if successful | 
 |         - caps: a dictionary mapping capability names to lists of tokens | 
 |         (for example {'VERSION': ['2'], 'OVER': [], LIST: ['ACTIVE', 'HEADERS'] }) | 
 |         """ | 
 |         caps = {} | 
 |         resp, lines = self._longcmdstring("CAPABILITIES") | 
 |         for line in lines: | 
 |             name, *tokens = line.split() | 
 |             caps[name] = tokens | 
 |         return resp, caps | 
 |  | 
 |     def newgroups(self, date, *, file=None): | 
 |         """Process a NEWGROUPS command.  Arguments: | 
 |         - date: a date or datetime object | 
 |         Return: | 
 |         - resp: server response if successful | 
 |         - list: list of newsgroup names | 
 |         """ | 
 |         if not isinstance(date, (datetime.date, datetime.date)): | 
 |             raise TypeError( | 
 |                 "the date parameter must be a date or datetime object, " | 
 |                 "not '{:40}'".format(date.__class__.__name__)) | 
 |         date_str, time_str = _unparse_datetime(date, self.nntp_version < 2) | 
 |         cmd = 'NEWGROUPS {0} {1}'.format(date_str, time_str) | 
 |         resp, lines = self._longcmdstring(cmd, file) | 
 |         return resp, self._grouplist(lines) | 
 |  | 
 |     def newnews(self, group, date, *, file=None): | 
 |         """Process a NEWNEWS command.  Arguments: | 
 |         - group: group name or '*' | 
 |         - date: a date or datetime object | 
 |         Return: | 
 |         - resp: server response if successful | 
 |         - list: list of message ids | 
 |         """ | 
 |         if not isinstance(date, (datetime.date, datetime.date)): | 
 |             raise TypeError( | 
 |                 "the date parameter must be a date or datetime object, " | 
 |                 "not '{:40}'".format(date.__class__.__name__)) | 
 |         date_str, time_str = _unparse_datetime(date, self.nntp_version < 2) | 
 |         cmd = 'NEWNEWS {0} {1} {2}'.format(group, date_str, time_str) | 
 |         return self._longcmdstring(cmd, file) | 
 |  | 
 |     def list(self, group_pattern=None, *, file=None): | 
 |         """Process a LIST or LIST ACTIVE command. Arguments: | 
 |         - group_pattern: a pattern indicating which groups to query | 
 |         - file: Filename string or file object to store the result in | 
 |         Returns: | 
 |         - resp: server response if successful | 
 |         - list: list of (group, last, first, flag) (strings) | 
 |         """ | 
 |         if group_pattern is not None: | 
 |             command = 'LIST ACTIVE ' + group_pattern | 
 |         else: | 
 |             command = 'LIST' | 
 |         resp, lines = self._longcmdstring(command, file) | 
 |         return resp, self._grouplist(lines) | 
 |  | 
 |     def _getdescriptions(self, group_pattern, return_all): | 
 |         line_pat = re.compile('^(?P<group>[^ \t]+)[ \t]+(.*)$') | 
 |         # Try the more std (acc. to RFC2980) LIST NEWSGROUPS first | 
 |         resp, lines = self._longcmdstring('LIST NEWSGROUPS ' + group_pattern) | 
 |         if not resp.startswith('215'): | 
 |             # Now the deprecated XGTITLE.  This either raises an error | 
 |             # or succeeds with the same output structure as LIST | 
 |             # NEWSGROUPS. | 
 |             resp, lines = self._longcmdstring('XGTITLE ' + group_pattern) | 
 |         groups = {} | 
 |         for raw_line in lines: | 
 |             match = line_pat.search(raw_line.strip()) | 
 |             if match: | 
 |                 name, desc = match.group(1, 2) | 
 |                 if not return_all: | 
 |                     return desc | 
 |                 groups[name] = desc | 
 |         if return_all: | 
 |             return resp, groups | 
 |         else: | 
 |             # Nothing found | 
 |             return '' | 
 |  | 
 |     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.""" | 
 |         return self._getdescriptions(group, False) | 
 |  | 
 |     def descriptions(self, group_pattern): | 
 |         """Get descriptions for a range of groups.""" | 
 |         return self._getdescriptions(group_pattern, True) | 
 |  | 
 |     def group(self, name): | 
 |         """Process a GROUP command.  Argument: | 
 |         - group: the group name | 
 |         Returns: | 
 |         - resp: server response if successful | 
 |         - count: number of articles | 
 |         - first: first article number | 
 |         - last: last article number | 
 |         - name: the group name | 
 |         """ | 
 |         resp = self._shortcmd('GROUP ' + name) | 
 |         if not resp.startswith('211'): | 
 |             raise NNTPReplyError(resp) | 
 |         words = resp.split() | 
 |         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: | 
 |                         name = words[4].lower() | 
 |         return resp, int(count), int(first), int(last), name | 
 |  | 
 |     def help(self, *, file=None): | 
 |         """Process a HELP command. Argument: | 
 |         - file: Filename string or file object to store the result in | 
 |         Returns: | 
 |         - resp: server response if successful | 
 |         - list: list of strings returned by the server in response to the | 
 |                 HELP command | 
 |         """ | 
 |         return self._longcmdstring('HELP', file) | 
 |  | 
 |     def _statparse(self, resp): | 
 |         """Internal: parse the response line of a STAT, NEXT, LAST, | 
 |         ARTICLE, HEAD or BODY command.""" | 
 |         if not resp.startswith('22'): | 
 |             raise NNTPReplyError(resp) | 
 |         words = resp.split() | 
 |         art_num = int(words[1]) | 
 |         message_id = words[2] | 
 |         return resp, art_num, message_id | 
 |  | 
 |     def _statcmd(self, line): | 
 |         """Internal: process a STAT, NEXT or LAST command.""" | 
 |         resp = self._shortcmd(line) | 
 |         return self._statparse(resp) | 
 |  | 
 |     def stat(self, message_spec=None): | 
 |         """Process a STAT command.  Argument: | 
 |         - message_spec: article number or message id (if not specified, | 
 |           the current article is selected) | 
 |         Returns: | 
 |         - resp: server response if successful | 
 |         - art_num: the article number | 
 |         - message_id: the message id | 
 |         """ | 
 |         if message_spec: | 
 |             return self._statcmd('STAT {0}'.format(message_spec)) | 
 |         else: | 
 |             return self._statcmd('STAT') | 
 |  | 
 |     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') | 
 |  | 
 |     def _artcmd(self, line, file=None): | 
 |         """Internal: process a HEAD, BODY or ARTICLE command.""" | 
 |         resp, lines = self._longcmd(line, file) | 
 |         resp, art_num, message_id = self._statparse(resp) | 
 |         return resp, ArticleInfo(art_num, message_id, lines) | 
 |  | 
 |     def head(self, message_spec=None, *, file=None): | 
 |         """Process a HEAD command.  Argument: | 
 |         - message_spec: article number or message id | 
 |         - file: filename string or file object to store the headers in | 
 |         Returns: | 
 |         - resp: server response if successful | 
 |         - ArticleInfo: (article number, message id, list of header lines) | 
 |         """ | 
 |         if message_spec is not None: | 
 |             cmd = 'HEAD {0}'.format(message_spec) | 
 |         else: | 
 |             cmd = 'HEAD' | 
 |         return self._artcmd(cmd, file) | 
 |  | 
 |     def body(self, message_spec=None, *, file=None): | 
 |         """Process a BODY command.  Argument: | 
 |         - message_spec: article number or message id | 
 |         - file: filename string or file object to store the body in | 
 |         Returns: | 
 |         - resp: server response if successful | 
 |         - ArticleInfo: (article number, message id, list of body lines) | 
 |         """ | 
 |         if message_spec is not None: | 
 |             cmd = 'BODY {0}'.format(message_spec) | 
 |         else: | 
 |             cmd = 'BODY' | 
 |         return self._artcmd(cmd, file) | 
 |  | 
 |     def article(self, message_spec=None, *, file=None): | 
 |         """Process an ARTICLE command.  Argument: | 
 |         - message_spec: article number or message id | 
 |         - file: filename string or file object to store the article in | 
 |         Returns: | 
 |         - resp: server response if successful | 
 |         - ArticleInfo: (article number, message id, list of article lines) | 
 |         """ | 
 |         if message_spec is not None: | 
 |             cmd = 'ARTICLE {0}'.format(message_spec) | 
 |         else: | 
 |             cmd = 'ARTICLE' | 
 |         return self._artcmd(cmd, file) | 
 |  | 
 |     def slave(self): | 
 |         """Process a SLAVE command.  Returns: | 
 |         - resp: server response if successful | 
 |         """ | 
 |         return self._shortcmd('SLAVE') | 
 |  | 
 |     def xhdr(self, hdr, str, *, file=None): | 
 |         """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 | 
 |         - file: Filename string or file object to store the result in | 
 |         Returns: | 
 |         - resp: server response if successful | 
 |         - list: list of (nr, value) strings | 
 |         """ | 
 |         pat = re.compile('^([0-9]+) ?(.*)\n?') | 
 |         resp, lines = self._longcmdstring('XHDR {0} {1}'.format(hdr, str), file) | 
 |         def remove_number(line): | 
 |             m = pat.match(line) | 
 |             return m.group(1, 2) if m else line | 
 |         return resp, [remove_number(line) for line in lines] | 
 |  | 
 |     def xover(self, start, end, *, file=None): | 
 |         """Process an XOVER command (optional server extension) Arguments: | 
 |         - start: start of range | 
 |         - end: end of range | 
 |         - file: Filename string or file object to store the result in | 
 |         Returns: | 
 |         - resp: server response if successful | 
 |         - list: list of dicts containing the response fields | 
 |         """ | 
 |         resp, lines = self._longcmdstring('XOVER {0}-{1}'.format(start, end), | 
 |                                           file) | 
 |         fmt = self._getoverviewfmt() | 
 |         return resp, _parse_overview(lines, fmt) | 
 |  | 
 |     def over(self, message_spec, *, file=None): | 
 |         """Process an OVER command.  If the command isn't supported, fall | 
 |         back to XOVER. Arguments: | 
 |         - message_spec: | 
 |             - either a message id, indicating the article to fetch | 
 |               information about | 
 |             - or a (start, end) tuple, indicating a range of article numbers; | 
 |               if end is None, information up to the newest message will be | 
 |               retrieved | 
 |             - or None, indicating the current article number must be used | 
 |         - file: Filename string or file object to store the result in | 
 |         Returns: | 
 |         - resp: server response if successful | 
 |         - list: list of dicts containing the response fields | 
 |  | 
 |         NOTE: the "message id" form isn't supported by XOVER | 
 |         """ | 
 |         cmd = 'OVER' if 'OVER' in self._caps else 'XOVER' | 
 |         if isinstance(message_spec, (tuple, list)): | 
 |             start, end = message_spec | 
 |             cmd += ' {0}-{1}'.format(start, end or '') | 
 |         elif message_spec is not None: | 
 |             cmd = cmd + ' ' + message_spec | 
 |         resp, lines = self._longcmdstring(cmd, file) | 
 |         fmt = self._getoverviewfmt() | 
 |         return resp, _parse_overview(lines, fmt) | 
 |  | 
 |     def date(self): | 
 |         """Process the DATE command. | 
 |         Returns: | 
 |         - resp: server response if successful | 
 |         - date: datetime object | 
 |         """ | 
 |         resp = self._shortcmd("DATE") | 
 |         if not resp.startswith('111'): | 
 |             raise NNTPReplyError(resp) | 
 |         elem = resp.split() | 
 |         if len(elem) != 2: | 
 |             raise NNTPDataError(resp) | 
 |         date = elem[1] | 
 |         if len(date) != 14: | 
 |             raise NNTPDataError(resp) | 
 |         return resp, _parse_datetime(date, None) | 
 |  | 
 |     def _post(self, command, f): | 
 |         resp = self._shortcmd(command) | 
 |         # Raises a specific exception if posting is not allowed | 
 |         if not resp.startswith('3'): | 
 |             raise NNTPReplyError(resp) | 
 |         if isinstance(f, (bytes, bytearray)): | 
 |             f = f.splitlines() | 
 |         # We don't use _putline() because: | 
 |         # - we don't want additional CRLF if the file or iterable is already | 
 |         #   in the right format | 
 |         # - we don't want a spurious flush() after each line is written | 
 |         for line in f: | 
 |             if not line.endswith(_CRLF): | 
 |                 line = line.rstrip(b"\r\n") + _CRLF | 
 |             if line.startswith(b'.'): | 
 |                 line = b'.' + line | 
 |             self.file.write(line) | 
 |         self.file.write(b".\r\n") | 
 |         self.file.flush() | 
 |         return self._getresp() | 
 |  | 
 |     def post(self, data): | 
 |         """Process a POST command.  Arguments: | 
 |         - data: bytes object, iterable or file containing the article | 
 |         Returns: | 
 |         - resp: server response if successful""" | 
 |         return self._post('POST', data) | 
 |  | 
 |     def ihave(self, message_id, data): | 
 |         """Process an IHAVE command.  Arguments: | 
 |         - message_id: message-id of the article | 
 |         - data: file containing the article | 
 |         Returns: | 
 |         - resp: server response if successful | 
 |         Note that if the server refuses the article an exception is raised.""" | 
 |         return self._post('IHAVE {0}'.format(message_id), data) | 
 |  | 
 |     def _close(self): | 
 |         try: | 
 |             if self.file: | 
 |                 self.file.close() | 
 |                 del self.file | 
 |         finally: | 
 |             self.sock.close() | 
 |  | 
 |     def quit(self): | 
 |         """Process a QUIT command and close the socket.  Returns: | 
 |         - resp: server response if successful""" | 
 |         try: | 
 |             resp = self._shortcmd('QUIT') | 
 |         finally: | 
 |             self._close() | 
 |         return resp | 
 |  | 
 |     def login(self, user=None, password=None, usenetrc=True): | 
 |         if self.authenticated: | 
 |             raise ValueError("Already logged in.") | 
 |         if not user and not usenetrc: | 
 |             raise ValueError( | 
 |                 "At least one of `user` and `usenetrc` must be specified") | 
 |         # If no login/password was specified but netrc was requested, | 
 |         # try to get them from ~/.netrc | 
 |         # Presume that if .netrc has an entry, NNRP authentication is required. | 
 |         try: | 
 |             if usenetrc and not user: | 
 |                 import netrc | 
 |                 credentials = netrc.netrc() | 
 |                 auth = credentials.authenticators(self.host) | 
 |                 if auth: | 
 |                     user = auth[0] | 
 |                     password = auth[2] | 
 |         except OSError: | 
 |             pass | 
 |         # Perform NNTP authentication if needed. | 
 |         if not user: | 
 |             return | 
 |         resp = self._shortcmd('authinfo user ' + user) | 
 |         if resp.startswith('381'): | 
 |             if not password: | 
 |                 raise NNTPReplyError(resp) | 
 |             else: | 
 |                 resp = self._shortcmd('authinfo pass ' + password) | 
 |                 if not resp.startswith('281'): | 
 |                     raise NNTPPermanentError(resp) | 
 |         # Capabilities might have changed after login | 
 |         self._caps = None | 
 |         self.getcapabilities() | 
 |         # Attempt to send mode reader if it was requested after login. | 
 |         # Only do so if we're not in reader mode already. | 
 |         if self.readermode_afterauth and 'READER' not in self._caps: | 
 |             self._setreadermode() | 
 |             # Capabilities might have changed after MODE READER | 
 |             self._caps = None | 
 |             self.getcapabilities() | 
 |  | 
 |     def _setreadermode(self): | 
 |         try: | 
 |             self.welcome = self._shortcmd('mode reader') | 
 |         except NNTPPermanentError: | 
 |             # Error 5xx, probably 'not implemented' | 
 |             pass | 
 |         except NNTPTemporaryError as e: | 
 |             if e.response.startswith('480'): | 
 |                 # Need authorization before 'mode reader' | 
 |                 self.readermode_afterauth = True | 
 |             else: | 
 |                 raise | 
 |  | 
 |     if _have_ssl: | 
 |         def starttls(self, context=None): | 
 |             """Process a STARTTLS command. Arguments: | 
 |             - context: SSL context to use for the encrypted connection | 
 |             """ | 
 |             # Per RFC 4642, STARTTLS MUST NOT be sent after authentication or if | 
 |             # a TLS session already exists. | 
 |             if self.tls_on: | 
 |                 raise ValueError("TLS is already enabled.") | 
 |             if self.authenticated: | 
 |                 raise ValueError("TLS cannot be started after authentication.") | 
 |             resp = self._shortcmd('STARTTLS') | 
 |             if resp.startswith('382'): | 
 |                 self.file.close() | 
 |                 self.sock = _encrypt_on(self.sock, context, self.host) | 
 |                 self.file = self.sock.makefile("rwb") | 
 |                 self.tls_on = True | 
 |                 # Capabilities may change after TLS starts up, so ask for them | 
 |                 # again. | 
 |                 self._caps = None | 
 |                 self.getcapabilities() | 
 |             else: | 
 |                 raise NNTPError("TLS failed to start.") | 
 |  | 
 |  | 
 | if _have_ssl: | 
 |     class NNTP_SSL(NNTP): | 
 |  | 
 |         def __init__(self, host, port=NNTP_SSL_PORT, | 
 |                     user=None, password=None, ssl_context=None, | 
 |                     readermode=None, usenetrc=False, | 
 |                     timeout=_GLOBAL_DEFAULT_TIMEOUT): | 
 |             """This works identically to NNTP.__init__, except for the change | 
 |             in default port and the `ssl_context` argument for SSL connections. | 
 |             """ | 
 |             self.ssl_context = ssl_context | 
 |             super().__init__(host, port, user, password, readermode, | 
 |                              usenetrc, timeout) | 
 |  | 
 |         def _create_socket(self, timeout): | 
 |             sock = super()._create_socket(timeout) | 
 |             try: | 
 |                 sock = _encrypt_on(sock, self.ssl_context, self.host) | 
 |             except: | 
 |                 sock.close() | 
 |                 raise | 
 |             else: | 
 |                 return sock | 
 |  | 
 |     __all__.append("NNTP_SSL") | 
 |  | 
 |  | 
 | # Test retrieval when run as a script. | 
 | if __name__ == '__main__': | 
 |     import argparse | 
 |  | 
 |     parser = argparse.ArgumentParser(description="""\ | 
 |         nntplib built-in demo - display the latest articles in a newsgroup""") | 
 |     parser.add_argument('-g', '--group', default='gmane.comp.python.general', | 
 |                         help='group to fetch messages from (default: %(default)s)') | 
 |     parser.add_argument('-s', '--server', default='news.gmane.io', | 
 |                         help='NNTP server hostname (default: %(default)s)') | 
 |     parser.add_argument('-p', '--port', default=-1, type=int, | 
 |                         help='NNTP port number (default: %s / %s)' % (NNTP_PORT, NNTP_SSL_PORT)) | 
 |     parser.add_argument('-n', '--nb-articles', default=10, type=int, | 
 |                         help='number of articles to fetch (default: %(default)s)') | 
 |     parser.add_argument('-S', '--ssl', action='store_true', default=False, | 
 |                         help='use NNTP over SSL') | 
 |     args = parser.parse_args() | 
 |  | 
 |     port = args.port | 
 |     if not args.ssl: | 
 |         if port == -1: | 
 |             port = NNTP_PORT | 
 |         s = NNTP(host=args.server, port=port) | 
 |     else: | 
 |         if port == -1: | 
 |             port = NNTP_SSL_PORT | 
 |         s = NNTP_SSL(host=args.server, port=port) | 
 |  | 
 |     caps = s.getcapabilities() | 
 |     if 'STARTTLS' in caps: | 
 |         s.starttls() | 
 |     resp, count, first, last, name = s.group(args.group) | 
 |     print('Group', name, 'has', count, 'articles, range', first, 'to', last) | 
 |  | 
 |     def cut(s, lim): | 
 |         if len(s) > lim: | 
 |             s = s[:lim - 4] + "..." | 
 |         return s | 
 |  | 
 |     first = str(int(last) - args.nb_articles + 1) | 
 |     resp, overviews = s.xover(first, last) | 
 |     for artnum, over in overviews: | 
 |         author = decode_header(over['from']).split('<', 1)[0] | 
 |         subject = decode_header(over['subject']) | 
 |         lines = int(over[':lines']) | 
 |         print("{:7} {:20} {:42} ({})".format( | 
 |               artnum, cut(author, 20), cut(subject, 42), lines) | 
 |               ) | 
 |  | 
 |     s.quit() |