diff --git a/docs/news.rst b/docs/news.rst index d38fe34ef..da29ed58b 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -97,6 +97,7 @@ Additional New Features and Enhancements - Dict-like settings now have per-key priorities (:issue:`1135`, :issue:`1149` and :issue:`1586`). +- Sending non-ASCII emails (:issue:`1662`) - ``CloseSpider`` and ``SpiderState`` extensions now get disabled if no relevant setting is set (:issue:`1723`, :issue:`1725`). - Added method ``ExecutionEngine.close`` (:issue:`1423`). @@ -105,7 +106,7 @@ Additional New Features and Enhancements :issue:`1335`, :issue:`1683`, :issue:`1660`, :issue:`1642`, :issue:`1721`, :issue:`1727`). - Other refactoring, optimizations and cleanup (:issue:`1476`, :issue:`1481`, - :issue:`1477`, :issue:`1315` and :issue:`1290`). + :issue:`1477`, :issue:`1315`, :issue:`1290` and :issue:`1750`). .. _`Code of Conduct`: https://github.com/scrapy/scrapy/blob/master/CODE_OF_CONDUCT.md @@ -124,6 +125,9 @@ Deprecations and Removals + ``scrapy.utils.datatypes.MultiValueDict`` + ``scrapy.utils.datatypes.SiteNode`` +- The previously bundled ``scrapy.xlib.pydispatch`` library was deprecated and + replaced by `pydispatcher `_. + Relocations ~~~~~~~~~~~ diff --git a/docs/topics/email.rst b/docs/topics/email.rst index 789fbd4fb..96487d865 100644 --- a/docs/topics/email.rst +++ b/docs/topics/email.rst @@ -76,7 +76,7 @@ uses `Twisted non-blocking IO`_, like the rest of the framework. :param settings: the e-mail recipients :type settings: :class:`scrapy.settings.Settings` object - .. method:: send(to, subject, body, cc=None, attachs=(), mimetype='text/plain') + .. method:: send(to, subject, body, cc=None, attachs=(), mimetype='text/plain', charset=None) Send email to the given recipients. @@ -102,6 +102,9 @@ uses `Twisted non-blocking IO`_, like the rest of the framework. :param mimetype: the MIME type of the e-mail :type mimetype: str + :param charset: the character encoding to use for the e-mail contents + :type charset: str + .. _topics-email-settings: diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index ef4403106..3c4bc662c 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -177,12 +177,23 @@ class ExecutionEngine(object): return d def spider_is_idle(self, spider): - scraper_idle = self.scraper.slot.is_idle() - pending = self.slot.scheduler.has_pending_requests() - downloading = bool(self.downloader.active) - pending_start_requests = self.slot.start_requests is not None - idle = scraper_idle and not (pending or downloading or pending_start_requests) - return idle + if not self.scraper.slot.is_idle(): + # scraper is not idle + return False + + if self.downloader.active: + # downloader has pending requests + return False + + if self.slot.start_requests is not None: + # not all start requests are handled + return False + + if self.slot.scheduler.has_pending_requests(): + # scheduler has pending requests + return False + + return True @property def open_spiders(self): diff --git a/scrapy/http/cookies.py b/scrapy/http/cookies.py index e92c3fe73..a1e95102e 100644 --- a/scrapy/http/cookies.py +++ b/scrapy/http/cookies.py @@ -137,13 +137,29 @@ class WrappedRequest(object): """ return self.request.meta.get('is_unverifiable', False) - # python3 uses request.unverifiable + def get_origin_req_host(self): + return urlparse_cached(self.request).hostname + + # python3 uses attributes instead of methods + @property + def full_url(self): + return self.get_full_url() + + @property + def host(self): + return self.get_host() + + @property + def type(self): + return self.get_type() + @property def unverifiable(self): return self.is_unverifiable() - def get_origin_req_host(self): - return urlparse_cached(self.request).hostname + @property + def origin_req_host(self): + return self.get_origin_req_host() def has_header(self, name): return name in self.request.headers diff --git a/scrapy/mail.py b/scrapy/mail.py index ad8ecbe13..c6339f25b 100644 --- a/scrapy/mail.py +++ b/scrapy/mail.py @@ -43,7 +43,7 @@ class MailSender(object): settings['MAIL_PASS'], settings.getint('MAIL_PORT'), settings.getbool('MAIL_TLS'), settings.getbool('MAIL_SSL')) - def send(self, to, subject, body, cc=None, attachs=(), mimetype='text/plain', _callback=None): + def send(self, to, subject, body, cc=None, attachs=(), mimetype='text/plain', charset=None, _callback=None): if attachs: msg = MIMEMultipart() else: @@ -57,8 +57,11 @@ class MailSender(object): rcpts.extend(cc) msg['Cc'] = COMMASPACE.join(cc) + if charset: + msg.set_charset(charset) + if attachs: - msg.attach(MIMEText(body)) + msg.attach(MIMEText(body, 'plain', charset or 'us-ascii')) for attach_name, mimetype, f in attachs: part = MIMEBase(*mimetype.split('/')) part.set_payload(f.read()) diff --git a/scrapy/spiders/sitemap.py b/scrapy/spiders/sitemap.py index eede467a8..89d96c330 100644 --- a/scrapy/spiders/sitemap.py +++ b/scrapy/spiders/sitemap.py @@ -32,7 +32,7 @@ class SitemapSpider(Spider): def _parse_sitemap(self, response): if response.url.endswith('/robots.txt'): - for url in sitemap_urls_from_robots(response.body): + for url in sitemap_urls_from_robots(response.text): yield Request(url, callback=self._parse_sitemap) else: body = self._get_sitemap_body(response) diff --git a/scrapy/xlib/pydispatch.py b/scrapy/xlib/pydispatch.py new file mode 100644 index 000000000..5ffeaf579 --- /dev/null +++ b/scrapy/xlib/pydispatch.py @@ -0,0 +1,19 @@ +from __future__ import absolute_import + +import warnings +from scrapy.exceptions import ScrapyDeprecationWarning + +from pydispatch import ( + dispatcher, + errors, + robust, + robustapply, + saferef, +) + +warnings.warn("Importing from scrapy.xlib.pydispatch is deprecated and will" + " no longer be supported in future Scrapy versions." + " If you just want to connect signals use the from_crawler class method," + " otherwise import pydispatch directly if needed." + " See: https://github.com/scrapy/scrapy/issues/1762", + ScrapyDeprecationWarning, stacklevel=2) diff --git a/tests/test_http_cookies.py b/tests/test_http_cookies.py index d529f609b..549f779d8 100644 --- a/tests/test_http_cookies.py +++ b/tests/test_http_cookies.py @@ -14,12 +14,15 @@ class WrappedRequestTest(TestCase): def test_get_full_url(self): self.assertEqual(self.wrapped.get_full_url(), self.request.url) + self.assertEqual(self.wrapped.full_url, self.request.url) def test_get_host(self): self.assertEqual(self.wrapped.get_host(), urlparse(self.request.url).netloc) + self.assertEqual(self.wrapped.host, urlparse(self.request.url).netloc) def test_get_type(self): self.assertEqual(self.wrapped.get_type(), urlparse(self.request.url).scheme) + self.assertEqual(self.wrapped.type, urlparse(self.request.url).scheme) def test_is_unverifiable(self): self.assertFalse(self.wrapped.is_unverifiable()) @@ -32,6 +35,7 @@ class WrappedRequestTest(TestCase): def test_get_origin_req_host(self): self.assertEqual(self.wrapped.get_origin_req_host(), 'www.example.com') + self.assertEqual(self.wrapped.origin_req_host, 'www.example.com') def test_has_header(self): self.assertTrue(self.wrapped.has_header('content-type')) diff --git a/tests/test_mail.py b/tests/test_mail.py index 25dd35099..bd7e49621 100644 --- a/tests/test_mail.py +++ b/tests/test_mail.py @@ -1,5 +1,8 @@ +# coding=utf-8 + import unittest from io import BytesIO +from email.charset import Charset from scrapy.mail import MailSender @@ -54,11 +57,58 @@ class MailSenderTest(unittest.TestCase): text, attach = payload self.assertEqual(text.get_payload(decode=True), b'body') + self.assertEqual(text.get_charset(), Charset('us-ascii')) self.assertEqual(attach.get_payload(decode=True), b'content') def _catch_mail_sent(self, **kwargs): self.catched_msg = dict(**kwargs) + def test_send_utf8(self): + subject = u'sübjèçt' + body = u'bödÿ-àéïöñß' + mailsender = MailSender(debug=True) + mailsender.send(to=['test@scrapy.org'], subject=subject, body=body, + charset='utf-8', _callback=self._catch_mail_sent) + + assert self.catched_msg + self.assertEqual(self.catched_msg['subject'], subject) + self.assertEqual(self.catched_msg['body'], body) + + msg = self.catched_msg['msg'] + self.assertEqual(msg['subject'], subject) + self.assertEqual(msg.get_payload(), body) + self.assertEqual(msg.get_charset(), Charset('utf-8')) + self.assertEqual(msg.get('Content-Type'), 'text/plain; charset="utf-8"') + + def test_send_attach_utf8(self): + subject = u'sübjèçt' + body = u'bödÿ-àéïöñß' + attach = BytesIO() + attach.write(body.encode('utf-8')) + attach.seek(0) + attachs = [('attachment', 'text/plain', attach)] + + mailsender = MailSender(debug=True) + mailsender.send(to=['test@scrapy.org'], subject=subject, body=body, + attachs=attachs, charset='utf-8', _callback=self._catch_mail_sent) + + assert self.catched_msg + self.assertEqual(self.catched_msg['subject'], subject) + self.assertEqual(self.catched_msg['body'], body) + + msg = self.catched_msg['msg'] + self.assertEqual(msg['subject'], subject) + self.assertEqual(msg.get_charset(), Charset('utf-8')) + self.assertEqual(msg.get('Content-Type'), 'multipart/mixed; charset="utf-8"') + + payload = msg.get_payload() + assert isinstance(payload, list) + self.assertEqual(len(payload), 2) + + text, attach = payload + self.assertEqual(text.get_payload(decode=True).decode('utf-8'), body) + self.assertEqual(text.get_charset(), Charset('utf-8')) + self.assertEqual(attach.get_payload(decode=True).decode('utf-8'), body) if __name__ == "__main__": unittest.main() diff --git a/tests/test_pydispatch_deprecated.py b/tests/test_pydispatch_deprecated.py new file mode 100644 index 000000000..6d3237fe1 --- /dev/null +++ b/tests/test_pydispatch_deprecated.py @@ -0,0 +1,12 @@ +import unittest +import warnings +from six.moves import reload_module + + +class DeprecatedPydispatchTest(unittest.TestCase): + def test_import_xlib_pydispatch_show_warning(self): + with warnings.catch_warnings(record=True) as w: + from scrapy.xlib import pydispatch + reload_module(pydispatch) + self.assertIn('Importing from scrapy.xlib.pydispatch is deprecated', + str(w[0].message)) diff --git a/tests/test_spider.py b/tests/test_spider.py index 4d5d4b07e..1d22c1212 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -328,6 +328,18 @@ class SitemapSpiderTest(SpiderTest): r = Response(url="http://www.example.com/sitemap.xml.gz", body=self.GZBODY) self.assertSitemapBody(r, self.BODY) + def test_get_sitemap_urls_from_robotstxt(self): + robots = b"""# Sitemap files +Sitemap: http://example.com/sitemap.xml +Sitemap: http://example.com/sitemap-product-index.xml +""" + + r = TextResponse(url="http://www.example.com/robots.txt", body=robots) + spider = self.spider_class("example.com") + self.assertEqual([req.url for req in spider._parse_sitemap(r)], + ['http://example.com/sitemap.xml', + 'http://example.com/sitemap-product-index.xml']) + class BaseSpiderDeprecationTest(unittest.TestCase):