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