1- # Copyright (c) 2020 Robert Lieck
21from pathlib import Path
2+ from abc import ABC , abstractmethod
33import os
4+ import io
45import re
56import json
67import ast
8+ import zipfile
9+ import tarfile
710
811import 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