diff options
author | Devtools Arcadia <arcadia-devtools@yandex-team.ru> | 2022-02-07 18:08:42 +0300 |
---|---|---|
committer | Devtools Arcadia <arcadia-devtools@mous.vla.yp-c.yandex.net> | 2022-02-07 18:08:42 +0300 |
commit | 1110808a9d39d4b808aef724c861a2e1a38d2a69 (patch) | |
tree | e26c9fed0de5d9873cce7e00bc214573dc2195b7 /contrib/tools/python3/src/Lib/nntplib.py | |
download | ydb-1110808a9d39d4b808aef724c861a2e1a38d2a69.tar.gz |
intermediate changes
ref:cde9a383711a11544ce7e107a78147fb96cc4029
Diffstat (limited to 'contrib/tools/python3/src/Lib/nntplib.py')
-rw-r--r-- | contrib/tools/python3/src/Lib/nntplib.py | 1090 |
1 files changed, 1090 insertions, 0 deletions
diff --git a/contrib/tools/python3/src/Lib/nntplib.py b/contrib/tools/python3/src/Lib/nntplib.py new file mode 100644 index 0000000000..f6e746e7c9 --- /dev/null +++ b/contrib/tools/python3/src/Lib/nntplib.py @@ -0,0 +1,1090 @@ +"""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() |