pyests 0.1.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.
ests/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ # Spanish Texts Statistics (esTS)
2
+ #
3
+ # Copyright (C) 2026
4
+ # Author: Sergey Shkarin <kouki.sergey@gmail.com>
5
+ # URL: <https://github.com/SergeyShk/esTS>
6
+
7
+ import logging
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ from .basic_stats import BasicStats
11
+ from .diversity_stats import DiversityStats
12
+ from .exceptions import (
13
+ DataFileError,
14
+ DatasetNotFoundError,
15
+ DownloadError,
16
+ EstsError,
17
+ ParameterError,
18
+ SourceError,
19
+ SourceTypeError,
20
+ UnknownStatError,
21
+ )
22
+ from .extractors import CharNgramsExtractor, SentsExtractor, WordsExtractor
23
+ from .readability_stats import ReadabilityStats
24
+
25
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
26
+
27
+ # Metadata
28
+
29
+ # The distribution is pyests, the package it installs is ests
30
+ try:
31
+ __version__ = version("pyests")
32
+ except PackageNotFoundError:
33
+ __version__ = "0.0.0"
34
+ __description__ = (
35
+ "A library for extracting statistics from texts in Spanish. Requires Python 3.11+"
36
+ )
37
+ __author__ = "Sergey Shkarin"
38
+ __author_email__ = "kouki.sergey@gmail.com"
39
+
40
+ __all__ = [
41
+ "BasicStats",
42
+ "CharNgramsExtractor",
43
+ "DataFileError",
44
+ "DatasetNotFoundError",
45
+ "DiversityStats",
46
+ "DownloadError",
47
+ "EstsError",
48
+ "ParameterError",
49
+ "ReadabilityStats",
50
+ "SentsExtractor",
51
+ "SourceError",
52
+ "SourceTypeError",
53
+ "UnknownStatError",
54
+ "WordsExtractor",
55
+ "__version__",
56
+ ]
ests/basic_stats.py ADDED
@@ -0,0 +1,329 @@
1
+ import re
2
+ import unicodedata
3
+ from collections import Counter
4
+ from collections.abc import Iterable
5
+ from typing import Any
6
+
7
+ from spacy.tokens import Doc, Span
8
+
9
+ from .constants import (
10
+ BASIC_STATS_DESC,
11
+ COMPLEX_SYL_FACTOR,
12
+ LONG_WORD_LETTER_FACTOR,
13
+ PUNCTUATION_TYPES,
14
+ PUNCTUATIONS,
15
+ SPACES,
16
+ )
17
+ from .exceptions import SourceError, SourceTypeError
18
+ from .extractors import SentsExtractor, WordsExtractor
19
+ from .syllables import count_syllables
20
+ from .utils import count_letters, iter_doc_words
21
+
22
+ ELLIPSIS_PATTERN = re.compile(r"…|\.{3,}|(?<=[?!])\.{2}")
23
+ # A hyphen after whitespace or at the start of a line, or before a space, is
24
+ # a dash, the way the raya is typed in plain-text corpora (-Hola -dijo Juan);
25
+ # a hyphen before a digit is a sign, a hyphen at the end of a line inside
26
+ # a word (pala-\nbra) is a hyphen
27
+ DASH_PATTERN = re.compile(r"(?:(?<=\s)|^)-(?!\d)|-(?=[ \t]|\Z)", re.MULTILINE)
28
+ _DELETE_SPACES = str.maketrans("", "", "".join(SPACES))
29
+ PUNCTUATION_CHARS = {
30
+ ",": "comma",
31
+ ".": "period",
32
+ "?": "question",
33
+ "¿": "question",
34
+ "!": "exclamation",
35
+ "¡": "exclamation",
36
+ ":": "colon",
37
+ ";": "semicolon",
38
+ "—": "dash",
39
+ "–": "dash",
40
+ "-": "hyphen",
41
+ "«": "angle_quotes",
42
+ "»": "angle_quotes",
43
+ '"': "straight_quotes",
44
+ "“": "straight_quotes",
45
+ "”": "straight_quotes",
46
+ "‘": "straight_quotes",
47
+ "’": "straight_quotes",
48
+ "(": "parentheses",
49
+ ")": "parentheses",
50
+ }
51
+
52
+
53
+ class BasicStats:
54
+ """
55
+ Class for computing the basic statistics of a text
56
+
57
+ Example:
58
+ >>> from ests import BasicStats
59
+ >>> text = "Hay tres clases de mentiras: mentiras, malditas mentiras y estadísticas"
60
+ >>> bs = BasicStats(text)
61
+ >>> bs.get_stats()
62
+ {'c_letters': {1: 1, 2: 1, 3: 1, 4: 1, 6: 1, 8: 4, 12: 1},
63
+ 'c_syllables': {1: 4, 2: 1, 3: 4, 5: 1},
64
+ 'n_sents': 1,
65
+ 'n_words': 10,
66
+ 'n_unique_words': 8,
67
+ 'n_long_words': 5,
68
+ 'n_complex_words': 5,
69
+ 'n_simple_words': 5,
70
+ 'n_monosyllable_words': 4,
71
+ 'n_polysyllable_words': 6,
72
+ 'n_chars': 71,
73
+ 'n_letters': 60,
74
+ 'n_spaces': 9,
75
+ 'n_syllables': 23,
76
+ 'n_punctuations': 2,
77
+ 'c_punctuations': {'comma': 1,
78
+ 'period': 0,
79
+ 'question': 0,
80
+ 'exclamation': 0,
81
+ 'ellipsis': 0,
82
+ 'colon': 1,
83
+ 'semicolon': 0,
84
+ 'dash': 0,
85
+ 'hyphen': 0,
86
+ 'angle_quotes': 0,
87
+ 'straight_quotes': 0,
88
+ 'parentheses': 0,
89
+ 'other': 0}}
90
+
91
+ Arguments:
92
+ source (str|Doc): Data source (a string or a Doc object); for a Doc the
93
+ words come from the tokens and the sentences from the annotation,
94
+ without sentence boundaries they come from sents_extractor
95
+ sents_extractor (SentsExtractor): Sentence extraction tool; an extractor
96
+ passed explicitly is used whatever the source, on the text of a Doc
97
+ words_extractor (WordsExtractor): Word extraction tool; an extractor
98
+ passed explicitly is used whatever the source, on the text of a Doc
99
+ normalize (bool): Compute the normalized statistics
100
+ complex_syl_factor (int): Minimum number of syllables in a complex word
101
+ long_word_letter_factor (int): Minimum number of letters in a long word
102
+
103
+ Attributes:
104
+ c_letters (dict[int, int]): Distribution of words by number of letters
105
+ c_syllables (dict[int, int]): Distribution of words by number of syllables
106
+ n_sents (int): Number of sentences
107
+ n_words (int): Number of words
108
+ n_unique_words (int): Number of unique words
109
+ n_long_words (int): Number of long words
110
+ n_complex_words (int): Number of complex words
111
+ n_simple_words (int): Number of simple words
112
+ n_monosyllable_words (int): Number of monosyllabic words
113
+ n_polysyllable_words (int): Number of polysyllabic words
114
+ n_chars (int): Number of characters
115
+ n_letters (int): Number of letters
116
+ n_spaces (int): Number of spaces
117
+ n_syllables (int): Number of syllables
118
+ n_punctuations (int): Number of punctuation marks
119
+ c_punctuations (dict[str, int]): Distribution of punctuation marks by type
120
+ p_unique_words (float): Normalized number of unique words
121
+ p_long_words (float): Normalized number of long words
122
+ p_complex_words (float): Normalized number of complex words
123
+ p_simple_words (float): Normalized number of simple words
124
+ p_monosyllable_words (float): Normalized number of monosyllabic words
125
+ p_polysyllable_words (float): Normalized number of polysyllabic words
126
+ p_letters (float): Normalized number of letters
127
+ p_spaces (float): Normalized number of spaces
128
+ p_punctuations (float): Normalized number of punctuation marks
129
+
130
+ Methods:
131
+ get_stats: Getting the computed statistics of the text
132
+ print_stats: Printing the computed statistics of the text with descriptions
133
+ count_words_by_syllables: Number of words with at least the given number of syllables
134
+ count_words_by_letters: Number of words with at least the given number of letters
135
+
136
+ Raises:
137
+ SourceTypeError: If the source is neither a string nor a Doc object
138
+ SourceError: If the source has no words
139
+ """
140
+
141
+ def __init__(
142
+ self,
143
+ source: str | Doc,
144
+ sents_extractor: SentsExtractor | None = None,
145
+ words_extractor: WordsExtractor | None = None,
146
+ normalize: bool = False,
147
+ complex_syl_factor: int = COMPLEX_SYL_FACTOR,
148
+ long_word_letter_factor: int = LONG_WORD_LETTER_FACTOR,
149
+ ):
150
+ sents: Iterable[Span] | Iterable[str]
151
+ if isinstance(source, Doc):
152
+ text = source.text
153
+ if sents_extractor is not None:
154
+ sents = sents_extractor.extract(text)
155
+ elif source.has_annotation("SENT_START"):
156
+ sents = source.sents
157
+ else:
158
+ sents = SentsExtractor().extract(text)
159
+ if words_extractor is not None:
160
+ words = words_extractor.extract(text)
161
+ else:
162
+ words = tuple(word for _, _, word in iter_doc_words(source))
163
+ elif isinstance(source, str):
164
+ text = source
165
+ sents = (sents_extractor or SentsExtractor()).extract(text)
166
+ words = (words_extractor or WordsExtractor()).extract(text)
167
+ else:
168
+ raise SourceTypeError("The data source is set incorrectly")
169
+ if not words:
170
+ raise SourceError("The data source has no words")
171
+
172
+ letters_per_word = tuple(count_letters(word) for word in words)
173
+ syllables_per_word = tuple(count_syllables(word) for word in words)
174
+ self.c_letters = dict(sorted(Counter(letters_per_word).items()))
175
+ self.c_syllables = dict(sorted(Counter(syllables_per_word).items()))
176
+ self.n_sents = sum(1 for sent in sents)
177
+ self.n_words = len(words)
178
+ self.n_unique_words = len({word.lower() for word in words})
179
+ self.n_long_words = self.count_words_by_letters(long_word_letter_factor)
180
+ self.n_complex_words = self.count_words_by_syllables(complex_syl_factor)
181
+ self.n_simple_words = sum(
182
+ count for spw, count in self.c_syllables.items() if complex_syl_factor > spw > 0
183
+ )
184
+ self.n_monosyllable_words = self.c_syllables.get(1, 0)
185
+ self.n_polysyllable_words = (
186
+ self.n_words - self.c_syllables.get(1, 0) - self.c_syllables.get(0, 0)
187
+ )
188
+ self.n_chars = len(text) - text.count("\n") - text.count("\r")
189
+ self.n_letters = sum(map(str.isalpha, text))
190
+ self.n_spaces = len(text) - len(text.translate(_DELETE_SPACES))
191
+ self.n_syllables = sum(syllables_per_word)
192
+ punctuations = count_punctuations(text)
193
+ self.n_punctuations = sum(punctuations.values())
194
+ self.c_punctuations = punctuations
195
+
196
+ if normalize:
197
+ self.p_unique_words = self.n_unique_words / self.n_words
198
+ self.p_long_words = self.n_long_words / self.n_words
199
+ self.p_complex_words = self.n_complex_words / self.n_words
200
+ self.p_simple_words = self.n_simple_words / self.n_words
201
+ self.p_monosyllable_words = self.n_monosyllable_words / self.n_words
202
+ self.p_polysyllable_words = self.n_polysyllable_words / self.n_words
203
+ self.p_letters = self.n_letters / self.n_chars
204
+ self.p_spaces = self.n_spaces / self.n_chars
205
+ self.p_punctuations = self.n_punctuations / self.n_chars
206
+
207
+ def count_words_by_syllables(self, min_syllables: int) -> int:
208
+ """
209
+ Getting the number of words with at least the given number of syllables
210
+
211
+ Arguments:
212
+ min_syllables (int): Minimum number of syllables in a word
213
+
214
+ Returns:
215
+ int: Number of words
216
+ """
217
+ return sum(count for spw, count in self.c_syllables.items() if spw >= min_syllables)
218
+
219
+ def count_words_by_letters(self, min_letters: int) -> int:
220
+ """
221
+ Getting the number of words with at least the given number of letters
222
+
223
+ Arguments:
224
+ min_letters (int): Minimum number of letters in a word
225
+
226
+ Returns:
227
+ int: Number of words
228
+ """
229
+ return sum(count for cpw, count in self.c_letters.items() if cpw >= min_letters)
230
+
231
+ def get_stats(self) -> dict[str, Any]:
232
+ """
233
+ Getting the computed statistics of the text
234
+
235
+ Returns:
236
+ dict[str, Any]: Dictionary of the computed statistics - a copy,
237
+ editing it does not change the object
238
+ """
239
+ return {
240
+ key: dict(value) if isinstance(value, dict) else value
241
+ for key, value in vars(self).items()
242
+ }
243
+
244
+ def print_stats(self):
245
+ """Printing the computed statistics of the text with descriptions"""
246
+ print(f"{'Statistic':^20}|{'Value':^10}")
247
+ print("-" * 30)
248
+ stats = self.get_stats()
249
+ for stat, value in BASIC_STATS_DESC.items():
250
+ print(f"{value:20}|{stats[stat]:^10}")
251
+
252
+
253
+ def count_punctuations(text: str) -> dict[str, int]:
254
+ """
255
+ Counting punctuation marks by type
256
+
257
+ Description:
258
+ The types of PUNCTUATION_TYPES: commas, periods, question and
259
+ exclamation marks (the inverted ¿ and ¡ included, so "¿Qué?" has two
260
+ question marks), ellipses (the character …, three or more periods,
261
+ or two periods after ? and ! count as one mark whose periods are not
262
+ periods: "¿Quién?.." is a question and an ellipsis), colons,
263
+ semicolons, dashes (— and –, as well as a hyphen after whitespace
264
+ or at the start of a line, or before a space, the way the raya is
265
+ typed in plain-text corpora: "-Hola -dijo Juan", "- Se fueron -
266
+ dijo"), hyphens inside words, before digits and at the end of
267
+ a line inside a word (teórico-práctico, 1990-1995, -5, pala-\nbra),
268
+ guillemets «», straight and curly quotes "“”‘’ of the three
269
+ levels of the orthography, parentheses and the other marks: every
270
+ remaining character of PUNCTUATIONS or of the Unicode categories P
271
+ and S, the same set that is_punctuation removes from the words,
272
+ so that no mark is lost between the words and the types
273
+
274
+ Arguments:
275
+ text (str): Text string
276
+
277
+ Returns:
278
+ dict[str, int]: Number of marks of each type in the order of PUNCTUATION_TYPES
279
+ """
280
+ counts = dict.fromkeys(PUNCTUATION_TYPES, 0)
281
+ rest, counts["ellipsis"] = ELLIPSIS_PATTERN.subn("", text)
282
+ rest, counts["dash"] = DASH_PATTERN.subn("", rest)
283
+ chars = Counter(rest)
284
+ for char, kind in PUNCTUATION_CHARS.items():
285
+ counts[kind] += chars[char]
286
+ counts["other"] = sum(
287
+ count
288
+ for char, count in chars.items()
289
+ if char not in PUNCTUATION_CHARS
290
+ and (char in PUNCTUATIONS or unicodedata.category(char)[0] in "PS")
291
+ )
292
+ return counts
293
+
294
+
295
+ def punctuation_profile(text: str, n_words: int | None = None) -> dict[str, float]:
296
+ """
297
+ Computing the punctuation profile - frequencies of marks by type per 1000 words
298
+
299
+ Description:
300
+ Frequencies of the types of PUNCTUATION_TYPES (count_punctuations)
301
+ per 1000 words and the share of inverted marks among all question
302
+ and exclamation marks (inverted_share): 0.5 when every question and
303
+ exclamation opens with ¿ or ¡ as the orthography requires, lower
304
+ when the writer drops them, as in informal texts and messages.
305
+ The profile is an editorial and stylometric feature: it depends on
306
+ the formatting of the text (typographic quotes and dashes, inverted
307
+ marks) and is easy to fake, so it is best read separately from
308
+ linguistic features
309
+
310
+ Arguments:
311
+ text (str): Text string
312
+ n_words (int): Number of words; if not given, the words are extracted
313
+ with WordsExtractor
314
+
315
+ Returns:
316
+ dict[str, float]: Frequencies of the types per 1000 words and
317
+ inverted_share; nan without words or without question and
318
+ exclamation marks
319
+ """
320
+ if n_words is None:
321
+ n_words = len(WordsExtractor().extract(text))
322
+ counts = count_punctuations(text)
323
+ profile = {
324
+ kind: count / n_words * 1000 if n_words else float("nan") for kind, count in counts.items()
325
+ }
326
+ n_marks = counts["question"] + counts["exclamation"]
327
+ n_inverted = text.count("¿") + text.count("¡")
328
+ profile["inverted_share"] = n_inverted / n_marks if n_marks else float("nan")
329
+ return profile