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