nntplib.py 42 KB

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