Skip to content

Commit d375c4d

Browse files
authoredJan 24, 2025··
Merge pull request #212 from urschrei/push-lqntrxsuzwkn
Use internal transport adapter for file:// URIs
2 parents 1f5eacf + b1afc63 commit d375c4d

File tree

5 files changed

+191
-27
lines changed

5 files changed

+191
-27
lines changed
 

‎pyproject.toml

+4-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ dependencies = [
88
"pytz",
99
"bibtexparser",
1010
"httpx>=0.28.1",
11-
"httpx-file>=0.2.0",
1211
]
1312
authors = [{ name = "Stephan Hügel", email = "urschrei@gmail.com" }]
1413
license = {file = "LICENSE.md"}
@@ -37,7 +36,8 @@ test = [
3736
"pytest >= 7.4.2",
3837
"httpretty",
3938
"python-dateutil",
40-
"ipython"
39+
"ipython",
40+
"pytest-asyncio",
4141
]
4242

4343
[tool.setuptools.dynamic]
@@ -58,6 +58,8 @@ addopts = [
5858
testpaths = [
5959
"tests",
6060
]
61+
asyncio_mode = "strict"
62+
asyncio_default_fixture_loop_scope = "function"
6163

6264
[tool.setuptools_scm]
6365
write_to = "src/_version.py"

‎src/pyzotero/filetransport.py

+151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# This is a modified version of httpx_file:
2+
# The aiofiles dependency has been removed by modifying the async functionality to use
3+
# asyncio instead. A specific test for this modification can be found in tests/test_async.py
4+
# https://github.com/nuno-andre/httpx-file
5+
6+
7+
# The license and copyright notice are reproduced below
8+
# Copyright (c) 2021, Nuno André Novo
9+
# All rights reserved.
10+
11+
# Redistribution and use in source and binary forms, with or without
12+
# modification, are permitted provided that the following conditions are met:
13+
# * Redistributions of source code must retain the above copyright notice, this
14+
# list of conditions and the following disclaimer.
15+
# * Redistributions in binary form must reproduce the above copyright notice,
16+
# this list of conditions and the following disclaimer in the documentation
17+
# and/or other materials provided with the distribution.
18+
# * Neither the name of the <copyright holder> nor the names of its contributors
19+
# may be used to endorse or promote products derived from this software without
20+
# specific prior written permission.
21+
22+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
23+
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
24+
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
25+
# DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY DIRECT,
26+
# INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
27+
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
28+
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
29+
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
30+
# OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
31+
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
32+
33+
import asyncio
34+
from pathlib import Path
35+
from typing import Optional, Tuple
36+
37+
import httpx
38+
from httpx import (
39+
AsyncBaseTransport,
40+
BaseTransport,
41+
ByteStream,
42+
Request,
43+
Response,
44+
)
45+
from httpx import (
46+
AsyncClient as _AsyncClient,
47+
)
48+
from httpx import (
49+
Client as _Client,
50+
)
51+
from httpx._utils import URLPattern
52+
53+
54+
# monkey patch to fix httpx URL parsing
55+
def is_relative_url(self):
56+
return not (self._uri_reference.scheme or self._uri_reference.host)
57+
58+
59+
def is_absolute_url(self):
60+
return not self.is_relative_url
61+
62+
63+
httpx.URL.is_relative_url = property(is_relative_url) # type: ignore
64+
httpx.URL.is_absolute_url = property(is_absolute_url) # type: ignore
65+
66+
67+
class FileTransport(AsyncBaseTransport, BaseTransport):
68+
def _handle(self, request: Request) -> Tuple[Optional[int], httpx.Headers]:
69+
if request.url.host and request.url.host != "localhost":
70+
raise NotImplementedError("Only local paths are allowed")
71+
if request.method in {"PUT", "DELETE"}:
72+
status = 501 # Not Implemented
73+
elif request.method not in {"GET", "HEAD"}:
74+
status = 405 # Method Not Allowed
75+
else:
76+
status = None
77+
return status, request.headers
78+
79+
def handle_request(self, request: Request) -> Response:
80+
status, headers = self._handle(request)
81+
stream = None
82+
if not status:
83+
parts = request.url.path.split("/")
84+
if parts[1].endswith((":", "|")):
85+
parts[1] = parts[1][:-1] + ":"
86+
parts.pop(0)
87+
ospath = Path("/".join(parts))
88+
try:
89+
content = ospath.read_bytes()
90+
status = 200
91+
except FileNotFoundError:
92+
status = 404
93+
except PermissionError:
94+
status = 403
95+
else:
96+
stream = ByteStream(content)
97+
headers["Content-Length"] = str(len(content))
98+
return Response(
99+
status_code=status,
100+
headers=headers,
101+
stream=stream,
102+
extensions=dict(),
103+
)
104+
105+
async def handle_async_request(self, request: Request) -> Response:
106+
status, headers = self._handle(request)
107+
stream = None
108+
if not status:
109+
parts = request.url.path.split("/")
110+
if parts[1].endswith((":", "|")):
111+
parts[1] = parts[1][:-1] + ":"
112+
parts.pop(0)
113+
ospath = Path("/".join(parts))
114+
try:
115+
loop = asyncio.get_event_loop()
116+
content = await loop.run_in_executor(None, ospath.read_bytes)
117+
status = 200
118+
except FileNotFoundError:
119+
status = 404
120+
except PermissionError:
121+
status = 403
122+
else:
123+
stream = ByteStream(content)
124+
headers["Content-Length"] = str(len(content))
125+
return Response(
126+
status_code=status,
127+
headers=headers,
128+
stream=stream,
129+
extensions=dict(),
130+
)
131+
132+
133+
class Client(_Client):
134+
def __init__(self, **kwargs) -> None:
135+
super().__init__(**kwargs)
136+
self.mount("file://", FileTransport())
137+
138+
def mount(self, protocol: str, transport: BaseTransport) -> None:
139+
self._mounts.update({URLPattern(protocol): transport})
140+
141+
142+
class AsyncClient(_AsyncClient):
143+
def __init__(self, **kwargs) -> None:
144+
super().__init__(**kwargs)
145+
self.mount("file://", FileTransport())
146+
147+
def mount(self, protocol: str, transport: AsyncBaseTransport) -> None:
148+
self._mounts.update({URLPattern(protocol): transport})
149+
150+
151+
__all__ = ["FileTransport", "AsyncClient", "Client"]

‎src/pyzotero/zotero.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,11 @@
4040
import httpx
4141
import pytz
4242
from httpx import Request
43-
from httpx_file import Client as File_Client
4443

4544
import pyzotero as pz
4645

4746
from . import zotero_errors as ze
47+
from .filetransport import Client as File_Client
4848

4949
# Avoid hanging the application if there's no server response
5050
timeout = 30

‎tests/test_async.py

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
from pyzotero.filetransport import AsyncClient
6+
7+
8+
@pytest.mark.asyncio
9+
async def test_file_transport():
10+
test_file = Path("test.txt")
11+
test_file.write_text("test content")
12+
13+
client = AsyncClient()
14+
try:
15+
async with client:
16+
resp = await client.get(f"file:///{test_file.absolute()}")
17+
content = await resp.aread()
18+
assert resp.status_code == 200
19+
assert content == b"test content"
20+
finally:
21+
test_file.unlink()

‎uv.lock

+14-24
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.