Index: Lib/test/test_nntplib.py =================================================================== --- Lib/test/test_nntplib.py (revision ) +++ Lib/test/test_nntplib.py (revision ) @@ -0,0 +1,69 @@ +import unittest +from test import support +from nntplib import NNTP, GroupInfo, XoverInfo + +TIMEOUT = 60 + +class NntpTests(unittest.TestCase): + NNTP_HOST = 'news.gmane.org' + GROUP_NAME = 'gmane.comp.python.devel' + + def setUp(self): + self.server = NNTP(self.NNTP_HOST, timeout=TIMEOUT) + + def tearDown(self): + self.server.quit() + + def test_welcome(self): + welcome = self.server.getwelcome() + self.assertEqual(str, type(welcome)) + + def test_help(self): + resp, list = self.server.help() + for line in list: + self.assertEqual(str, type(line)) + + def test_groups(self): + resp, list = self.server.list() + if len(list) > 0: + self.assertEqual(GroupInfo, type(list[0])) + self.assertEqual(str, type(list[0].group)) + + def test_description(self): + descr = self.server.description(self.GROUP_NAME) + self.assertEqual(str, type(descr)) + + def test_group(self): + result = self.server.group(self.GROUP_NAME) + self.assertEqual(5, len(result)) + self.assertEqual(str, type(result[4])) + + def test_date(self): + result = self.server.date() + self.assertEqual(3, len(result)) + self.assertEqual(str, type(result[1])) + + def test_head(self): + resp, count, first, last, name = self.server.group(self.GROUP_NAME) + result = self.server.head(last) + for line in result.lines: + self.assertEqual(str, type(line)) + + def test_xover(self): + resp, count, first, last, name = self.server.group(self.GROUP_NAME) + resp, lines = self.server.xover(last, last) + self.assertEqual(XoverInfo, type(lines[0])) + self.assertEqual(str, type(lines[0].subject)) + + def test_xhdr(self): + resp, count, first, last, name = self.server.group(self.GROUP_NAME) + resp, lines = self.server.xhdr('subject', last) + for line in lines: + self.assertEqual(str, type(line[1])) + +def test_main(): + support.requires("network") + support.run_unittest(NntpTests) + +if __name__ == "__main__": + test_main() Index: Lib/nntplib.py =================================================================== --- Lib/nntplib.py (revision 72643) +++ Lib/nntplib.py (revision ) @@ -31,7 +31,10 @@ # Imports import re import socket +import collections +from socket import _GLOBAL_DEFAULT_TIMEOUT + __all__ = ["NNTP","NNTPReplyError","NNTPTemporaryError", "NNTPPermanentError","NNTPProtocolError","NNTPDataError", "error_reply","error_temp","error_perm","error_proto", @@ -87,12 +90,23 @@ # 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', + ['resp', 'nr', 'id', 'lines']) +XoverInfo = collections.namedtuple('XoverInfo', + ['articleno', 'subject', 'poster', 'date', + 'id', 'references', 'size', 'lines']) + # The class itself class NNTP: + encoding = 'latin1' + def __init__(self, host, port=NNTP_PORT, user=None, password=None, - readermode=None, usenetrc=True): + readermode=None, usenetrc=True, + timeout=_GLOBAL_DEFAULT_TIMEOUT): """Initialize an instance. Arguments: - host: hostname to connect to - port: port to connect to (default the standard NNTP port) @@ -100,6 +114,9 @@ - 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 @@ -109,10 +126,10 @@ """ self.host = host self.port = port - self.sock = socket.create_connection((host, port)) + self.sock = socket.create_connection((host, port), timeout) self.file = self.sock.makefile('rb') self.debugging = 0 - self.welcome = self.getresp() + self.welcome = self.getresp().decode(self.encoding) # 'mode reader' is sometimes necessary to enable 'reader' mode. # However, the order in which 'mode reader' and 'authinfo' need to @@ -162,12 +179,6 @@ # error 500, probably 'not implemented' pass - - # 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 - def getwelcome(self): """Get the welcome message from the server (this is read and squirreled away by __init__()). @@ -266,14 +277,19 @@ self.putcmd(line) return self.getlongresp(file) + def _longcmdstring(self, line, file=None): + self.putcmd(line) + resp, list = self.getlongresp(file) + return resp, (line.decode(self.encoding) for line in list) + def newgroups(self, date, time, file=None): """Process a NEWGROUPS command. Arguments: - date: string 'yymmdd' indicating the date - time: string 'hhmmss' indicating the time Return: - resp: server response if successful - - list: list of newsgroup names""" - + - list: list of newsgroup names + """ return self.longcmd('NEWGROUPS ' + date + ' ' + time, file) def newnews(self, group, date, time, file=None): @@ -283,24 +299,23 @@ - time: string 'hhmmss' indicating the time Return: - resp: server response if successful - - list: list of message ids""" - + - list: list of message ids + """ cmd = 'NEWNEWS ' + group + ' ' + date + ' ' + time return self.longcmd(cmd, file) def list(self, file=None): - """Process a LIST command. Return: + """Process a LIST command. Argument: + - 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)""" - - resp, list = self.longcmd('LIST', file) - for i in range(len(list)): + - list: list of (group, last, first, flag) (strings) + """ + resp, list = self._longcmdstring('LIST', file) - # Parse lines into "group last first flag" + # Parse lines into "group last first flag" - list[i] = tuple(list[i].split()) - return resp, list + return resp, [GroupInfo(*line.split()) for line in list] 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. @@ -311,7 +326,6 @@ NOTE: This neither checks for a wildcard in 'group' nor does it check whether the group actually exists.""" - resp, lines = self.descriptions(group) if len(lines) == 0: return b'' @@ -320,14 +334,14 @@ def descriptions(self, group_pattern): """Get descriptions for a range of groups.""" - line_pat = re.compile(b'^(?P[^ \t]+)[ \t]+(.*)$') + line_pat = re.compile('^(?P[^ \t]+)[ \t]+(.*)$') # Try the more std (acc. to RFC2980) LIST NEWSGROUPS first - resp, raw_lines = self.longcmd('LIST NEWSGROUPS ' + group_pattern) + resp, raw_lines = self._longcmdstring('LIST NEWSGROUPS ' + group_pattern) if not resp.startswith(b'215'): # Now the deprecated XGTITLE. This either raises an error # or succeeds with the same output structure as LIST # NEWSGROUPS. - resp, raw_lines = self.longcmd('XGTITLE ' + group_pattern) + resp, raw_lines = self._longcmdstring('XGTITLE ' + group_pattern) lines = [] for raw_line in raw_lines: match = line_pat.search(raw_line.strip()) @@ -343,12 +357,12 @@ - count: number of articles (string) - first: first article number (string) - last: last article number (string) - - name: the group name""" - + - name: the group name + """ resp = self.shortcmd('GROUP ' + name) if not resp.startswith(b'211'): raise NNTPReplyError(resp) - words = resp.split() + words = resp.decode(self.encoding).split() count = first = last = 0 n = len(words) if n > 1: @@ -362,12 +376,15 @@ return resp, count, first, last, name def help(self, file=None): - """Process a HELP command. Returns: + """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""" + - list: list of strings returned by the server in response to the + HELP command + """ + return self._longcmdstring('HELP',file) - return self.longcmd('HELP',file) - def statparse(self, resp): """Internal: parse the response of a STAT, NEXT or LAST command.""" if not resp.startswith(b'22'): @@ -407,9 +424,9 @@ def artcmd(self, line, file=None): """Internal: process a HEAD, BODY or ARTICLE command.""" - resp, list = self.longcmd(line, file) + resp, list = self._longcmdstring(line, file) resp, nr, id = self.statparse(resp) - return resp, nr, id, list + return ArticleInfo(resp, nr, id, list) def head(self, id): """Process a HEAD command. Argument: @@ -431,8 +448,8 @@ - nr: article number - id: message id - list: the lines of the article's body or an empty list - if file was used""" - + if file was used + """ return self.artcmd('BODY {0}'.format(id), file) def article(self, id): @@ -442,58 +459,59 @@ - resp: server response if successful - nr: article number - id: message id - - list: the lines of the article""" - + - list: the lines of the article + """ return self.artcmd('ARTICLE {0}'.format(id)) def slave(self): """Process a SLAVE command. Returns: - - resp: server response if successful""" - + - 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(b'^([0-9]+) ?(.*)\n?') - resp, lines = self.longcmd('XHDR {0} {1}'.format(hdr, str), file) - for i in range(len(lines)): - line = lines[i] + - 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) - if m: - lines[i] = m.group(1, 2) - return resp, lines + 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 (art-nr, subject, poster, date, - id, references, size, lines)""" - - resp, lines = self.longcmd('XOVER {0}-{1}'.format(start, end), file) + - list: list of XoverInfo named tuples, with elements (art-nr, subject, + poster, date, id, references, size, lines) + """ + resp, lines = self._longcmdstring('XOVER {0}-{1}'.format(start, end), + file) xover_lines = [] for line in lines: - elem = line.split(b'\t') + elem = line.split('\t') try: - xover_lines.append((elem[0], + xover_lines.append(XoverInfo(elem[0], - elem[1], - elem[2], - elem[3], - elem[4], - elem[5].split(), - elem[6], - elem[7])) + elem[1], + elem[2], + elem[3], + elem[4], + elem[5].split(), + elem[6], + elem[7])) except IndexError: raise NNTPDataError(line) - return resp,xover_lines + return resp, xover_lines def xgtitle(self, group, file=None): """Process an XGTITLE command (optional server extension) Arguments: @@ -501,9 +519,11 @@ Returns: - resp: server response if successful - list: list of (name,title) strings""" - + from warnings import warn + warn("The XGTITLE extension is not actively used, use the description()" + "function instead", PendingDeprecationWarning, 2) line_pat = re.compile(b'^([^ \t]+)[ \t]+(.*)$') - resp, raw_lines = self.longcmd('XGTITLE ' + group, file) + resp, raw_lines = self._longcmdstring('XGTITLE ' + group, file) lines = [] for raw_line in raw_lines: match = line_pat.search(raw_line.strip()) @@ -511,12 +531,16 @@ lines.append(match.group(1, 2)) return resp, lines - def xpath(self,id): + def xpath(self, id): """Process an XPATH command (optional server extension) Arguments: - id: Message id of article Returns: resp: server response if successful - path: directory path to article""" + path: directory path to article + """ + from warnings import warn + warn("The XPATH extension is not actively used", + PendingDeprecationWarning, 2) resp = self.shortcmd('XPATH {0}'.format(id)) if not resp.startswith(b'223'): @@ -528,14 +552,13 @@ else: return resp, path - def date (self): + def date(self): - """Process the DATE command. Arguments: - None + """Process the DATE command. Returns: resp: server response if successful date: Date suitable for newnews/newgroups commands etc. - time: Time suitable for newnews/newgroups commands etc.""" - + time: Time suitable for newnews/newgroups commands etc. + """ resp = self.shortcmd("DATE") if not resp.startswith(b'111'): raise NNTPReplyError(resp) @@ -546,7 +569,7 @@ time = elem[1][-6:] if len(date) != 6 or len(time) != 6: raise NNTPDataError(resp) - return resp, date, time + return resp, date.decode(self.encoding), time.decode(self.encoding) def _post(self, command, f): resp = self.shortcmd(command)