Skip to content
This repository was archived by the owner on Apr 11, 2025. It is now read-only.

Commit b605099

Browse files
cscanlin-kwhbosd
authored andcommitted
add support for file_bytes argument with managed_file_context()
1 parent 567520b commit b605099

File tree

4 files changed

+148
-73
lines changed

4 files changed

+148
-73
lines changed

camelot/handlers.py

+93-50
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from contextlib import contextmanager
2+
import io
13
import os
24
import sys
35
from pathlib import Path
@@ -11,7 +13,8 @@
1113
from .parsers import Lattice
1214
from .parsers import Stream
1315
from .utils import TemporaryDirectory
14-
from .utils import download_url
16+
from .utils import InvalidArguments
17+
from .utils import get_url_bytes
1518
from .utils import get_page_layout
1619
from .utils import get_rotation
1720
from .utils import get_text_objects
@@ -25,21 +28,36 @@ class PDFHandler:
2528
2629
Parameters
2730
----------
28-
filepath : str
29-
Filepath or URL of the PDF file.
31+
filepath : str | pathlib.Path, optional (default: None)
32+
Filepath or URL of the PDF file. Required if file_bytes is not given
3033
pages : str, optional (default: '1')
3134
Comma-separated page numbers.
3235
Example: '1,3,4' or '1,4-end' or 'all'.
3336
password : str, optional (default: None)
3437
Password for decryption.
38+
file_bytes : io.IOBase, optional (default: None)
39+
A file-like stream. Required if filepath is not given
3540
3641
"""
3742

38-
def __init__(self, filepath: Union[StrByteType, Path], pages="1", password=None):
43+
def __init__(self, filepath: Union[StrByteType, Path, None], pages="1", password=None, file_bytes=None):
3944
if is_url(filepath):
40-
filepath = download_url(filepath)
45+
file_bytes = get_url_bytes(filepath)
46+
47+
if not filepath and not file_bytes:
48+
raise InvalidArguments('Either `filepath` or `file_bytes` is required')
49+
if not filepath:
50+
# filepath must either be passed, or taken from the name attribute
51+
filepath = getattr(file_bytes, 'name')
52+
if not filepath:
53+
msg = ('Either pass a `filepath`, or give the '
54+
'`file_bytes` argument a name attribute')
55+
raise InvalidArguments(msg)
56+
self.file_bytes = file_bytes # ok to be None
57+
58+
# self.filepath = filepath
59+
# or
4160
self.filepath: Union[StrByteType, Path] = filepath
42-
4361
if isinstance(filepath, str) and not filepath.lower().endswith(".pdf"):
4462
raise NotImplementedError("File format not supported")
4563

@@ -51,6 +69,28 @@ def __init__(self, filepath: Union[StrByteType, Path], pages="1", password=None)
5169
self.password = self.password.encode("ascii")
5270
self.pages = self._get_pages(pages)
5371

72+
@contextmanager
73+
def managed_file_context(self):
74+
"""Reads from either the `filepath` or `file_bytes`
75+
attribute of this instance, to return a file-like object.
76+
Closes any open file handles on exit or error.
77+
78+
Returns
79+
-------
80+
file_bytes : io.IOBase
81+
A readable, seekable, file-like object
82+
"""
83+
if self.file_bytes:
84+
# if we can't seek, write to a BytesIO object that can,
85+
# then seek to the beginning before yielding
86+
if not hasattr(self.file_bytes, 'seek'):
87+
self.file_bytes = io.BytesIO(self.file_bytes.read())
88+
self.file_bytes.seek(0)
89+
yield self.file_bytes
90+
else:
91+
with open(self.filepath, "rb") as file_bytes:
92+
yield file_bytes
93+
5494
def _get_pages(self, pages):
5595
"""Converts pages string to list of ints.
5696
@@ -73,29 +113,30 @@ def _get_pages(self, pages):
73113
if pages == "1":
74114
page_numbers.append({"start": 1, "end": 1})
75115
else:
76-
infile = PdfReader(self.filepath, strict=False)
77-
78-
if infile.is_encrypted:
79-
infile.decrypt(self.password)
80-
81-
if pages == "all":
82-
page_numbers.append({"start": 1, "end": len(infile.pages)})
83-
else:
84-
for r in pages.split(","):
85-
if "-" in r:
86-
a, b = r.split("-")
87-
if b == "end":
88-
b = len(infile.pages)
89-
page_numbers.append({"start": int(a), "end": int(b)})
90-
else:
91-
page_numbers.append({"start": int(r), "end": int(r)})
116+
with self.managed_file_context() as f:
117+
infile = PdfReader(f, strict=False)
118+
119+
if infile.is_encrypted:
120+
infile.decrypt(self.password)
121+
122+
if pages == "all":
123+
page_numbers.append({"start": 1, "end": len(infile.pages)})
124+
else:
125+
for r in pages.split(","):
126+
if "-" in r:
127+
a, b = r.split("-")
128+
if b == "end":
129+
b = len(infile.pages)
130+
page_numbers.append({"start": int(a), "end": int(b)})
131+
else:
132+
page_numbers.append({"start": int(r), "end": int(r)})
92133

93134
result = []
94135
for p in page_numbers:
95136
result.extend(range(p["start"], p["end"] + 1))
96137
return sorted(set(result))
97138

98-
def _save_page(self, filepath: Union[StrByteType, Path], page, temp):
139+
def _save_page(self, filepath: Union[StrByteType, Path, None], page, temp):
99140
"""Saves specified page from PDF into a temporary directory.
100141
101142
Parameters
@@ -108,39 +149,41 @@ def _save_page(self, filepath: Union[StrByteType, Path], page, temp):
108149
Tmp directory.
109150
110151
"""
111-
infile = PdfReader(filepath, strict=False)
112-
if infile.is_encrypted:
113-
infile.decrypt(self.password)
114-
fpath = os.path.join(temp, f"page-{page}.pdf")
115-
froot, fext = os.path.splitext(fpath)
116-
p = infile.pages[page - 1]
117-
outfile = PdfWriter()
118-
outfile.add_page(p)
119-
with open(fpath, "wb") as f:
120-
outfile.write(f)
121-
layout, dim = get_page_layout(fpath)
122-
# fix rotated PDF
123-
chars = get_text_objects(layout, ltype="char")
124-
horizontal_text = get_text_objects(layout, ltype="horizontal_text")
125-
vertical_text = get_text_objects(layout, ltype="vertical_text")
126-
rotation = get_rotation(chars, horizontal_text, vertical_text)
127-
if rotation != "":
128-
fpath_new = "".join([froot.replace("page", "p"), "_rotated", fext])
129-
os.rename(fpath, fpath_new)
130-
instream = open(fpath_new, "rb")
131-
infile = PdfReader(instream, strict=False)
152+
153+
with self.managed_file_context() as fileobj:
154+
infile = PdfReader(fileobj, strict=False)
132155
if infile.is_encrypted:
133156
infile.decrypt(self.password)
157+
fpath = os.path.join(temp, f"page-{page}.pdf")
158+
froot, fext = os.path.splitext(fpath)
159+
p = infile.pages[page - 1]
134160
outfile = PdfWriter()
135-
p = infile.pages[0]
136-
if rotation == "anticlockwise":
137-
p.rotate(90)
138-
elif rotation == "clockwise":
139-
p.rotate(-90)
140161
outfile.add_page(p)
141162
with open(fpath, "wb") as f:
142163
outfile.write(f)
143-
instream.close()
164+
layout, dim = get_page_layout(fpath)
165+
# fix rotated PDF
166+
chars = get_text_objects(layout, ltype="char")
167+
horizontal_text = get_text_objects(layout, ltype="horizontal_text")
168+
vertical_text = get_text_objects(layout, ltype="vertical_text")
169+
rotation = get_rotation(chars, horizontal_text, vertical_text)
170+
if rotation != "":
171+
fpath_new = "".join([froot.replace("page", "p"), "_rotated", fext])
172+
os.rename(fpath, fpath_new)
173+
instream = open(fpath_new, "rb")
174+
infile = PdfReader(instream, strict=False)
175+
if infile.is_encrypted:
176+
infile.decrypt(self.password)
177+
outfile = PdfWriter()
178+
p = infile.pages[0]
179+
if rotation == "anticlockwise":
180+
p.rotate(90)
181+
elif rotation == "clockwise":
182+
p.rotate(-90)
183+
outfile.add_page(p)
184+
with open(fpath, "wb") as f:
185+
outfile.write(f)
186+
instream.close()
144187

145188
def parse(
146189
self, flavor="lattice", suppress_stdout=False, layout_kwargs=None, **kwargs

camelot/io.py

+16-6
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,22 @@
55
from pypdf._utils import StrByteType
66

77
from .handlers import PDFHandler
8-
from .utils import remove_extra
9-
from .utils import validate_input
8+
9+
from .utils import (
10+
InvalidArguments,
11+
validate_input,
12+
remove_extra,
13+
)
1014

1115

1216
def read_pdf(
13-
filepath: Union[StrByteType, Path],
17+
filepath=Union[StrByteType, Path],
1418
pages="1",
1519
password=None,
1620
flavor="lattice",
1721
suppress_stdout=False,
1822
layout_kwargs=None,
23+
file_bytes=None,
1924
**kwargs
2025
):
2126
"""Read PDF and return extracted tables.
@@ -25,8 +30,8 @@ def read_pdf(
2530
2631
Parameters
2732
----------
28-
filepath : str, Path, IO
29-
Filepath or URL of the PDF file.
33+
filepath : str | pathlib.Path, optional (default: None)
34+
Filepath or URL of the PDF file. Required if file_bytes is not given
3035
pages : str, optional (default: '1')
3136
Comma-separated page numbers.
3237
Example: '1,3,4' or '1,4-end' or 'all'.
@@ -37,6 +42,8 @@ def read_pdf(
3742
Lattice is used by default.
3843
suppress_stdout : bool, optional (default: True)
3944
Print all logs and warnings.
45+
file_bytes : io.IOBase, optional (default: None)
46+
A file-like stream. Required if filepath is not given
4047
layout_kwargs : dict, optional (default: {})
4148
A dict of `pdfminer.layout.LAParams
4249
<https://github.com/euske/pdfminer/blob/master/pdfminer/layout.py#L33>`_ kwargs.
@@ -112,12 +119,15 @@ def read_pdf(
112119
"Unknown flavor specified." " Use either 'lattice' or 'stream'"
113120
)
114121

122+
if not filepath and not file_bytes:
123+
raise InvalidArguments('Either `filepath` or `file_bytes` is required')
124+
115125
with warnings.catch_warnings():
116126
if suppress_stdout:
117127
warnings.simplefilter("ignore")
118128

119129
validate_input(kwargs, flavor=flavor)
120-
p = PDFHandler(filepath, pages=pages, password=password)
130+
p = PDFHandler(filepath, pages=pages, password=password, file_bytes=file_bytes)
121131
kwargs = remove_extra(kwargs, flavor=flavor)
122132
tables = p.parse(
123133
flavor=flavor,

camelot/utils.py

+20-17
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import os
1+
import io
22
import random
33
import re
44
import shutil
@@ -34,6 +34,10 @@
3434
_VALID_URLS.discard("")
3535

3636

37+
class InvalidArguments(Exception):
38+
pass
39+
40+
3741
# https://github.com/pandas-dev/pandas/blob/master/pandas/io/common.py
3842
def is_url(url):
3943
"""Check to see if a URL has a valid protocol.
@@ -64,31 +68,30 @@ def random_string(length):
6468
return ret
6569

6670

67-
def download_url(url):
68-
"""Download file from specified URL.
71+
def get_url_bytes(url):
72+
"""Get a stream of bytes for url
6973
7074
Parameters
7175
----------
7276
url : str or unicode
7377
7478
Returns
7579
-------
76-
filepath : str or unicode
77-
Temporary filepath.
80+
file_bytes : io.BytesIO
81+
a file-like object that cane be read
7882
7983
"""
80-
filename = f"{random_string(6)}.pdf"
81-
with tempfile.NamedTemporaryFile("wb", delete=False) as f:
82-
headers = {"User-Agent": "Mozilla/5.0"}
83-
request = Request(url, None, headers)
84-
obj = urlopen(request)
85-
content_type = obj.info().get_content_type()
86-
if content_type != "application/pdf":
87-
raise NotImplementedError("File format not supported")
88-
f.write(obj.read())
89-
filepath = os.path.join(os.path.dirname(f.name), filename)
90-
shutil.move(f.name, filepath)
91-
return filepath
84+
file_bytes = io.BytesIO()
85+
file_bytes.name = url
86+
headers = {"User-Agent": "Mozilla/5.0"}
87+
request = Request(url, data=None, headers=headers)
88+
obj = urlopen(request)
89+
content_type = obj.info().get_content_type()
90+
if content_type != "application/pdf":
91+
raise NotImplementedError("File format not supported")
92+
file_bytes.write(obj.read())
93+
file_bytes.seek(0)
94+
return file_bytes
9295

9396

9497
stream_kwargs = ["columns", "edge_tol", "row_tol", "column_tol"]

tests/test_common.py

+19
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import io
12
import os
23
from pathlib import Path
34

@@ -188,3 +189,21 @@ def test_handler_with_pathlib(testdir):
188189
with open(filename, "rb") as f:
189190
handler = PDFHandler(f)
190191
assert handler._get_pages("1") == [1]
192+
193+
@skip_on_windows
194+
def test_from_open(testdir):
195+
filename = os.path.join(testdir, "foo.pdf")
196+
with open(filename, "rb") as file_bytes:
197+
tables = camelot.read_pdf(file_bytes=file_bytes)
198+
assert repr(tables) == "<TableList n=1>"
199+
assert repr(tables[0]) == "<Table shape=(7, 7)>"
200+
201+
@skip_on_windows
202+
def test_from_bytes(testdir):
203+
filename = os.path.join(testdir, "foo.pdf")
204+
file_bytes = io.BytesIO()
205+
with open(filename, "rb") as f:
206+
file_bytes.write(f.read()) # note that we didn't seek, done by PDFHandler
207+
tables = camelot.read_pdf(file_bytes=file_bytes)
208+
assert repr(tables) == "<TableList n=1>"
209+
assert repr(tables[0]) == "<Table shape=(7, 7)>"

0 commit comments

Comments
 (0)