Skip to content

Commit 0b98e6b

Browse files
committed
Unify FileCorpus classes to incorporate archives (zip/tar/gz/...)
1 parent 30b2f95 commit 0b98e6b

10 files changed

Lines changed: 390 additions & 116 deletions

File tree

corpusinterface/corpora.py

Lines changed: 291 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,78 @@
1-
# Copyright (c) 2020 Robert Lieck
21
from pathlib import Path
2+
from abc import ABC, abstractmethod
33
import os
4+
import io
45
import re
56
import json
67
import ast
8+
import zipfile
9+
import tarfile
710

811
import pandas
912

1013

11-
class Data:
14+
class Corpus(ABC):
1215
"""An abstract base class for data items, such as corpora or documents."""
16+
17+
def __enter__(self):
18+
self.open()
19+
return self
20+
21+
def __exit__(self, exc_type, exc_value, traceback):
22+
self.close()
23+
return False
24+
25+
def open(self):
26+
"""Open resources owned by the corpus."""
27+
pass
28+
29+
def close(self):
30+
"""Close resources owned by the corpus."""
31+
pass
32+
1333
def metadata(self, *args, **kwargs):
14-
raise NotImplementedError
34+
return None
1535

36+
@abstractmethod
1637
def data(self, *args, **kwargs):
1738
raise NotImplementedError
1839

1940

20-
class FileCorpus(Data):
21-
"""A collection of files in a directory"""
41+
class FileCorpusBase(Corpus):
42+
"""A collection of file-like objects in a directory-like object (e.g., normal files or a zip archive)."""
2243

2344
# special keyword arguments
2445
__META_READER__ = "meta_reader"
2546
__FILE_READER__ = "file_reader"
2647

27-
@classmethod
28-
def init(cls, **kwargs):
29-
if 'path' not in kwargs:
30-
raise TypeError("Missing required keyword argument 'path'")
31-
kwargs = {**dict(file_regex=None,
32-
path_regex=None,
33-
file_exclude_regex=None,
34-
path_exclude_regex=None),
35-
**kwargs}
36-
return FileCorpus(**kwargs)
37-
38-
def __init__(self,
48+
class LazyLoad:
49+
"""Simple wrapper for lazy-loading of data using a provided loading function."""
50+
51+
def __init__(self, load_func, *args, **kwargs):
52+
self.load_func = load_func
53+
self.args = args
54+
self.kwargs = kwargs
55+
self.data = None
56+
57+
def load(self):
58+
if self.data is None:
59+
self.data = self.load_func(*self.args, **self.kwargs)
60+
return self.data
61+
62+
def __init__(self, *,
3963
path,
64+
include_dirs=False,
4065
file_regex=None,
4166
path_regex=None,
4267
file_exclude_regex=None,
4368
path_exclude_regex=None,
4469
**kwargs):
4570
# set path and check
46-
self.path = Path(path)
47-
if not self.path.exists():
48-
raise FileNotFoundError(f"Corpus directory {self.path} does not exist")
49-
elif not self.path.is_dir():
50-
raise NotADirectoryError(f"{self.path} is not a directory")
71+
self._path = Path(path)
72+
if not self._path.exists():
73+
raise FileNotFoundError(f"Corpus path '{self._path}' does not exist")
74+
# whether to include directories when iterating over the corpus
75+
self.include_dirs = include_dirs
5176
# remember additional keyword arguments
5277
self.kwargs = kwargs
5378
# initialise regex for including files
@@ -72,50 +97,265 @@ def __init__(self,
7297
self.path_exclude_regex = re.compile(path_exclude_regex)
7398

7499
def __repr__(self):
75-
return f"{self.__class__.__name__}({self.path})"
100+
return f"{self.__class__.__name__}({self._path})"
101+
102+
def _skip_file(self, file_name, file_path):
103+
# check file inclusion regex
104+
if self.file_regex is not None and not self.file_regex.match(file_name):
105+
return True # skip non-matching files
106+
# check path inclusion regex
107+
if self.path_regex is not None and not self.path_regex.match(file_path):
108+
return True # skip non-matching paths
109+
# check file exclusion regex
110+
if self.file_exclude_regex is not None and self.file_exclude_regex.match(file_name):
111+
return True # skip matching files
112+
# check path exclusion regex
113+
if self.path_exclude_regex is not None and self.path_exclude_regex.match(file_path):
114+
return True # skip matching paths
115+
return False
116+
117+
@abstractmethod
118+
def files(self):
119+
"""Return an iterator over the files in the corpus."""
120+
raise NotImplementedError
76121

77122
def metadata(self, *args, **kwargs):
78123
kwargs = {**self.kwargs, **kwargs}
79124
meta_reader = kwargs.get(self.__META_READER__, None)
80125
if meta_reader is None:
81-
return self.path
126+
return self._path
82127
else:
83-
return meta_reader(self.path, *args, **kwargs)
128+
return meta_reader(self._path, *args, **kwargs)
129+
130+
def data(self, *args, **kwargs):
131+
kwargs = {**dict(return_files=False, lazy_load=False), **self.kwargs, **kwargs}
132+
return_files = kwargs.pop('return_files')
133+
lazy_load = kwargs.pop('lazy_load')
134+
file_reader = kwargs.get(self.__FILE_READER__, None)
135+
if file_reader is None:
136+
raise TypeError(f"{self.__FILE_READER__} is None; provide file_reader or use files() if you want to iterate over the files instead")
137+
if not callable(file_reader):
138+
raise TypeError(f"{self.__FILE_READER__} must be a callable, not '{file_reader}' of type {type(file_reader)}")
139+
for file_path in self.files():
140+
if lazy_load:
141+
d = self.LazyLoad(file_reader, file_path, *args, **kwargs)
142+
else:
143+
d = file_reader(file_path, *args, **kwargs)
144+
if return_files:
145+
yield file_path, d
146+
else:
147+
yield d
148+
149+
150+
class PathLike(ABC):
151+
"""
152+
An abstract base class for path-like objects that behave roughly like pathlib.Path. They are NOT guaranteed to
153+
exist as real paths in the filesystem (e.g., they could be files within a zip archive) and therefore do not
154+
implement the os.PathLike API.
155+
"""
156+
157+
def __repr__(self):
158+
return f"{self.__class__.__name__}('{self.path}')"
159+
160+
def __str__(self):
161+
return str(self.path)
162+
163+
@property
164+
@abstractmethod
165+
def name(self):
166+
"""Name of the file or directory the path points to."""
167+
raise NotImplementedError
168+
169+
@property
170+
@abstractmethod
171+
def path(self):
172+
"""Full path to the file or directory the path points to."""
173+
raise NotImplementedError
174+
175+
@abstractmethod
176+
def open(self, mode='r'):
177+
"""Open the file the path points to."""
178+
raise NotImplementedError
179+
180+
181+
class FileCorpus(FileCorpusBase):
182+
"""A collection of normal files in a directory"""
183+
184+
def __init__(self, **kwargs):
185+
super().__init__(**kwargs)
186+
# check if path is a directory
187+
if not self._path.is_dir():
188+
raise NotADirectoryError(f"{self._path} is not a directory")
84189

85190
def files(self):
86191
# recursively traverse directory
87-
for root, dirs, files in os.walk(self.path):
192+
for root, dirs, files in os.walk(self._path):
88193
root = Path(root)
89-
for file in files:
90-
path = root / file
91-
# check file inclusion regex
92-
if self.file_regex is not None and not self.file_regex.match(file):
93-
continue # skip non-matching files
94-
# check path inclusion regex
95-
if self.path_regex is not None and not self.path_regex.match(str(path)):
96-
continue # skip non-matching paths
97-
# check file exclusion regex
98-
if self.file_exclude_regex is not None and self.file_exclude_regex.match(file):
99-
continue # skip matching files
100-
# check path exclusion regex
101-
if self.path_exclude_regex is not None and self.path_exclude_regex.match(str(path)):
102-
continue # skip matching paths
103-
# yield absolute path to file
104-
yield path
194+
for file_name in files:
195+
file_path = root / file_name
196+
if self._skip_file(file_name, str(file_path)):
197+
continue
198+
if file_path.is_dir() and not self.include_dirs:
199+
continue
200+
yield RealPath(file_path)
201+
202+
203+
class RealPath(PathLike, os.PathLike):
204+
"""
205+
Class representing a real path. This is a subclass of PathLike and os.PathLike to make it compatible with both
206+
standard os.PathLike objects and virtual paths in archive files (e.g., ZipFile, TarFile).
207+
"""
208+
209+
def __init__(self, path):
210+
self._path = Path(path)
211+
212+
def __fspath__(self):
213+
return os.fspath(self._path)
214+
215+
@property
216+
def name(self):
217+
return self._path.name
218+
219+
@property
220+
def path(self):
221+
return self._path
222+
223+
def open(self, mode='r'):
224+
return self._path.open(mode)
225+
226+
227+
class ArchiveCorpusBase(FileCorpusBase):
228+
def __init__(self, path, file=None, **kwargs):
229+
path = Path(path)
230+
if file is not None:
231+
path /= Path(file)
232+
super().__init__(path=path, **kwargs)
233+
if not self._path.is_file():
234+
raise FileNotFoundError(f"'{self._path}' is not an archive file")
235+
self._archive = None
236+
237+
238+
class ZipFileCorpus(ArchiveCorpusBase):
239+
240+
def open(self) -> None:
241+
if self._archive is not None:
242+
raise RuntimeError("Archive is already open")
243+
self._archive = zipfile.ZipFile(self._path, mode="r")
244+
245+
def close(self) -> None:
246+
if self._archive is not None:
247+
self._archive.close()
248+
self._archive = None
249+
250+
def files(self):
251+
if self._archive is None:
252+
raise RuntimeError("Archive must be opened before iteration")
253+
for info in self._archive.infolist():
254+
file_name = Path(info.filename).name
255+
file_path = self._path / Path(info.filename)
256+
257+
if self._skip_file(str(file_name), str(file_path)):
258+
continue
259+
260+
if info.is_dir() and not self.include_dirs:
261+
continue
262+
263+
yield ZipEntry(self, info)
264+
265+
266+
class ZipEntry(PathLike):
267+
268+
def __init__(self, archive, info):
269+
self._archive = archive
270+
self._info = info
271+
272+
@property
273+
def name(self):
274+
return Path(self._info.filename).name
275+
276+
@property
277+
def path(self):
278+
return self._archive._path / Path(self._info.filename)
279+
280+
def open(self, mode="r", **kwargs):
281+
binary = self._archive._archive.open(self._info, "r")
282+
if mode == "rb":
283+
return binary
284+
elif mode in ("r", "rt"):
285+
kwargs.setdefault('encoding', "utf-8")
286+
return io.TextIOWrapper(binary, **kwargs)
287+
else:
288+
raise ValueError(f"Unsupported mode: {mode}")
289+
290+
291+
class TarFileCorpus(ArchiveCorpusBase):
292+
293+
def open(self):
294+
if self._archive is not None:
295+
raise RuntimeError("Archive is already open")
296+
297+
# Automatically detects .tar, .tar.gz, .tgz, .tar.bz2, .tar.xz, etc.
298+
self._archive = tarfile.open(self._path, mode="r:*")
299+
300+
def close(self):
301+
if self._archive is not None:
302+
self._archive.close()
303+
self._archive = None
304+
305+
def files(self):
306+
if self._archive is None:
307+
raise RuntimeError("Archive must be opened before iteration")
308+
309+
for info in self._archive.getmembers():
310+
file_name = Path(info.name).name
311+
file_path = self._path / Path(info.name)
312+
313+
if self._skip_file(str(file_name), str(file_path)):
314+
continue
315+
316+
if info.isdir() and not self.include_dirs:
317+
continue
318+
319+
# Skip entries such as symbolic links and device files.
320+
if not info.isfile() and not info.isdir():
321+
continue
322+
323+
yield TarEntry(self, info)
324+
325+
326+
class TarEntry(PathLike):
327+
328+
def __init__(self, archive, info):
329+
self._archive = archive
330+
self._info = info
331+
332+
@property
333+
def name(self):
334+
return Path(self._info.name).name
335+
336+
@property
337+
def path(self):
338+
return self._archive._path / Path(self._info.name)
339+
340+
def open(self, mode="r", **kwargs):
341+
binary = self._archive._archive.extractfile(self._info)
342+
343+
if binary is None:
344+
raise OSError(f"Could not open archive entry: {self.path}")
345+
346+
if mode == "rb":
347+
return binary
348+
elif mode in ("r", "rt"):
349+
kwargs.setdefault('encoding', "utf-8")
350+
return io.TextIOWrapper(binary, **kwargs)
351+
else:
352+
raise ValueError(f"Unsupported mode: {mode}")
105353

106-
def data(self, *args, **kwargs):
107-
kwargs = {**self.kwargs, **kwargs}
108-
file_reader = kwargs.get(self.__FILE_READER__, None)
109-
for path in self.files():
110-
if file_reader is None:
111-
yield path
112-
else:
113-
yield file_reader(path, *args, **kwargs)
114354

115355
# single file corpora
116356
# -------------------
117357

118-
class SingleFileCorpus(Data):
358+
class SingleFileCorpus(Corpus):
119359
"""
120360
A corpus consisting of a single file, superclass for different file types.
121361

0 commit comments

Comments
 (0)