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