pax_global_header00006660000000000000000000000064145557670220014527gustar00rootroot0000000000000052 comment=829eceaca285e64a8579691577ab59ad4821b53d requests-file-2.0.0/000077500000000000000000000000001455576702200143165ustar00rootroot00000000000000requests-file-2.0.0/.github/000077500000000000000000000000001455576702200156565ustar00rootroot00000000000000requests-file-2.0.0/.github/workflows/000077500000000000000000000000001455576702200177135ustar00rootroot00000000000000requests-file-2.0.0/.github/workflows/test.yml000066400000000000000000000022231455576702200214140ustar00rootroot00000000000000name: CI on: [push, pull_request] jobs: pytest: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.x"] name: "pytest: Python ${{ matrix.python-version }}" steps: - uses: actions/checkout@v2 - name: Setup python uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} - name: Install build dependencies run: pip install --upgrade setuptools setuptools-scm wheel build - name: Install package run: pip install . - name: Install test dependencies run: pip install pytest pytest-cov - name: Test with pytest run: pytest --cov=. --cov-report=xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v1 with: file: ./coverage.xml fail_ci_if_error: false black: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Setup python uses: actions/setup-python@v1 with: python-version: "3.x" - name: Install black run: pip install black - name: Run black run: black --check . requests-file-2.0.0/.gitignore000066400000000000000000000000771455576702200163120ustar00rootroot00000000000000*.pyc __pycache__ build dist .eggs *.egg-info .*.swp .DS_Store requests-file-2.0.0/CHANGES.rst000066400000000000000000000030411455576702200161160ustar00rootroot000000000000002.0.0 (29 Jan 2024) =================== - Correct a typo in requests_file.py (github PR #21) - Remove dependency on six (github PR #23) - Move metadata to pyproject.toml (github PR #26) - Remove support for Python 2 - Remove support for raw distutils - Correct homepage link in pyproject.toml (github PR #28) - Fix black formatting (github PR #27) 1.5.1 (25 Apr 2020) =================== - Fix python 2.7 compatibility - Rename test file for pytest - Add tests via github actions - Format code with black 1.5.0 (23 Apr 2020) ================== - Add set_content_length flag to disable on demand setting Content-Length 1.4.3 (2 Jan 2018) ================== - Skip the permissions test when running as root - Handle missing locale in tests 1.4.2 (28 Apr 2017) =================== - Set the response URL to the request URL 1.4.1 (13 Oct 2016) =================== - Add a wheel distribution 1.4 (24 Aug 2015) ================= - Use getprerredencoding instead of nl_langinfo (github issue #1) - Handle files with a drive component (github issue #2) - Fix some issues with running the tests on Windows 1.3.1 (18 May 2015) ================== - Add python version classifiers to the package info 1.3 (18 May 2015) ================= - Fix a crash when closing a file response. - Use named aliases instead of integers for status codes. 1.2 (8 May 2015) ================= - Added support for HEAD requests 1.1 (12 Mar 2015) ================= - Added handling for % escapes in URLs - Proofread the README 1.0 (10 Mar 2015) ================= - Initial release requests-file-2.0.0/LICENSE000066400000000000000000000011051455576702200153200ustar00rootroot00000000000000Copyright 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. requests-file-2.0.0/MANIFEST.in000066400000000000000000000001171455576702200160530ustar00rootroot00000000000000include LICENSE include README.rst include requirements.txt include tests/*.py requests-file-2.0.0/README.rst000066400000000000000000000021561455576702200160110ustar00rootroot00000000000000Requests-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 requests-file-2.0.0/pyproject.toml000066400000000000000000000014641455576702200172370ustar00rootroot00000000000000[build-system] requires = ["setuptools>=61.2", "setuptools_scm[toml]>=3.4.3"] build-backend = "setuptools.build_meta" [project] name = "requests-file" authors = [{name = "David Shea", email = "reallylongword@gmail.com"}] license = {text = "Apache 2.0"} description = "File transport adapter for Requests" readme = "README.rst" classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Plugins", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", ] urls = {Homepage = "https://github.com/dashea/requests-file"} dependencies = ["requests>=1.0.0"] dynamic = ["version"] [tool.distutils.bdist_wheel] universal = 1 [tool.setuptools] py-modules = ["requests_file"] include-package-data = false [tool.setuptools_scm] requests-file-2.0.0/requests_file.py000066400000000000000000000112721455576702200175450ustar00rootroot00000000000000from 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 try: from io import BytesIO except ImportError: from StringIO import StringIO as BytesIO class FileAdapter(BaseAdapter): def __init__(self, set_content_length=True): super(FileAdapter, self).__init__() self._set_content_length = set_content_length 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 # really 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) if self._set_content_length: 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) and self._set_content_length: resp.headers["Content-Length"] = resp_stat.st_size return resp def close(self): pass requests-file-2.0.0/tests/000077500000000000000000000000001455576702200154605ustar00rootroot00000000000000requests-file-2.0.0/tests/test_requests_file.py000066400000000000000000000162631455576702200217530ustar00rootroot00000000000000import 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()