diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2814b39 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2015 Red Hat, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..fd93776 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include LICENSE +include README.rst +include requirements.txt +include tests/*.py diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..bb59b8e --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,17 @@ +Metadata-Version: 1.1 +Name: requests-file +Version: 1.4.3 +Summary: File transport adapter for Requests +Home-page: http://github.com/dashea/requests-file +Author: David Shea +Author-email: dshea@redhat.com +License: Apache 2.0 +Description-Content-Type: UNKNOWN +Description: UNKNOWN +Platform: UNKNOWN +Classifier: Development Status :: 3 - Alpha +Classifier: Environment :: Plugins +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: Apache Software License +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3.4 diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..91b62fe --- /dev/null +++ b/README.rst @@ -0,0 +1,45 @@ +Requests-File +============= + +Requests-File is a transport adapter for use with the `Requests`_ Python +library to allow local filesystem access via file:\/\/ URLs. + +To use: + +.. code-block:: python + + import requests + from requests_file import FileAdapter + + s = requests.Session() + s.mount('file://', FileAdapter()) + + resp = s.get('file:///path/to/file') + +Features +-------- + +- Will open and read local files +- Might set a Content-Length header +- That's about it + +No encoding information is set in the response object, so be careful using +Response.text: the chardet library will be used to convert the file to a +unicode type and it may not detect what you actually want. + +EACCES is converted to a 403 status code, and ENOENT is converted to a +404. All other IOError types are converted to a 400. + +Contributing +------------ + +Contributions welcome! Feel free to open a pull request against +https://github.com/dashea/requests-file + +License +------- + +To maximise compatibility with Requests, this code is licensed under the Apache +license. See LICENSE for more details. + +.. _`Requests`: https://github.com/kennethreitz/requests diff --git a/requests_file.egg-info/PKG-INFO b/requests_file.egg-info/PKG-INFO new file mode 100644 index 0000000..bb59b8e --- /dev/null +++ b/requests_file.egg-info/PKG-INFO @@ -0,0 +1,17 @@ +Metadata-Version: 1.1 +Name: requests-file +Version: 1.4.3 +Summary: File transport adapter for Requests +Home-page: http://github.com/dashea/requests-file +Author: David Shea +Author-email: dshea@redhat.com +License: Apache 2.0 +Description-Content-Type: UNKNOWN +Description: UNKNOWN +Platform: UNKNOWN +Classifier: Development Status :: 3 - Alpha +Classifier: Environment :: Plugins +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: Apache Software License +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3.4 diff --git a/requests_file.egg-info/SOURCES.txt b/requests_file.egg-info/SOURCES.txt new file mode 100644 index 0000000..220f793 --- /dev/null +++ b/requests_file.egg-info/SOURCES.txt @@ -0,0 +1,13 @@ +LICENSE +MANIFEST.in +README.rst +requests_file.py +requirements.txt +setup.cfg +setup.py +requests_file.egg-info/PKG-INFO +requests_file.egg-info/SOURCES.txt +requests_file.egg-info/dependency_links.txt +requests_file.egg-info/requires.txt +requests_file.egg-info/top_level.txt +tests/__init__.py \ No newline at end of file diff --git a/requests_file.egg-info/dependency_links.txt b/requests_file.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/requests_file.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/requests_file.egg-info/requires.txt b/requests_file.egg-info/requires.txt new file mode 100644 index 0000000..0f255c9 --- /dev/null +++ b/requests_file.egg-info/requires.txt @@ -0,0 +1,2 @@ +requests>=1.0.0 +six diff --git a/requests_file.egg-info/top_level.txt b/requests_file.egg-info/top_level.txt new file mode 100644 index 0000000..929f0ab --- /dev/null +++ b/requests_file.egg-info/top_level.txt @@ -0,0 +1 @@ +requests_file diff --git a/requests_file.py b/requests_file.py new file mode 100644 index 0000000..283b845 --- /dev/null +++ b/requests_file.py @@ -0,0 +1,110 @@ +from requests.adapters import BaseAdapter +from requests.compat import urlparse, unquote +from requests import Response, codes +import errno +import os +import stat +import locale +import io + +from six import BytesIO + +class FileAdapter(BaseAdapter): + def send(self, request, **kwargs): + """ Wraps a file, described in request, in a Response object. + + :param request: The PreparedRequest` being "sent". + :returns: a Response object containing the file + """ + + # Check that the method makes sense. Only support GET + if request.method not in ("GET", "HEAD"): + raise ValueError("Invalid request method %s" % request.method) + + # Parse the URL + url_parts = urlparse(request.url) + + # Reject URLs with a hostname component + if url_parts.netloc and url_parts.netloc != "localhost": + raise ValueError("file: URLs with hostname components are not permitted") + + resp = Response() + + # Open the file, translate certain errors into HTTP responses + # Use urllib's unquote to translate percent escapes into whatever + # they actually need to be + try: + # Split the path on / (the URL directory separator) and decode any + # % escapes in the parts + path_parts = [unquote(p) for p in url_parts.path.split('/')] + + # Strip out the leading empty parts created from the leading /'s + while path_parts and not path_parts[0]: + path_parts.pop(0) + + # If os.sep is in any of the parts, someone fed us some shenanigans. + # Treat is like a missing file. + if any(os.sep in p for p in path_parts): + raise IOError(errno.ENOENT, os.strerror(errno.ENOENT)) + + # Look for a drive component. If one is present, store it separately + # so that a directory separator can correctly be added to the real + # path, and remove any empty path parts between the drive and the path. + # Assume that a part ending with : or | (legacy) is a drive. + if path_parts and (path_parts[0].endswith('|') or + path_parts[0].endswith(':')): + path_drive = path_parts.pop(0) + if path_drive.endswith('|'): + path_drive = path_drive[:-1] + ':' + + while path_parts and not path_parts[0]: + path_parts.pop(0) + else: + path_drive = '' + + # Try to put the path back together + # Join the drive back in, and stick os.sep in front of the path to + # make it absolute. + path = path_drive + os.sep + os.path.join(*path_parts) + + # Check if the drive assumptions above were correct. If path_drive + # is set, and os.path.splitdrive does not return a drive, it wasn't + # reall a drive. Put the path together again treating path_drive + # as a normal path component. + if path_drive and not os.path.splitdrive(path): + path = os.sep + os.path.join(path_drive, *path_parts) + + # Use io.open since we need to add a release_conn method, and + # methods can't be added to file objects in python 2. + resp.raw = io.open(path, "rb") + resp.raw.release_conn = resp.raw.close + except IOError as e: + if e.errno == errno.EACCES: + resp.status_code = codes.forbidden + elif e.errno == errno.ENOENT: + resp.status_code = codes.not_found + else: + resp.status_code = codes.bad_request + + # Wrap the error message in a file-like object + # The error message will be localized, try to convert the string + # representation of the exception into a byte stream + resp_str = str(e).encode(locale.getpreferredencoding(False)) + resp.raw = BytesIO(resp_str) + resp.headers['Content-Length'] = len(resp_str) + + # Add release_conn to the BytesIO object + resp.raw.release_conn = resp.raw.close + else: + resp.status_code = codes.ok + resp.url = request.url + + # If it's a regular file, set the Content-Length + resp_stat = os.fstat(resp.raw.fileno()) + if stat.S_ISREG(resp_stat.st_mode): + resp.headers['Content-Length'] = resp_stat.st_size + + return resp + + def close(self): + pass diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0f255c9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests>=1.0.0 +six diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..adf5ed7 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,7 @@ +[bdist_wheel] +universal = 1 + +[egg_info] +tag_build = +tag_date = 0 + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..adbdec0 --- /dev/null +++ b/setup.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python + +import os + +try: + from setuptools import setup +except ImportError: + from distutils.core import setup + +requires = [line.strip() for line in open("requirements.txt").readlines()] + +setup( + name='requests-file', + version='1.4.3', + description='File transport adapter for Requests', + author='David Shea', + author_email='dshea@redhat.com', + url='http://github.com/dashea/requests-file', + py_modules=['requests_file'], + install_requires=requires, + license='Apache 2.0', + test_suite='tests', + classifiers=['Development Status :: 3 - Alpha', + 'Environment :: Plugins', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.4' + ] + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..6369a00 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,168 @@ +import unittest +import requests +from requests_file import FileAdapter + +import os, stat +import tempfile +import shutil +import platform + +class FileRequestTestCase(unittest.TestCase): + def setUp(self): + self._session = requests.Session() + self._session.mount("file://", FileAdapter()) + + def _pathToURL(self, path): + """Convert a filesystem path to a URL path""" + urldrive, urlpath = os.path.splitdrive(path) + + # Split the path on the os spearator and recombine it with / as the + # separator. There probably aren't any OS's that allow / as a path + # component, but just in case, encode any remaining /'s. + urlsplit = (part.replace('/', '%2F') for part in urlpath.split(os.sep)) + urlpath = "/".join(urlsplit) + + # Encode /'s in the drive for the imaginary case where that can be a thing + urldrive = urldrive.replace('/', '%2F') + + # Add the leading /. If there is a drive component, this needs to be + # placed before the drive. + urldrive = "/" + urldrive + + return urldrive + urlpath + + def test_fetch_regular(self): + # Fetch this file using requests + with open(__file__, "rb") as f: + testdata = f.read() + response = self._session.get("file://%s" % self._pathToURL(os.path.abspath(__file__))) + + self.assertEqual(response.status_code, requests.codes.ok) + self.assertEqual(response.headers['Content-Length'], len(testdata)) + self.assertEqual(response.content, testdata) + + response.close() + + def test_fetch_missing(self): + # Fetch a file that (hopefully) doesn't exist, look for a 404 + response = self._session.get("file:///no/such/path") + self.assertEqual(response.status_code, requests.codes.not_found) + self.assertTrue(response.text) + response.close() + + @unittest.skipIf(hasattr(os, "geteuid") and os.geteuid() == 0, + "Skipping permissions test since running as root") + def test_fetch_no_access(self): + # Create a file and remove read permissions, try to get a 403 + # probably doesn't work on windows + with tempfile.NamedTemporaryFile() as tmp: + os.chmod(tmp.name, 0) + response = self._session.get("file://%s" % self._pathToURL(os.path.abspath(tmp.name))) + + self.assertEqual(response.status_code, requests.codes.forbidden) + self.assertTrue(response.text) + + response.close() + + @unittest.skipIf(platform.system() == "Windows", + "skipping locale test on windows") + def test_fetch_missing_localized(self): + # Make sure translated error messages don't cause any problems + import locale + + saved_locale = locale.setlocale(locale.LC_MESSAGES, None) + try: + locale.setlocale(locale.LC_MESSAGES, 'ru_RU.UTF-8') + response = self._session.get("file:///no/such/path") + self.assertEqual(response.status_code, requests.codes.not_found) + self.assertTrue(response.text) + response.close() + except locale.Error: + unittest.SkipTest('ru_RU.UTF-8 locale not available') + finally: + locale.setlocale(locale.LC_MESSAGES, saved_locale) + + def test_head(self): + # Check that HEAD returns the content-length + testlen = os.stat(__file__).st_size + response = self._session.head("file://%s" % self._pathToURL(os.path.abspath(__file__))) + + self.assertEqual(response.status_code, requests.codes.ok) + self.assertEqual(response.headers['Content-Length'], testlen) + + response.close() + + def test_fetch_post(self): + # Make sure that non-GET methods are rejected + self.assertRaises(ValueError, self._session.post, + ("file://%s" % self._pathToURL(os.path.abspath(__file__)))) + + def test_fetch_nonlocal(self): + # Make sure that network locations are rejected + self.assertRaises(ValueError, self._session.get, + ("file://example.com%s" % self._pathToURL(os.path.abspath(__file__)))) + self.assertRaises(ValueError, self._session.get, + ("file://localhost:8080%s" % self._pathToURL(os.path.abspath(__file__)))) + + # localhost is ok, though + with open(__file__, "rb") as f: + testdata = f.read() + response = self._session.get("file://localhost%s" % self._pathToURL(os.path.abspath(__file__))) + self.assertEqual(response.status_code, requests.codes.ok) + self.assertEqual(response.content, testdata) + response.close() + + def test_funny_names(self): + testdata = 'yo wassup man\n'.encode('ascii') + tmpdir = tempfile.mkdtemp() + + try: + with open(os.path.join(tmpdir, 'spa ces'), "w+b") as space_file: + space_file.write(testdata) + space_file.flush() + response = self._session.get("file://%s/spa%%20ces" % self._pathToURL(tmpdir)) + self.assertEqual(response.status_code, requests.codes.ok) + self.assertEqual(response.content, testdata) + response.close() + + with open(os.path.join(tmpdir, 'per%cent'), "w+b") as percent_file: + percent_file.write(testdata) + percent_file.flush() + response = self._session.get("file://%s/per%%25cent" % self._pathToURL(tmpdir)) + self.assertEqual(response.status_code, requests.codes.ok) + self.assertEqual(response.content, testdata) + response.close() + + # percent-encoded directory separators should be rejected + with open(os.path.join(tmpdir, 'badname'), "w+b") as bad_file: + response = self._session.get("file://%s%%%Xbadname" % (self._pathToURL(tmpdir), ord(os.sep))) + self.assertEqual(response.status_code, requests.codes.not_found) + response.close() + + finally: + shutil.rmtree(tmpdir) + + def test_close(self): + # Open a request for this file + response = self._session.get("file://%s" % self._pathToURL(os.path.abspath(__file__))) + + # Try closing it + response.close() + + def test_missing_close(self): + # Make sure non-200 responses can be closed + response = self._session.get("file:///no/such/path") + response.close() + + @unittest.skipIf(platform.system() != "Windows", "skipping windows URL test") + def test_windows_legacy(self): + """Test |-encoded drive characters on Windows""" + with open(__file__, "rb") as f: + testdata = f.read() + + drive, path = os.path.splitdrive(os.path.abspath(__file__)) + response = self._session.get("file:///%s|%s" % (drive[:-1], path.replace(os.sep, "/"))) + self.assertEqual(response.status_code, requests.codes.ok) + self.assertEqual(response.headers['Content-Length'], len(testdata)) + self.assertEqual(response.content, testdata) + response.close()