textdirectory 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ """Top-level package for textdirectory."""
2
+
3
+ __author__ = 'Ingo Kleiber'
4
+ __email__ = 'ingo@kleiber.me'
5
+ __version__ = '0.4.0'
6
+
7
+ from textdirectory import helpers, transformations
8
+ from textdirectory.crudespellchecker import CrudeSpellChecker
9
+ from textdirectory.textdirectory import TextDirectory
10
+
11
+ __all__ = ['CrudeSpellChecker', 'TextDirectory', '__version__', 'helpers', 'transformations']
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m textdirectory."""
2
+
3
+ from textdirectory.cli import main
4
+
5
+ if __name__ == '__main__':
6
+ main()
textdirectory/cli.py ADDED
@@ -0,0 +1,99 @@
1
+ """Console script for textdirectory."""
2
+
3
+ import sys
4
+ from typing import Any
5
+
6
+ import click
7
+
8
+ from textdirectory import helpers, textdirectory
9
+
10
+ available_filters = helpers.get_available_filters()
11
+ available_transformations = helpers.get_available_transformations()
12
+
13
+
14
+ @click.command()
15
+ @click.version_option(package_name='textdirectory')
16
+ @click.option('--directory', help='The directory containing text files', type=str)
17
+ @click.option('--output_file', help='The file to aggregate to', type=str)
18
+ @click.option('--filetype', help='The file type to look for.', default='txt', type=str)
19
+ @click.option('--encoding', help='The encoding of the files.', default='utf8', type=str)
20
+ @click.option('--recursive', help='Recursion', type=bool)
21
+ @click.option('--disable_tqdm', help='Disable progress bar', default=False, type=bool)
22
+ @click.option('--filters', help=f'The filters you want to apply. Filters: {available_filters}', type=str)
23
+ @click.option(
24
+ '--transformations',
25
+ help=f'The transformations you want to apply. Tranformations: {available_transformations}',
26
+ type=str,
27
+ )
28
+ def main(
29
+ directory: str | None,
30
+ output_file: str | None,
31
+ filetype: str,
32
+ encoding: str,
33
+ recursive: bool,
34
+ disable_tqdm: bool,
35
+ filters: str | None,
36
+ transformations: str | None,
37
+ ) -> int:
38
+ """Console script for textdirectory."""
39
+ if not directory:
40
+ click.echo('Welcome to TextDirectory!\nRun textdirectory --help for further information.')
41
+ click.echo(
42
+ 'Example (Basic Aggregation): textdirectory --directory testdata --output_file aggregated.txt --filetype txt'
43
+ )
44
+ sys.exit()
45
+
46
+ if filters:
47
+ filters_list: list[list[Any]] = []
48
+ for filter in filters.split('/'):
49
+ name, *filter_args = filter.split(',')
50
+
51
+ # Coerce string arguments (e.g. '100') to the types the filter expects
52
+ filter_method = getattr(textdirectory.TextDirectory, name, None)
53
+ if filter_method is not None and name in available_filters:
54
+ filter_args = helpers.coerce_args_by_signature(filter_method, filter_args)
55
+
56
+ filters_list.append([name, *filter_args])
57
+
58
+ if transformations:
59
+ transformations_list: list[list[str]] = []
60
+ for transformation in transformations.split('/'):
61
+ transformations_list.append(transformation.split(','))
62
+
63
+ if disable_tqdm or not output_file:
64
+ disable_tqdm = True
65
+ else:
66
+ disable_tqdm = False
67
+
68
+ try:
69
+ td = textdirectory.TextDirectory(directory=directory, encoding=encoding, disable_tqdm=disable_tqdm)
70
+ td.load_files(recursive=recursive, filetype=filetype)
71
+ except NotADirectoryError:
72
+ click.echo('The directory could not be found.')
73
+ sys.exit(1)
74
+ except FileNotFoundError:
75
+ click.echo('There seem to be no files. Maybe you want to run with --recursive True.')
76
+ sys.exit(1)
77
+
78
+ try:
79
+ if filters and len(filters_list) > 0:
80
+ td.run_filters(filters_list)
81
+
82
+ if transformations and len(transformations_list) > 0:
83
+ for staged_transformation in transformations_list:
84
+ td.stage_transformation(staged_transformation)
85
+ except NameError as e:
86
+ click.echo(str(e))
87
+ sys.exit(1)
88
+
89
+ if output_file:
90
+ td.print_aggregation()
91
+ td.aggregate_to_file(output_file)
92
+ else:
93
+ click.echo(td.aggregate_to_memory())
94
+
95
+ return 0
96
+
97
+
98
+ if __name__ == '__main__':
99
+ sys.exit(main())
@@ -0,0 +1,179 @@
1
+ """Spellchecker module."""
2
+
3
+ import gzip
4
+ import importlib.resources
5
+ import pickle
6
+ import re
7
+ from collections import Counter
8
+ from collections.abc import Iterable, Iterator
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ class CrudeSpellChecker:
14
+ """A very simple and crude spellchecker based on Peter Norvig's design.
15
+ Simple Language Models:
16
+ crudesc_lm_en.gz.lm English based on COCA (sample), OANC (written), BNC
17
+ crudesc_lm_ame.lm American English based on COCA (sample) and OANC (written)
18
+ crudesc_lm_amehistorical.lm American English based on COHA (sample)
19
+ """
20
+
21
+ def __init__(self, caching: bool = True, language_model: str = 'crudesc_lm_en') -> None:
22
+ """
23
+ :param caching: caching of corrections
24
+ :type caching: bool
25
+ :param language_model: the name of the lm
26
+ :type language_model: str
27
+ """
28
+ self.caching = caching
29
+ self.cache: dict[str, str] = {}
30
+ self.language_model_name = language_model
31
+
32
+ model_resource = importlib.resources.files('textdirectory').joinpath( # type: ignore[call-arg]
33
+ 'data', 'language_models', f'{self.language_model_name}.gz.lm'
34
+ )
35
+ self.frequencies: Counter[str] = pickle.loads(gzip.decompress(model_resource.read_bytes()))
36
+
37
+ def p_word(self, word: str) -> float:
38
+ """
39
+ :param word: a word
40
+ :type word: str
41
+ """
42
+ return self.frequencies[word] / sum(self.frequencies.values())
43
+
44
+ def correction(self, word: str) -> str:
45
+ """
46
+ :param word: a word
47
+ :type word: str
48
+ :return: most probable spelling correction for word
49
+ """
50
+
51
+ # Preserve
52
+ word_isupper = word[0].isupper()
53
+ word = word.lower()
54
+
55
+ def reconstruct_case(word: str, word_isupper: bool) -> str:
56
+ """
57
+ :param word: the word
58
+ :type word: str
59
+ :param word_isupper: the initial capitalization
60
+ :type word_isupper: bool
61
+ :return: the word with its initial capitalization
62
+ """
63
+ if word_isupper:
64
+ return word.capitalize()
65
+ else:
66
+ return word
67
+
68
+ if word in self.cache:
69
+ return reconstruct_case(self.cache[word], word_isupper)
70
+ else:
71
+ correction = max(self.candidates(word), key=self.p_word)
72
+
73
+ if self.caching:
74
+ self.cache[word] = correction
75
+
76
+ return reconstruct_case(correction, word_isupper)
77
+
78
+ def candidates(self, word: str) -> set[str] | list[str]:
79
+ """
80
+ :param word: a word
81
+ :type word: str
82
+ :return: a list of candidates
83
+ """
84
+ return (
85
+ self.known([word])
86
+ or self.known(self.edit_distance_1(word))
87
+ or self.known(self.edit_distance_2(word))
88
+ or [word]
89
+ )
90
+
91
+ def known(self, words: Iterable[str]) -> set[str]:
92
+ """
93
+ :param word: a word
94
+ :type word: str
95
+ :return: a subset of words in the dictionary of frequencies
96
+ """
97
+ return {w for w in words if w in self.frequencies}
98
+
99
+ def edit_distance_1(self, word: str) -> set[str]:
100
+ """
101
+ :param word: a word
102
+ :type word: str
103
+ :return: all edits one edit away from the word
104
+ """
105
+ letters = 'abcdefghijklmnopqrstuvwxyz'
106
+ splits = [(word[:i], word[i:]) for i in range(len(word) + 1)]
107
+ deletes = [L + R[1:] for L, R in splits if R]
108
+ transposes = [L + R[1] + R[0] + R[2:] for L, R in splits if len(R) > 1]
109
+ replaces = [L + c + R[1:] for L, R in splits if R for c in letters]
110
+ inserts = [L + c + R for L, R in splits for c in letters]
111
+
112
+ return set(deletes + transposes + replaces + inserts)
113
+
114
+ def edit_distance_2(self, word: str) -> Iterator[str]:
115
+ """
116
+ :param word: a word
117
+ :type word: str
118
+ :return: all edits two edits away from the word
119
+ """
120
+ return (e2 for e1 in self.edit_distance_1(word) for e2 in self.edit_distance_1(e1))
121
+
122
+ def correct_string(self, string: str, return_corrections: bool = False) -> Any:
123
+ """
124
+ :param string: the string to correct.
125
+ :type string: str
126
+ :param return_corrections: include the corrections in the result
127
+ :type return_corrections: bool
128
+ :return: the corrected string
129
+ """
130
+ corrections: list[tuple[str, str]] = []
131
+ corrected: list[str] = []
132
+ for word in string.split():
133
+ word_matches = re.findall(r'\w+', word)
134
+
135
+ # Tokens without any word characters (e.g. '---') are kept as-is
136
+ if not word_matches:
137
+ corrected.append(word)
138
+ continue
139
+
140
+ corrected_word = self.correction(word_matches[0])
141
+ corrected_word = re.sub(r'(.*?)(\w+)(.*?)', rf'\g<1>{corrected_word}\g<3>', word)
142
+ corrected.append(corrected_word)
143
+
144
+ if return_corrections and corrected_word != word:
145
+ corrections.append((word, corrected_word))
146
+
147
+ if return_corrections:
148
+ return (' '.join(corrected), corrections)
149
+ else:
150
+ return ' '.join(corrected)
151
+
152
+
153
+ def generate_crudespellchecker_lm(corpus_directory: str | Path, model_name: str, strip_xml: bool = False) -> None:
154
+ """
155
+ :param corpus_directory: path the folder containing the files.
156
+ :type corpus_directory: str
157
+ :param model_name: th name of the model
158
+ :type model_name: str
159
+ :param strip_xml: stripping XML tags with bs4
160
+ :type strip_xml: bool
161
+ """
162
+ from bs4 import BeautifulSoup
163
+
164
+ frequencies: Counter[str] = Counter()
165
+ files = list(Path(corpus_directory).glob('*.txt'))
166
+
167
+ for file in files:
168
+ with open(file, encoding='utf-8', errors='ignore') as corpus_file:
169
+ if strip_xml:
170
+ soup = BeautifulSoup(corpus_file.read().lower(), 'lxml')
171
+ text = soup.get_text()
172
+ else:
173
+ text = corpus_file.read().lower()
174
+
175
+ file_frequency = Counter(re.findall(r'\b[^\d\W]+\b', text))
176
+ frequencies = frequencies + file_frequency
177
+
178
+ with gzip.open(model_name + '.gz.lm', 'wb') as pkl:
179
+ pickle.dump(frequencies, pkl)
@@ -0,0 +1,96 @@
1
+ # A fairly conservative stopwords list based on frequency with a strong focus on function words. Verbs have been excluded. (13.06.2019)
2
+ a
3
+ about
4
+ above
5
+ an
6
+ and
7
+ as
8
+ at
9
+ because
10
+ been
11
+ below
12
+ between
13
+ but
14
+ by
15
+ could
16
+ for
17
+ from
18
+ he
19
+ her
20
+ hers
21
+ herself
22
+ him
23
+ himself
24
+ his
25
+ how
26
+ i
27
+ if
28
+ in
29
+ into
30
+ it
31
+ its
32
+ itself
33
+ just
34
+ mine
35
+ more
36
+ most
37
+ my
38
+ myself
39
+ not
40
+ of
41
+ on
42
+ or
43
+ our
44
+ ours
45
+ ourselves
46
+ out
47
+ she
48
+ should
49
+ so
50
+ some
51
+ such
52
+ the
53
+ their
54
+ theirs
55
+ them
56
+ themselves
57
+ these
58
+ they
59
+ those
60
+ through
61
+ to
62
+ us
63
+ very
64
+ we
65
+ what
66
+ when
67
+ which
68
+ while
69
+ who
70
+ would
71
+ you
72
+ your
73
+ yours
74
+ yourself
75
+ yourselves
76
+ this
77
+ that
78
+ whose
79
+ both
80
+ many
81
+ much
82
+ enough
83
+ several
84
+ yet
85
+ neither
86
+ although
87
+ however
88
+ before
89
+ over
90
+ across
91
+ around
92
+ within
93
+ really
94
+ quite
95
+ somewhat
96
+ rather
@@ -0,0 +1,273 @@
1
+ """Helpers module."""
2
+
3
+ import copy
4
+ import re
5
+ from collections import Counter
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+
10
+ def tabulate_flat_list_of_dicts(list_of_dicts: list[dict[str, Any]], max_length: int = 25) -> str:
11
+ """
12
+ :param list_of_dicts: a list of dictionaries; each list is a row
13
+ :type list_of_dicts: list
14
+ :param max_length: the maximum length of a cell
15
+ :type max_length: int
16
+ :return: a table
17
+ :type return: str
18
+ """
19
+
20
+ # Create a copy of the list to prevent object mutation
21
+ list_of_dicts = copy.deepcopy(list_of_dicts)
22
+
23
+ if len(list_of_dicts) == 0:
24
+ return ''
25
+
26
+ # Enforce a maximum length
27
+ if max_length:
28
+ for row in list_of_dicts:
29
+ for key, value in row.items():
30
+ row[key] = str(value)[:max_length]
31
+
32
+ # Determine the width of the columns
33
+ longest_values: dict[str, int] = {}
34
+
35
+ for key in list_of_dicts[0].keys():
36
+ longest_values[key] = len(key)
37
+
38
+ for row in list_of_dicts:
39
+ for key, value in row.items():
40
+ value = str(value)
41
+ if key in longest_values:
42
+ if len(value) > longest_values[key]:
43
+ longest_values[key] = len(value)
44
+ else:
45
+ longest_values[key] = len(value)
46
+
47
+ # Line / len(longest_values) = additonal characters for pipes
48
+ length = 0
49
+ for value in longest_values.values():
50
+ length += value
51
+
52
+ line = '\n|' + '-' * (length + len(longest_values) - 1) + '|'
53
+
54
+ # Header based on the first dictionary
55
+ table = line + '\n|'
56
+ for key in list_of_dicts[0].keys():
57
+ table += f'{key}'.ljust(longest_values[key]) + '|'
58
+
59
+ table += line
60
+
61
+ # Rows
62
+ for row in list_of_dicts:
63
+ table += '\n|'
64
+ for key, value in row.items():
65
+ # Remove linebreaks
66
+ value = value.replace('\n', '')
67
+ table += str(value).ljust(longest_values[key]) + '|'
68
+
69
+ table += line
70
+
71
+ return table
72
+
73
+
74
+ def count_non_alphanum(string: str) -> int:
75
+ """
76
+ :param string: a string
77
+ :type string: str
78
+ :return: the number of non-alphanumeric characters in the string
79
+ :type return: int
80
+ """
81
+
82
+ non_alphanum = 0
83
+ for c in string:
84
+ if not c.isalpha():
85
+ non_alphanum += 1
86
+
87
+ return non_alphanum
88
+
89
+
90
+ def chunk_text(string: str, chunk_size: int = 50000) -> list[str]:
91
+ """
92
+ :param string: a string
93
+ :type string: str
94
+ :param chunk_size: the max characters of one chunk
95
+ :type chunk_size: int
96
+ :return: a list of chunks
97
+ :type return: list
98
+ """
99
+
100
+ chunks = [string[i : i + chunk_size] for i in range(0, len(string), chunk_size)]
101
+ return chunks
102
+
103
+
104
+ def simple_tokenizer(string: str, regular_expression: str = r'\w+') -> list[str]:
105
+ """
106
+ :param string: a string
107
+ :type string: str
108
+ :return: a list of tokens
109
+ :type return: list
110
+ """
111
+
112
+ if not regular_expression:
113
+ tokens = string.split(' ')
114
+ else:
115
+ tokens = re.findall(regular_expression, string)
116
+
117
+ return tokens
118
+
119
+
120
+ def estimate_spacy_max_length(override: float | bool = False, tokenizer_only: bool = False) -> float:
121
+ """Returns a somewhat sensible suggestions for max_length."""
122
+ if override:
123
+ return override
124
+
125
+ try:
126
+ import psutil
127
+ except ImportError as e:
128
+ raise ImportError(
129
+ 'estimate_spacy_max_length requires psutil, which is an optional dependency. '
130
+ "Install it with: pip install 'textdirectory[nlp]'"
131
+ ) from e
132
+
133
+ memory = psutil.virtual_memory()
134
+ gb_available = memory.available / 1024 / 1024 / 1024
135
+
136
+ # tagger, parser, ner 100,000 characters = 1 GB
137
+ estimated_max_length = gb_available * 100000
138
+
139
+ if tokenizer_only:
140
+ estimated_max_length = estimated_max_length * 3
141
+
142
+ return estimated_max_length
143
+
144
+
145
+ def type_token_ratio(text: str) -> float:
146
+ """Returns a simple rounded type-token ratio of a text."""
147
+ tokens = simple_tokenizer(text)
148
+ no_types = len(Counter(tokens))
149
+ no_tokens = len(tokens)
150
+
151
+ if no_tokens == 0:
152
+ return 0.0
153
+
154
+ return round(no_types / no_tokens, 2)
155
+
156
+
157
+ def get_human_from_docstring(doc: str) -> dict[str, str]:
158
+ """
159
+ :param doc: if True, also return the 'human name'
160
+ :type doc: string
161
+ :return: a dictionary of name_* keys/values from the docstring.
162
+ :type return: dict
163
+ """
164
+ doc = doc.replace(' ', '')
165
+ res = re.findall(r'human_(.*):(.*)', doc)
166
+
167
+ return {k: v.strip() for (k, v) in res}
168
+
169
+
170
+ def coerce_args_by_signature(func: Callable[..., Any], args: list[Any]) -> list[Any]:
171
+ """
172
+ Coerce string arguments to the types suggested by a function's signature.
173
+
174
+ String values are converted to int, float, or bool when the corresponding
175
+ parameter's annotation or default value indicates such a type; all other
176
+ values are passed through unchanged.
177
+
178
+ :param func: the function or method the arguments are meant for
179
+ :type func: callable
180
+ :param args: the arguments to coerce
181
+ :type args: list
182
+ :return: a list of coerced arguments
183
+ :type return: list
184
+ """
185
+ import inspect
186
+
187
+ parameters = [p for p in inspect.signature(func).parameters.values() if p.name not in ('self', 'text')]
188
+
189
+ coerced: list[Any] = []
190
+ for arg, parameter in zip(args, parameters, strict=False):
191
+ target_type = None
192
+
193
+ if parameter.annotation in (int, float, bool):
194
+ target_type = parameter.annotation
195
+ elif parameter.default is not inspect.Parameter.empty and isinstance(parameter.default, (bool, int, float)):
196
+ target_type = type(parameter.default)
197
+
198
+ if not isinstance(arg, str) or target_type is None:
199
+ coerced.append(arg)
200
+ elif target_type is bool:
201
+ coerced.append(arg.lower() in ('1', 'true', 'yes'))
202
+ else:
203
+ try:
204
+ coerced.append(target_type(arg))
205
+ except ValueError:
206
+ coerced.append(arg)
207
+
208
+ # Surplus arguments (consumed by *args) are passed through untouched
209
+ coerced.extend(args[len(coerced) :])
210
+
211
+ return coerced
212
+
213
+
214
+ def get_available_filters(get_human_name: bool = False) -> list[Any]:
215
+ """
216
+ :param get_human_name: if True, also return the 'human name'
217
+ :type get_human_name: bool
218
+ :return: a list of functions; if get_human_name a list of tuples
219
+ :type return: list
220
+ """
221
+
222
+ from textdirectory.textdirectory import TextDirectory
223
+
224
+ available_filters: list[Any] = [
225
+ filter
226
+ for filter in dir(TextDirectory)
227
+ if filter.startswith('filter_by_') and callable(getattr(TextDirectory, filter))
228
+ ]
229
+
230
+ if get_human_name:
231
+ available_filters_with_human: list[tuple[str, str]] = []
232
+ for f in available_filters:
233
+ doc = getattr(TextDirectory, f).__doc__
234
+ human = get_human_from_docstring(doc)
235
+ if 'name' in human:
236
+ available_filters_with_human.append((f, human['name']))
237
+ else:
238
+ available_filters_with_human.append((f, f))
239
+
240
+ available_filters = available_filters_with_human
241
+
242
+ return available_filters
243
+
244
+
245
+ def get_available_transformations(get_human_name: bool = False) -> list[Any]:
246
+ """
247
+ :param get_human_name: if True, also return the 'human name'
248
+ :type string: bool
249
+ :return: a list of functions; if get_human_name a list of tuples
250
+ :type return: list
251
+ """
252
+
253
+ from textdirectory import transformations
254
+
255
+ available_transformations: list[Any] = [
256
+ transformation
257
+ for transformation in dir(transformations)
258
+ if transformation.startswith('transformation_') and callable(getattr(transformations, transformation))
259
+ ]
260
+
261
+ if get_human_name:
262
+ available_transformations_with_human: list[tuple[str, str]] = []
263
+ for t in available_transformations:
264
+ doc = getattr(transformations, t).__doc__
265
+ human = get_human_from_docstring(doc)
266
+ if 'name' in human:
267
+ available_transformations_with_human.append((t, human['name']))
268
+ else:
269
+ available_transformations_with_human.append((t, t))
270
+
271
+ available_transformations = available_transformations_with_human
272
+
273
+ return available_transformations
textdirectory/py.typed ADDED
File without changes