|  | """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() |