pygarble 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.
- pygarble/__init__.py +7 -0
- pygarble/core.py +92 -0
- pygarble/strategies/__init__.py +17 -0
- pygarble/strategies/base.py +37 -0
- pygarble/strategies/character_frequency.py +33 -0
- pygarble/strategies/entropy_based.py +47 -0
- pygarble/strategies/language_detection.py +106 -0
- pygarble/strategies/pattern_matching.py +44 -0
- pygarble/strategies/statistical_analysis.py +22 -0
- pygarble/strategies/word_length.py +29 -0
- pygarble-0.1.0.dist-info/METADATA +707 -0
- pygarble-0.1.0.dist-info/RECORD +21 -0
- pygarble-0.1.0.dist-info/WHEEL +5 -0
- pygarble-0.1.0.dist-info/licenses/LICENSE +21 -0
- pygarble-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_core.py +34 -0
- tests/test_detector.py +78 -0
- tests/test_edge_cases.py +75 -0
- tests/test_language_detection.py +59 -0
- tests/test_strategies.py +88 -0
pygarble/__init__.py
ADDED
pygarble/core.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import concurrent.futures
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import List, Union
|
|
4
|
+
|
|
5
|
+
from .strategies import (
|
|
6
|
+
CharacterFrequencyStrategy,
|
|
7
|
+
EntropyBasedStrategy,
|
|
8
|
+
LanguageDetectionStrategy,
|
|
9
|
+
PatternMatchingStrategy,
|
|
10
|
+
StatisticalAnalysisStrategy,
|
|
11
|
+
WordLengthStrategy,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Strategy(Enum):
|
|
16
|
+
CHARACTER_FREQUENCY = "character_frequency"
|
|
17
|
+
WORD_LENGTH = "word_length"
|
|
18
|
+
PATTERN_MATCHING = "pattern_matching"
|
|
19
|
+
STATISTICAL_ANALYSIS = "statistical_analysis"
|
|
20
|
+
ENTROPY_BASED = "entropy_based"
|
|
21
|
+
LANGUAGE_DETECTION = "language_detection"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GarbleDetector:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
strategy: Strategy,
|
|
28
|
+
threshold: float = 0.5,
|
|
29
|
+
threads: int = None,
|
|
30
|
+
**kwargs,
|
|
31
|
+
):
|
|
32
|
+
self.strategy = strategy
|
|
33
|
+
self.threshold = threshold
|
|
34
|
+
self.threads = threads
|
|
35
|
+
self.kwargs = kwargs
|
|
36
|
+
self._strategy_instance = self._create_strategy_instance()
|
|
37
|
+
|
|
38
|
+
def _create_strategy_instance(self):
|
|
39
|
+
strategy_map = {
|
|
40
|
+
Strategy.CHARACTER_FREQUENCY: CharacterFrequencyStrategy,
|
|
41
|
+
Strategy.WORD_LENGTH: WordLengthStrategy,
|
|
42
|
+
Strategy.PATTERN_MATCHING: PatternMatchingStrategy,
|
|
43
|
+
Strategy.STATISTICAL_ANALYSIS: StatisticalAnalysisStrategy,
|
|
44
|
+
Strategy.ENTROPY_BASED: EntropyBasedStrategy,
|
|
45
|
+
Strategy.LANGUAGE_DETECTION: LanguageDetectionStrategy,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if self.strategy not in strategy_map:
|
|
49
|
+
raise NotImplementedError(
|
|
50
|
+
f"Strategy {self.strategy.value} is not implemented"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
strategy_class = strategy_map[self.strategy]
|
|
54
|
+
return strategy_class(**self.kwargs)
|
|
55
|
+
|
|
56
|
+
def _process_text_proba(self, text: str) -> float:
|
|
57
|
+
return self._strategy_instance.predict_proba(text)
|
|
58
|
+
|
|
59
|
+
def _process_text_predict(self, text: str) -> bool:
|
|
60
|
+
proba = self._strategy_instance.predict_proba(text)
|
|
61
|
+
return proba >= self.threshold
|
|
62
|
+
|
|
63
|
+
def _process_batch_threaded(self, texts: List[str], process_func) -> List:
|
|
64
|
+
if self.threads is None or self.threads <= 1 or len(texts) < 10:
|
|
65
|
+
return [process_func(text) for text in texts]
|
|
66
|
+
|
|
67
|
+
max_workers = min(self.threads, len(texts))
|
|
68
|
+
|
|
69
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
70
|
+
max_workers=max_workers
|
|
71
|
+
) as executor:
|
|
72
|
+
futures = [executor.submit(process_func, text) for text in texts]
|
|
73
|
+
return [future.result() for future in futures]
|
|
74
|
+
|
|
75
|
+
def predict(self, X: Union[str, List[str]]) -> Union[bool, List[bool]]:
|
|
76
|
+
if isinstance(X, str):
|
|
77
|
+
proba = self._strategy_instance.predict_proba(X)
|
|
78
|
+
return proba >= self.threshold
|
|
79
|
+
elif isinstance(X, list):
|
|
80
|
+
return self._process_batch_threaded(X, self._process_text_predict)
|
|
81
|
+
else:
|
|
82
|
+
raise ValueError("Input must be a string or list of strings")
|
|
83
|
+
|
|
84
|
+
def predict_proba(
|
|
85
|
+
self, X: Union[str, List[str]]
|
|
86
|
+
) -> Union[float, List[float]]:
|
|
87
|
+
if isinstance(X, str):
|
|
88
|
+
return self._strategy_instance.predict_proba(X)
|
|
89
|
+
elif isinstance(X, list):
|
|
90
|
+
return self._process_batch_threaded(X, self._process_text_proba)
|
|
91
|
+
else:
|
|
92
|
+
raise ValueError("Input must be a string or list of strings")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .base import BaseStrategy
|
|
2
|
+
from .character_frequency import CharacterFrequencyStrategy
|
|
3
|
+
from .entropy_based import EntropyBasedStrategy
|
|
4
|
+
from .language_detection import LanguageDetectionStrategy
|
|
5
|
+
from .pattern_matching import PatternMatchingStrategy
|
|
6
|
+
from .statistical_analysis import StatisticalAnalysisStrategy
|
|
7
|
+
from .word_length import WordLengthStrategy
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BaseStrategy",
|
|
11
|
+
"CharacterFrequencyStrategy",
|
|
12
|
+
"WordLengthStrategy",
|
|
13
|
+
"PatternMatchingStrategy",
|
|
14
|
+
"StatisticalAnalysisStrategy",
|
|
15
|
+
"EntropyBasedStrategy",
|
|
16
|
+
"LanguageDetectionStrategy",
|
|
17
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BaseStrategy(ABC):
|
|
6
|
+
def __init__(self, **kwargs):
|
|
7
|
+
self.kwargs = kwargs
|
|
8
|
+
|
|
9
|
+
def predict(self, text: str) -> bool:
|
|
10
|
+
if not text or not text.strip():
|
|
11
|
+
return False
|
|
12
|
+
|
|
13
|
+
if self._is_extremely_long_string(text):
|
|
14
|
+
return True
|
|
15
|
+
|
|
16
|
+
return self._predict_impl(text)
|
|
17
|
+
|
|
18
|
+
def predict_proba(self, text: str) -> float:
|
|
19
|
+
if not text or not text.strip():
|
|
20
|
+
return 0.0
|
|
21
|
+
|
|
22
|
+
if self._is_extremely_long_string(text):
|
|
23
|
+
return 1.0
|
|
24
|
+
|
|
25
|
+
return self._predict_proba_impl(text)
|
|
26
|
+
|
|
27
|
+
def _is_extremely_long_string(self, text: str) -> bool:
|
|
28
|
+
max_length = self.kwargs.get("max_string_length", 1000)
|
|
29
|
+
return len(text) > max_length and not re.search(r"\s", text)
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def _predict_impl(self, text: str) -> bool:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def _predict_proba_impl(self, text: str) -> float:
|
|
37
|
+
pass
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from .base import BaseStrategy
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CharacterFrequencyStrategy(BaseStrategy):
|
|
5
|
+
def _get_char_counts(self, text: str):
|
|
6
|
+
char_counts = {}
|
|
7
|
+
for char in text.lower():
|
|
8
|
+
if char.isalpha():
|
|
9
|
+
char_counts[char] = char_counts.get(char, 0) + 1
|
|
10
|
+
return char_counts
|
|
11
|
+
|
|
12
|
+
def _predict_impl(self, text: str) -> bool:
|
|
13
|
+
char_counts = self._get_char_counts(text)
|
|
14
|
+
total_chars = sum(char_counts.values())
|
|
15
|
+
if total_chars == 0:
|
|
16
|
+
return False
|
|
17
|
+
|
|
18
|
+
threshold = self.kwargs.get("frequency_threshold", 0.1)
|
|
19
|
+
for count in char_counts.values():
|
|
20
|
+
if count / total_chars > threshold:
|
|
21
|
+
return True
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
def _predict_proba_impl(self, text: str) -> float:
|
|
25
|
+
char_counts = self._get_char_counts(text)
|
|
26
|
+
total_chars = sum(char_counts.values())
|
|
27
|
+
if total_chars == 0:
|
|
28
|
+
return 0.0
|
|
29
|
+
|
|
30
|
+
max_frequency = (
|
|
31
|
+
max(char_counts.values()) / total_chars if char_counts else 0.0
|
|
32
|
+
)
|
|
33
|
+
return min(max_frequency, 1.0)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
from .base import BaseStrategy
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EntropyBasedStrategy(BaseStrategy):
|
|
7
|
+
def _get_char_counts(self, text: str):
|
|
8
|
+
char_counts = {}
|
|
9
|
+
for char in text.lower():
|
|
10
|
+
if char.isalpha():
|
|
11
|
+
char_counts[char] = char_counts.get(char, 0) + 1
|
|
12
|
+
return char_counts
|
|
13
|
+
|
|
14
|
+
def _calculate_entropy(self, char_counts):
|
|
15
|
+
total_chars = sum(char_counts.values())
|
|
16
|
+
if total_chars == 0:
|
|
17
|
+
return 0.0
|
|
18
|
+
|
|
19
|
+
entropy = 0
|
|
20
|
+
for count in char_counts.values():
|
|
21
|
+
probability = count / total_chars
|
|
22
|
+
entropy -= probability * math.log2(probability)
|
|
23
|
+
|
|
24
|
+
return entropy
|
|
25
|
+
|
|
26
|
+
def _predict_impl(self, text: str) -> bool:
|
|
27
|
+
char_counts = self._get_char_counts(text)
|
|
28
|
+
total_chars = sum(char_counts.values())
|
|
29
|
+
if total_chars == 0:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
entropy = self._calculate_entropy(char_counts)
|
|
33
|
+
threshold = self.kwargs.get("entropy_threshold", 3.0)
|
|
34
|
+
|
|
35
|
+
return entropy < threshold
|
|
36
|
+
|
|
37
|
+
def _predict_proba_impl(self, text: str) -> float:
|
|
38
|
+
char_counts = self._get_char_counts(text)
|
|
39
|
+
total_chars = sum(char_counts.values())
|
|
40
|
+
if total_chars == 0:
|
|
41
|
+
return 0.0
|
|
42
|
+
|
|
43
|
+
entropy = self._calculate_entropy(char_counts)
|
|
44
|
+
max_entropy = math.log2(len(char_counts)) if char_counts else 1.0
|
|
45
|
+
normalized_entropy = entropy / max_entropy if max_entropy > 0 else 0.0
|
|
46
|
+
|
|
47
|
+
return 1.0 - normalized_entropy
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import urllib.request
|
|
3
|
+
|
|
4
|
+
from .base import BaseStrategy
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LanguageDetectionStrategy(BaseStrategy):
|
|
8
|
+
def __init__(self, **kwargs):
|
|
9
|
+
super().__init__(**kwargs)
|
|
10
|
+
self._model = None
|
|
11
|
+
self._model_path = self._get_model_path()
|
|
12
|
+
|
|
13
|
+
def _get_model_path(self):
|
|
14
|
+
custom_path = self.kwargs.get("model_path")
|
|
15
|
+
if custom_path:
|
|
16
|
+
return custom_path
|
|
17
|
+
|
|
18
|
+
model_dir = os.path.expanduser("~/.pygarble/models")
|
|
19
|
+
os.makedirs(model_dir, exist_ok=True)
|
|
20
|
+
return os.path.join(model_dir, "lid.176.bin")
|
|
21
|
+
|
|
22
|
+
def _download_model(self):
|
|
23
|
+
if os.path.exists(self._model_path):
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
custom_path = self.kwargs.get("model_path")
|
|
27
|
+
if custom_path:
|
|
28
|
+
raise FileNotFoundError(
|
|
29
|
+
f"Model file not found at custom path: {self._model_path}"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
model_url = (
|
|
33
|
+
"https://dl.fbaipublicfiles.com/fasttext/supervised-models/"
|
|
34
|
+
"lid.176.bin"
|
|
35
|
+
)
|
|
36
|
+
print(
|
|
37
|
+
f"Downloading FastText language detection model to "
|
|
38
|
+
f"{self._model_path}..."
|
|
39
|
+
)
|
|
40
|
+
urllib.request.urlretrieve(model_url, self._model_path)
|
|
41
|
+
print("Model download completed.")
|
|
42
|
+
|
|
43
|
+
def _load_model(self):
|
|
44
|
+
if self._model is None:
|
|
45
|
+
try:
|
|
46
|
+
import fasttext
|
|
47
|
+
import numpy as np
|
|
48
|
+
|
|
49
|
+
self._download_model()
|
|
50
|
+
self._model = fasttext.load_model(self._model_path)
|
|
51
|
+
|
|
52
|
+
# Monkey patch to fix NumPy 2.0 compatibility issue
|
|
53
|
+
original_predict = self._model.predict
|
|
54
|
+
|
|
55
|
+
def patched_predict(text, k=1):
|
|
56
|
+
labels, probs = original_predict(text, k)
|
|
57
|
+
# Convert to list to avoid NumPy 2.0 copy=False issue
|
|
58
|
+
if isinstance(probs, np.ndarray):
|
|
59
|
+
probs = probs.tolist()
|
|
60
|
+
return labels, probs
|
|
61
|
+
|
|
62
|
+
self._model.predict = patched_predict
|
|
63
|
+
|
|
64
|
+
except ImportError:
|
|
65
|
+
raise ImportError(
|
|
66
|
+
"fasttext is required for LanguageDetectionStrategy. "
|
|
67
|
+
"Install it with: pip install fasttext-wheel"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def _predict_impl(self, text: str) -> bool:
|
|
71
|
+
self._load_model()
|
|
72
|
+
|
|
73
|
+
predictions = self._model.predict(text, k=1)
|
|
74
|
+
labels, scores = predictions
|
|
75
|
+
|
|
76
|
+
if not labels or not scores:
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
label = labels[0]
|
|
80
|
+
score = float(scores[0])
|
|
81
|
+
|
|
82
|
+
target_lang = self.kwargs.get("target_language", "en")
|
|
83
|
+
|
|
84
|
+
if label == f"__label__{target_lang}":
|
|
85
|
+
return score < 0.5
|
|
86
|
+
else:
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
def _predict_proba_impl(self, text: str) -> float:
|
|
90
|
+
self._load_model()
|
|
91
|
+
|
|
92
|
+
predictions = self._model.predict(text, k=1)
|
|
93
|
+
labels, scores = predictions
|
|
94
|
+
|
|
95
|
+
if not labels or not scores:
|
|
96
|
+
return 1.0
|
|
97
|
+
|
|
98
|
+
label = labels[0]
|
|
99
|
+
score = float(scores[0])
|
|
100
|
+
|
|
101
|
+
target_lang = self.kwargs.get("target_language", "en")
|
|
102
|
+
|
|
103
|
+
if label == f"__label__{target_lang}":
|
|
104
|
+
return float(1.0 - score)
|
|
105
|
+
else:
|
|
106
|
+
return 1.0
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from .base import BaseStrategy
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PatternMatchingStrategy(BaseStrategy):
|
|
7
|
+
DEFAULT_PATTERNS = {
|
|
8
|
+
"special_chars": r"[^a-zA-Z0-9\s]{3,}",
|
|
9
|
+
"repeated_chars": r"(.)\1{4,}",
|
|
10
|
+
"uppercase_sequence": r"[A-Z]{5,}",
|
|
11
|
+
"long_numbers": r"[0-9]{8,}",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
def _get_patterns(self):
|
|
15
|
+
custom_patterns = self.kwargs.get("patterns", {})
|
|
16
|
+
override_defaults = self.kwargs.get("override_defaults", False)
|
|
17
|
+
|
|
18
|
+
if override_defaults:
|
|
19
|
+
patterns = custom_patterns
|
|
20
|
+
else:
|
|
21
|
+
patterns = {**self.DEFAULT_PATTERNS, **custom_patterns}
|
|
22
|
+
|
|
23
|
+
return patterns
|
|
24
|
+
|
|
25
|
+
def _predict_impl(self, text: str) -> bool:
|
|
26
|
+
patterns = self._get_patterns()
|
|
27
|
+
|
|
28
|
+
for pattern_name, pattern_regex in patterns.items():
|
|
29
|
+
if re.search(pattern_regex, text):
|
|
30
|
+
return True
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
def _predict_proba_impl(self, text: str) -> float:
|
|
34
|
+
patterns = self._get_patterns()
|
|
35
|
+
|
|
36
|
+
if not patterns:
|
|
37
|
+
return 0.0
|
|
38
|
+
|
|
39
|
+
matches = 0
|
|
40
|
+
for pattern_name, pattern_regex in patterns.items():
|
|
41
|
+
if re.search(pattern_regex, text):
|
|
42
|
+
matches += 1
|
|
43
|
+
|
|
44
|
+
return min(matches / len(patterns), 1.0)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .base import BaseStrategy
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class StatisticalAnalysisStrategy(BaseStrategy):
|
|
5
|
+
def _get_alpha_ratio(self, text: str) -> float:
|
|
6
|
+
alpha_chars = sum(1 for c in text if c.isalpha())
|
|
7
|
+
content_chars = sum(1 for c in text if not c.isspace())
|
|
8
|
+
|
|
9
|
+
if content_chars == 0:
|
|
10
|
+
return 0.0
|
|
11
|
+
|
|
12
|
+
return alpha_chars / content_chars
|
|
13
|
+
|
|
14
|
+
def _predict_impl(self, text: str) -> bool:
|
|
15
|
+
alpha_ratio = self._get_alpha_ratio(text)
|
|
16
|
+
threshold = self.kwargs.get("alpha_threshold", 0.5)
|
|
17
|
+
|
|
18
|
+
return alpha_ratio < threshold
|
|
19
|
+
|
|
20
|
+
def _predict_proba_impl(self, text: str) -> float:
|
|
21
|
+
alpha_ratio = self._get_alpha_ratio(text)
|
|
22
|
+
return 1.0 - alpha_ratio
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from .base import BaseStrategy
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WordLengthStrategy(BaseStrategy):
|
|
5
|
+
def _get_words(self, text: str):
|
|
6
|
+
return text.split()
|
|
7
|
+
|
|
8
|
+
def _predict_impl(self, text: str) -> bool:
|
|
9
|
+
words = self._get_words(text)
|
|
10
|
+
if not words:
|
|
11
|
+
return False
|
|
12
|
+
|
|
13
|
+
avg_length = sum(len(word) for word in words) / len(words)
|
|
14
|
+
max_length = self.kwargs.get("max_word_length", 20)
|
|
15
|
+
|
|
16
|
+
return avg_length > max_length
|
|
17
|
+
|
|
18
|
+
def _predict_proba_impl(self, text: str) -> float:
|
|
19
|
+
words = self._get_words(text)
|
|
20
|
+
if not words:
|
|
21
|
+
return 0.0
|
|
22
|
+
|
|
23
|
+
avg_length = sum(len(word) for word in words) / len(words)
|
|
24
|
+
max_length = self.kwargs.get("max_word_length", 20)
|
|
25
|
+
|
|
26
|
+
if avg_length <= max_length:
|
|
27
|
+
return 0.0
|
|
28
|
+
|
|
29
|
+
return min((avg_length - max_length) / max_length, 1.0)
|