mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 13:23:01 +02:00
feat: text learning pipe
This commit is contained in:
parent
15bc7bf34f
commit
bcb17f60e0
24
python_pkg/word_frequency/__init__.py
Normal file
24
python_pkg/word_frequency/__init__.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"""Word frequency analyzer package.
|
||||||
|
|
||||||
|
This package provides tools for:
|
||||||
|
1. Analyzing word frequency in text (analyzer module)
|
||||||
|
2. Finding text excerpts where target words are most prevalent (excerpt_finder module)
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
from python_pkg.word_frequency.analyzer import analyze_text, analyze_and_format
|
||||||
|
from python_pkg.word_frequency.excerpt_finder import find_best_excerpt
|
||||||
|
|
||||||
|
# Analyze word frequency
|
||||||
|
counts = analyze_text("hello world hello")
|
||||||
|
print(counts["hello"]) # 2
|
||||||
|
|
||||||
|
# Find excerpt with target words
|
||||||
|
results = find_best_excerpt(
|
||||||
|
"they went somewhere he and she and the guy",
|
||||||
|
target_words=["and", "the"],
|
||||||
|
excerpt_length=3,
|
||||||
|
)
|
||||||
|
print(results[0].excerpt) # "and she and" or similar
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
260
python_pkg/word_frequency/analyzer.py
Normal file
260
python_pkg/word_frequency/analyzer.py
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Word frequency analyzer - analyzes text and produces word usage statistics.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# From raw text
|
||||||
|
python -m python_pkg.word_frequency.analyzer --text "Hello world hello"
|
||||||
|
|
||||||
|
# From a single file
|
||||||
|
python -m python_pkg.word_frequency.analyzer --file path/to/file.txt
|
||||||
|
|
||||||
|
# From multiple files
|
||||||
|
python -m python_pkg.word_frequency.analyzer --files file1.txt file2.txt file3.txt
|
||||||
|
|
||||||
|
# Limit output to top N words
|
||||||
|
python -m python_pkg.word_frequency.analyzer --file text.txt --top 20
|
||||||
|
|
||||||
|
# Case-sensitive mode
|
||||||
|
python -m python_pkg.word_frequency.analyzer --file text.txt --case-sensitive
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
|
||||||
|
def extract_words(text: str, *, case_sensitive: bool = False) -> list[str]:
|
||||||
|
"""Extract words from text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The input text to extract words from.
|
||||||
|
case_sensitive: If False, convert all words to lowercase.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of words found in the text.
|
||||||
|
"""
|
||||||
|
# Match word characters including unicode letters (for Polish, Latin, etc.)
|
||||||
|
words = re.findall(r"\b[\w]+\b", text, re.UNICODE)
|
||||||
|
|
||||||
|
if not case_sensitive:
|
||||||
|
words = [word.lower() for word in words]
|
||||||
|
|
||||||
|
return words
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_text(text: str, *, case_sensitive: bool = False) -> Counter[str]:
|
||||||
|
"""Analyze text and return word counts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The input text to analyze.
|
||||||
|
case_sensitive: If False, treat words case-insensitively.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Counter object with word frequencies.
|
||||||
|
"""
|
||||||
|
words = extract_words(text, case_sensitive=case_sensitive)
|
||||||
|
return Counter(words)
|
||||||
|
|
||||||
|
|
||||||
|
def read_file(filepath: str | Path) -> str:
|
||||||
|
"""Read text content from a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: Path to the file to read.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The text content of the file.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If the file doesn't exist.
|
||||||
|
UnicodeDecodeError: If the file can't be decoded as UTF-8.
|
||||||
|
"""
|
||||||
|
path = Path(filepath)
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def read_files(filepaths: Sequence[str | Path]) -> str:
|
||||||
|
"""Read and concatenate text content from multiple files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepaths: Sequence of paths to files to read.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Combined text content of all files.
|
||||||
|
"""
|
||||||
|
texts = []
|
||||||
|
for filepath in filepaths:
|
||||||
|
texts.append(read_file(filepath))
|
||||||
|
return "\n".join(texts)
|
||||||
|
|
||||||
|
|
||||||
|
def format_results(
|
||||||
|
word_counts: Counter[str],
|
||||||
|
*,
|
||||||
|
top_n: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Format word frequency results as a table.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
word_counts: Counter object with word frequencies.
|
||||||
|
top_n: If provided, only show the top N words.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string table with results.
|
||||||
|
"""
|
||||||
|
total_words = sum(word_counts.values())
|
||||||
|
|
||||||
|
if total_words == 0:
|
||||||
|
return "No words found in input."
|
||||||
|
|
||||||
|
# Get items sorted by frequency
|
||||||
|
if top_n is not None:
|
||||||
|
items = word_counts.most_common(top_n)
|
||||||
|
else:
|
||||||
|
items = word_counts.most_common()
|
||||||
|
|
||||||
|
# Find the maximum width for the word column
|
||||||
|
max_word_len = max(len(word) for word, _ in items) if items else 4
|
||||||
|
max_word_len = max(max_word_len, 4) # Minimum width for "Word" header
|
||||||
|
|
||||||
|
# Find the maximum width for the count column
|
||||||
|
max_count = max(count for _, count in items) if items else 0
|
||||||
|
count_width = max(len(str(max_count)), 5) # Minimum width for "Count" header
|
||||||
|
|
||||||
|
# Build the table
|
||||||
|
lines = []
|
||||||
|
lines.append(f"Total words: {total_words}")
|
||||||
|
lines.append(f"Unique words: {len(word_counts)}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header = f"{'Word':<{max_word_len}} {'Count':>{count_width}} {'Percentage':>10}"
|
||||||
|
lines.append(header)
|
||||||
|
lines.append("-" * len(header))
|
||||||
|
|
||||||
|
# Data rows
|
||||||
|
for word, count in items:
|
||||||
|
percentage = (count / total_words) * 100
|
||||||
|
lines.append(f"{word:<{max_word_len}} {count:>{count_width}} {percentage:>9.2f}%")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_and_format(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
case_sensitive: bool = False,
|
||||||
|
top_n: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Analyze text and return formatted results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The input text to analyze.
|
||||||
|
case_sensitive: If False, treat words case-insensitively.
|
||||||
|
top_n: If provided, only show the top N words.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string with word frequency analysis.
|
||||||
|
"""
|
||||||
|
word_counts = analyze_text(text, case_sensitive=case_sensitive)
|
||||||
|
return format_results(word_counts, top_n=top_n)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
"""Main entry point for the word frequency analyzer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
argv: Command line arguments (defaults to sys.argv[1:]).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Exit code (0 for success, non-zero for errors).
|
||||||
|
"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Analyze word frequency in text.",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=__doc__,
|
||||||
|
)
|
||||||
|
|
||||||
|
input_group = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
input_group.add_argument(
|
||||||
|
"--text",
|
||||||
|
"-t",
|
||||||
|
type=str,
|
||||||
|
help="Raw text to analyze",
|
||||||
|
)
|
||||||
|
input_group.add_argument(
|
||||||
|
"--file",
|
||||||
|
"-f",
|
||||||
|
type=str,
|
||||||
|
help="Path to a file to analyze",
|
||||||
|
)
|
||||||
|
input_group.add_argument(
|
||||||
|
"--files",
|
||||||
|
"-F",
|
||||||
|
nargs="+",
|
||||||
|
type=str,
|
||||||
|
help="Paths to multiple files to analyze",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--top",
|
||||||
|
"-n",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Show only the top N most frequent words",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--case-sensitive",
|
||||||
|
"-c",
|
||||||
|
action="store_true",
|
||||||
|
help="Treat words case-sensitively (default: case-insensitive)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
type=str,
|
||||||
|
help="Output file path (default: print to stdout)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.text:
|
||||||
|
text = args.text
|
||||||
|
elif args.file:
|
||||||
|
text = read_file(args.file)
|
||||||
|
else: # args.files
|
||||||
|
text = read_files(args.files)
|
||||||
|
|
||||||
|
result = analyze_and_format(
|
||||||
|
text,
|
||||||
|
case_sensitive=args.case_sensitive,
|
||||||
|
top_n=args.top,
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
Path(args.output).write_text(result, encoding="utf-8")
|
||||||
|
print(f"Output written to {args.output}") # noqa: T201
|
||||||
|
else:
|
||||||
|
print(result) # noqa: T201
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
print(f"Error: File not found - {e}", file=sys.stderr) # noqa: T201
|
||||||
|
return 1
|
||||||
|
except UnicodeDecodeError as e:
|
||||||
|
print(f"Error: Could not decode file as UTF-8 - {e}", file=sys.stderr) # noqa: T201
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
361
python_pkg/word_frequency/excerpt_finder.py
Normal file
361
python_pkg/word_frequency/excerpt_finder.py
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Excerpt finder - finds text excerpts where target words are most prevalent.
|
||||||
|
|
||||||
|
Given a text and a list of target words, this tool finds the excerpt of a
|
||||||
|
specified length (in words) where the target words appear most frequently.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# From raw text with target words
|
||||||
|
python -m python_pkg.word_frequency.excerpt_finder --text "they went somewhere he and she and the guy" --words and the --length 3
|
||||||
|
|
||||||
|
# From a file
|
||||||
|
python -m python_pkg.word_frequency.excerpt_finder --file path/to/file.txt --words the and of --length 10
|
||||||
|
|
||||||
|
# Target words from a file (one word per line)
|
||||||
|
python -m python_pkg.word_frequency.excerpt_finder --file text.txt --words-file targets.txt --length 20
|
||||||
|
|
||||||
|
# Show top N excerpts instead of just the best one
|
||||||
|
python -m python_pkg.word_frequency.excerpt_finder --file text.txt --words the and --length 10 --top 5
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, NamedTuple
|
||||||
|
|
||||||
|
try:
|
||||||
|
from python_pkg.word_frequency.analyzer import extract_words, read_file
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
from analyzer import extract_words, read_file # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
|
||||||
|
class ExcerptResult(NamedTuple):
|
||||||
|
"""Result of an excerpt search."""
|
||||||
|
|
||||||
|
excerpt: str
|
||||||
|
words: list[str]
|
||||||
|
start_index: int
|
||||||
|
end_index: int
|
||||||
|
match_count: int
|
||||||
|
match_percentage: float
|
||||||
|
|
||||||
|
|
||||||
|
def find_best_excerpt(
|
||||||
|
text: str,
|
||||||
|
target_words: Sequence[str],
|
||||||
|
excerpt_length: int,
|
||||||
|
*,
|
||||||
|
case_sensitive: bool = False,
|
||||||
|
top_n: int = 1,
|
||||||
|
) -> list[ExcerptResult]:
|
||||||
|
"""Find the excerpt(s) where target words are most prevalent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The input text to search.
|
||||||
|
target_words: Words to search for in the excerpt.
|
||||||
|
excerpt_length: Length of the excerpt in words.
|
||||||
|
case_sensitive: If False, match words case-insensitively.
|
||||||
|
top_n: Number of top excerpts to return.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ExcerptResult with the best excerpt(s) found.
|
||||||
|
"""
|
||||||
|
if excerpt_length <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Extract words with positions preserved
|
||||||
|
words = extract_words(text, case_sensitive=case_sensitive)
|
||||||
|
|
||||||
|
if not words or len(words) < excerpt_length:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Normalize target words for matching
|
||||||
|
if case_sensitive:
|
||||||
|
target_set = set(target_words)
|
||||||
|
else:
|
||||||
|
target_set = {w.lower() for w in target_words}
|
||||||
|
|
||||||
|
# Use sliding window to find the best excerpt
|
||||||
|
results: list[tuple[int, int, float, int]] = [] # (match_count, -start, percentage, start)
|
||||||
|
|
||||||
|
# Count matches in first window
|
||||||
|
current_matches = sum(1 for w in words[:excerpt_length] if w in target_set)
|
||||||
|
|
||||||
|
# Store first window result
|
||||||
|
percentage = (current_matches / excerpt_length) * 100
|
||||||
|
results.append((current_matches, 0, percentage, 0))
|
||||||
|
|
||||||
|
# Slide the window
|
||||||
|
for i in range(1, len(words) - excerpt_length + 1):
|
||||||
|
# Remove the word leaving the window
|
||||||
|
leaving_word = words[i - 1]
|
||||||
|
if leaving_word in target_set:
|
||||||
|
current_matches -= 1
|
||||||
|
|
||||||
|
# Add the word entering the window
|
||||||
|
entering_word = words[i + excerpt_length - 1]
|
||||||
|
if entering_word in target_set:
|
||||||
|
current_matches += 1
|
||||||
|
|
||||||
|
percentage = (current_matches / excerpt_length) * 100
|
||||||
|
results.append((current_matches, -i, percentage, i))
|
||||||
|
|
||||||
|
# Sort by match count (desc), then by position (asc for tie-breaking)
|
||||||
|
results.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
||||||
|
|
||||||
|
# Build ExcerptResult objects for top N
|
||||||
|
output: list[ExcerptResult] = []
|
||||||
|
seen_excerpts: set[tuple[str, ...]] = set()
|
||||||
|
|
||||||
|
for match_count, _, percentage, start_idx in results:
|
||||||
|
if len(output) >= top_n:
|
||||||
|
break
|
||||||
|
|
||||||
|
end_idx = start_idx + excerpt_length
|
||||||
|
excerpt_words = words[start_idx:end_idx]
|
||||||
|
excerpt_tuple = tuple(excerpt_words)
|
||||||
|
|
||||||
|
# Skip duplicate excerpts
|
||||||
|
if excerpt_tuple in seen_excerpts:
|
||||||
|
continue
|
||||||
|
seen_excerpts.add(excerpt_tuple)
|
||||||
|
|
||||||
|
output.append(
|
||||||
|
ExcerptResult(
|
||||||
|
excerpt=" ".join(excerpt_words),
|
||||||
|
words=list(excerpt_words),
|
||||||
|
start_index=start_idx,
|
||||||
|
end_index=end_idx,
|
||||||
|
match_count=match_count,
|
||||||
|
match_percentage=percentage,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def find_best_excerpt_with_context(
|
||||||
|
text: str,
|
||||||
|
target_words: Sequence[str],
|
||||||
|
excerpt_length: int,
|
||||||
|
*,
|
||||||
|
case_sensitive: bool = False,
|
||||||
|
top_n: int = 1,
|
||||||
|
context_words: int = 0,
|
||||||
|
) -> list[ExcerptResult]:
|
||||||
|
"""Find the excerpt(s) with optional surrounding context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The input text to search.
|
||||||
|
target_words: Words to search for in the excerpt.
|
||||||
|
excerpt_length: Length of the excerpt in words.
|
||||||
|
case_sensitive: If False, match words case-insensitively.
|
||||||
|
top_n: Number of top excerpts to return.
|
||||||
|
context_words: Number of words to include before/after the excerpt.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ExcerptResult with context included in the excerpt.
|
||||||
|
"""
|
||||||
|
base_results = find_best_excerpt(
|
||||||
|
text,
|
||||||
|
target_words,
|
||||||
|
excerpt_length,
|
||||||
|
case_sensitive=case_sensitive,
|
||||||
|
top_n=top_n,
|
||||||
|
)
|
||||||
|
|
||||||
|
if context_words <= 0:
|
||||||
|
return base_results
|
||||||
|
|
||||||
|
# Re-extract all words to get context
|
||||||
|
all_words = extract_words(text, case_sensitive=case_sensitive)
|
||||||
|
|
||||||
|
expanded_results: list[ExcerptResult] = []
|
||||||
|
for result in base_results:
|
||||||
|
# Expand the excerpt with context
|
||||||
|
ctx_start = max(0, result.start_index - context_words)
|
||||||
|
ctx_end = min(len(all_words), result.end_index + context_words)
|
||||||
|
context_excerpt_words = all_words[ctx_start:ctx_end]
|
||||||
|
|
||||||
|
expanded_results.append(
|
||||||
|
ExcerptResult(
|
||||||
|
excerpt=" ".join(context_excerpt_words),
|
||||||
|
words=context_excerpt_words,
|
||||||
|
start_index=ctx_start,
|
||||||
|
end_index=ctx_end,
|
||||||
|
match_count=result.match_count,
|
||||||
|
match_percentage=result.match_percentage,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return expanded_results
|
||||||
|
|
||||||
|
|
||||||
|
def format_excerpt_results(
|
||||||
|
results: list[ExcerptResult],
|
||||||
|
target_words: Sequence[str],
|
||||||
|
) -> str:
|
||||||
|
"""Format excerpt results for display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: List of ExcerptResult to format.
|
||||||
|
target_words: The target words that were searched for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string with results.
|
||||||
|
"""
|
||||||
|
if not results:
|
||||||
|
return "No excerpts found."
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append(f"Target words: {', '.join(target_words)}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for i, result in enumerate(results, 1):
|
||||||
|
if len(results) > 1:
|
||||||
|
lines.append(f"=== Result #{i} ===")
|
||||||
|
lines.append(f"Excerpt: \"{result.excerpt}\"")
|
||||||
|
lines.append(f"Word position: {result.start_index} - {result.end_index - 1}")
|
||||||
|
lines.append(f"Matches: {result.match_count}/{len(result.words)} ({result.match_percentage:.2f}%)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
"""Main entry point for the excerpt finder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
argv: Command line arguments (defaults to sys.argv[1:]).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Exit code (0 for success, non-zero for errors).
|
||||||
|
"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Find text excerpts where target words are most prevalent.",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=__doc__,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Input source
|
||||||
|
input_group = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
input_group.add_argument(
|
||||||
|
"--text",
|
||||||
|
"-t",
|
||||||
|
type=str,
|
||||||
|
help="Raw text to search",
|
||||||
|
)
|
||||||
|
input_group.add_argument(
|
||||||
|
"--file",
|
||||||
|
"-f",
|
||||||
|
type=str,
|
||||||
|
help="Path to a file to search",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Target words source
|
||||||
|
words_group = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
words_group.add_argument(
|
||||||
|
"--words",
|
||||||
|
"-w",
|
||||||
|
nargs="+",
|
||||||
|
type=str,
|
||||||
|
help="Target words to find",
|
||||||
|
)
|
||||||
|
words_group.add_argument(
|
||||||
|
"--words-file",
|
||||||
|
"-W",
|
||||||
|
type=str,
|
||||||
|
help="Path to file with target words (one per line)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Excerpt parameters
|
||||||
|
parser.add_argument(
|
||||||
|
"--length",
|
||||||
|
"-l",
|
||||||
|
type=int,
|
||||||
|
required=True,
|
||||||
|
help="Length of excerpt in words",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--top",
|
||||||
|
"-n",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
help="Show top N excerpts (default: 1)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--context",
|
||||||
|
"-c",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="Number of context words before/after excerpt",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--case-sensitive",
|
||||||
|
"-s",
|
||||||
|
action="store_true",
|
||||||
|
help="Match words case-sensitively",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
type=str,
|
||||||
|
help="Output file path (default: print to stdout)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get input text
|
||||||
|
if args.text:
|
||||||
|
text = args.text
|
||||||
|
else:
|
||||||
|
text = read_file(args.file)
|
||||||
|
|
||||||
|
# Get target words
|
||||||
|
if args.words:
|
||||||
|
target_words = args.words
|
||||||
|
else:
|
||||||
|
words_content = read_file(args.words_file)
|
||||||
|
target_words = [w.strip() for w in words_content.splitlines() if w.strip()]
|
||||||
|
|
||||||
|
if not target_words:
|
||||||
|
print("Error: No target words provided", file=sys.stderr) # noqa: T201
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Find excerpts
|
||||||
|
results = find_best_excerpt_with_context(
|
||||||
|
text,
|
||||||
|
target_words,
|
||||||
|
args.length,
|
||||||
|
case_sensitive=args.case_sensitive,
|
||||||
|
top_n=args.top,
|
||||||
|
context_words=args.context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format and print results
|
||||||
|
output = format_excerpt_results(results, target_words)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
Path(args.output).write_text(output, encoding="utf-8")
|
||||||
|
print(f"Output written to {args.output}") # noqa: T201
|
||||||
|
else:
|
||||||
|
print(output) # noqa: T201
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
print(f"Error: File not found - {e}", file=sys.stderr) # noqa: T201
|
||||||
|
return 1
|
||||||
|
except UnicodeDecodeError as e:
|
||||||
|
print(f"Error: Could not decode file as UTF-8 - {e}", file=sys.stderr) # noqa: T201
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
355
python_pkg/word_frequency/learning_pipe.py
Normal file
355
python_pkg/word_frequency/learning_pipe.py
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Learning pipe - combines word frequency analysis with excerpt finding for language learning.
|
||||||
|
|
||||||
|
This script helps language learners by:
|
||||||
|
1. Analyzing a text to find the most common words
|
||||||
|
2. Finding excerpts where those common words are most prevalent
|
||||||
|
3. Creating a progressive learning experience in batches
|
||||||
|
|
||||||
|
The idea is to:
|
||||||
|
- Learn the top N most frequent words first
|
||||||
|
- Then read excerpts that are dense with those words
|
||||||
|
- Progressively learn more words and more complex excerpts
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Basic usage - get top 20 words and find excerpts with them
|
||||||
|
python -m python_pkg.word_frequency.learning_pipe --file text.txt
|
||||||
|
|
||||||
|
# Custom batch size and excerpt length
|
||||||
|
python -m python_pkg.word_frequency.learning_pipe --file text.txt --batch-size 30 --excerpt-length 50
|
||||||
|
|
||||||
|
# Multiple batches for progressive learning
|
||||||
|
python -m python_pkg.word_frequency.learning_pipe --file text.txt --batches 5 --batch-size 20
|
||||||
|
|
||||||
|
# Output to file
|
||||||
|
python -m python_pkg.word_frequency.learning_pipe --file text.txt --output lesson.txt
|
||||||
|
|
||||||
|
# Skip common words (like "the", "a", "is") using a stopwords file
|
||||||
|
python -m python_pkg.word_frequency.learning_pipe --file text.txt --stopwords stopwords.txt
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
try:
|
||||||
|
from python_pkg.word_frequency.analyzer import analyze_text, read_file
|
||||||
|
from python_pkg.word_frequency.excerpt_finder import find_best_excerpt
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
from analyzer import analyze_text, read_file # type: ignore[import-not-found]
|
||||||
|
from excerpt_finder import find_best_excerpt # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
|
||||||
|
# Common stopwords for various languages (can be overridden with --stopwords)
|
||||||
|
DEFAULT_STOPWORDS_EN = frozenset({
|
||||||
|
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for",
|
||||||
|
"of", "with", "by", "from", "is", "are", "was", "were", "be", "been",
|
||||||
|
"being", "have", "has", "had", "do", "does", "did", "will", "would",
|
||||||
|
"could", "should", "may", "might", "must", "shall", "can", "this",
|
||||||
|
"that", "these", "those", "i", "you", "he", "she", "it", "we", "they",
|
||||||
|
"me", "him", "her", "us", "them", "my", "your", "his", "its", "our",
|
||||||
|
"their", "what", "which", "who", "whom", "whose", "where", "when",
|
||||||
|
"why", "how", "all", "each", "every", "both", "few", "more", "most",
|
||||||
|
"other", "some", "such", "no", "nor", "not", "only", "own", "same",
|
||||||
|
"so", "than", "too", "very", "just", "as", "if", "then", "because",
|
||||||
|
"while", "although", "though", "after", "before", "when", "where",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def load_stopwords(filepath: str | Path | None) -> frozenset[str]:
|
||||||
|
"""Load stopwords from a file (one word per line).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: Path to stopwords file, or None to use defaults.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Frozenset of stopwords.
|
||||||
|
"""
|
||||||
|
if filepath is None:
|
||||||
|
return frozenset()
|
||||||
|
|
||||||
|
path = Path(filepath)
|
||||||
|
if not path.exists():
|
||||||
|
return frozenset()
|
||||||
|
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
return frozenset(word.strip().lower() for word in content.splitlines() if word.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def generate_learning_lesson(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
batch_size: int = 20,
|
||||||
|
num_batches: int = 1,
|
||||||
|
excerpt_length: int = 30,
|
||||||
|
excerpts_per_batch: int = 3,
|
||||||
|
stopwords: frozenset[str] | None = None,
|
||||||
|
skip_default_stopwords: bool = False,
|
||||||
|
skip_numbers: bool = True,
|
||||||
|
case_sensitive: bool = False,
|
||||||
|
context_words: int = 5,
|
||||||
|
) -> str:
|
||||||
|
"""Generate a learning lesson from text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The source text to analyze.
|
||||||
|
batch_size: Number of words per learning batch.
|
||||||
|
num_batches: Number of batches to generate.
|
||||||
|
excerpt_length: Length of each excerpt in words.
|
||||||
|
excerpts_per_batch: Number of excerpts to find per batch.
|
||||||
|
stopwords: Custom stopwords to skip (in addition to defaults).
|
||||||
|
skip_default_stopwords: If True, don't filter out default English stopwords.
|
||||||
|
skip_numbers: If True, filter out numeric words (default: True).
|
||||||
|
case_sensitive: If True, treat words case-sensitively.
|
||||||
|
context_words: Words of context to include around excerpts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted learning lesson as a string.
|
||||||
|
"""
|
||||||
|
# Combine stopwords
|
||||||
|
all_stopwords: frozenset[str]
|
||||||
|
if skip_default_stopwords:
|
||||||
|
all_stopwords = stopwords or frozenset()
|
||||||
|
else:
|
||||||
|
all_stopwords = DEFAULT_STOPWORDS_EN | (stopwords or frozenset())
|
||||||
|
|
||||||
|
# Analyze text for word frequencies
|
||||||
|
word_counts = analyze_text(text, case_sensitive=case_sensitive)
|
||||||
|
|
||||||
|
# Filter out stopwords and get sorted words
|
||||||
|
filtered_words = [
|
||||||
|
(word, count)
|
||||||
|
for word, count in word_counts.most_common()
|
||||||
|
if word.lower() not in all_stopwords
|
||||||
|
and len(word) > 1
|
||||||
|
and not (skip_numbers and word.isdigit())
|
||||||
|
]
|
||||||
|
|
||||||
|
total_words = sum(word_counts.values())
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
lines.append("=" * 70)
|
||||||
|
lines.append("LANGUAGE LEARNING LESSON")
|
||||||
|
lines.append("=" * 70)
|
||||||
|
lines.append(f"Source text: {total_words:,} total words, {len(word_counts):,} unique words")
|
||||||
|
if all_stopwords:
|
||||||
|
lines.append(f"After filtering {len(all_stopwords)} stopwords: {len(filtered_words):,} vocabulary words")
|
||||||
|
else:
|
||||||
|
lines.append(f"Vocabulary words: {len(filtered_words):,}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Generate batches
|
||||||
|
cumulative_words: list[str] = []
|
||||||
|
|
||||||
|
for batch_num in range(num_batches):
|
||||||
|
start_idx = batch_num * batch_size
|
||||||
|
end_idx = start_idx + batch_size
|
||||||
|
|
||||||
|
if start_idx >= len(filtered_words):
|
||||||
|
break
|
||||||
|
|
||||||
|
batch_words = filtered_words[start_idx:end_idx]
|
||||||
|
cumulative_words.extend(word for word, _ in batch_words)
|
||||||
|
|
||||||
|
lines.append("-" * 70)
|
||||||
|
lines.append(f"BATCH {batch_num + 1}: Words {start_idx + 1} - {min(end_idx, len(filtered_words))}")
|
||||||
|
lines.append("-" * 70)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Word list with frequencies
|
||||||
|
lines.append("VOCABULARY TO LEARN:")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for i, (word, count) in enumerate(batch_words, start=start_idx + 1):
|
||||||
|
percentage = (count / total_words) * 100
|
||||||
|
lines.append(f" {i:3}. {word:<20} ({count:,} occurrences, {percentage:.2f}%)")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Calculate cumulative coverage
|
||||||
|
cumulative_count = sum(
|
||||||
|
word_counts[word] for word in cumulative_words if word in word_counts
|
||||||
|
)
|
||||||
|
coverage = (cumulative_count / total_words) * 100
|
||||||
|
lines.append(f"After learning these words, you'll recognize ~{coverage:.1f}% of the text")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Find excerpts using cumulative words
|
||||||
|
lines.append("PRACTICE EXCERPTS:")
|
||||||
|
lines.append("(Excerpts where your learned vocabulary is most concentrated)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
excerpts = find_best_excerpt(
|
||||||
|
text,
|
||||||
|
cumulative_words,
|
||||||
|
excerpt_length,
|
||||||
|
case_sensitive=case_sensitive,
|
||||||
|
top_n=excerpts_per_batch,
|
||||||
|
)
|
||||||
|
|
||||||
|
for j, excerpt in enumerate(excerpts, 1):
|
||||||
|
lines.append(f" Excerpt {j} ({excerpt.match_percentage:.1f}% known words):")
|
||||||
|
lines.append(f" \"{excerpt.excerpt}\"")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
lines.append("=" * 70)
|
||||||
|
lines.append("SUMMARY")
|
||||||
|
lines.append("=" * 70)
|
||||||
|
|
||||||
|
if cumulative_words:
|
||||||
|
final_coverage = sum(
|
||||||
|
word_counts[word] for word in cumulative_words if word in word_counts
|
||||||
|
)
|
||||||
|
final_percentage = (final_coverage / total_words) * 100
|
||||||
|
lines.append(f"Total vocabulary words learned: {len(cumulative_words)}")
|
||||||
|
lines.append(f"Text coverage: {final_percentage:.1f}%")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("TIP: Focus on understanding the excerpts first, then read")
|
||||||
|
lines.append("more of the original text as your vocabulary grows!")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
"""Main entry point for the learning pipe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
argv: Command line arguments (defaults to sys.argv[1:]).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Exit code (0 for success, non-zero for errors).
|
||||||
|
"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Generate language learning lessons from text.",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=__doc__,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Input source
|
||||||
|
input_group = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
input_group.add_argument(
|
||||||
|
"--text",
|
||||||
|
"-t",
|
||||||
|
type=str,
|
||||||
|
help="Raw text to analyze",
|
||||||
|
)
|
||||||
|
input_group.add_argument(
|
||||||
|
"--file",
|
||||||
|
"-f",
|
||||||
|
type=str,
|
||||||
|
help="Path to a text file to analyze",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Learning parameters
|
||||||
|
parser.add_argument(
|
||||||
|
"--batch-size",
|
||||||
|
"-b",
|
||||||
|
type=int,
|
||||||
|
default=20,
|
||||||
|
help="Number of words per learning batch (default: 20)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--batches",
|
||||||
|
"-n",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
help="Number of batches to generate (default: 1)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--excerpt-length",
|
||||||
|
"-l",
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
help="Length of excerpts in words (default: 30)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--excerpts-per-batch",
|
||||||
|
"-e",
|
||||||
|
type=int,
|
||||||
|
default=3,
|
||||||
|
help="Number of excerpts per batch (default: 3)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filtering options
|
||||||
|
parser.add_argument(
|
||||||
|
"--stopwords",
|
||||||
|
"-s",
|
||||||
|
type=str,
|
||||||
|
help="Path to custom stopwords file (one word per line)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-default-stopwords",
|
||||||
|
action="store_true",
|
||||||
|
help="Don't filter out default English stopwords",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--case-sensitive",
|
||||||
|
"-c",
|
||||||
|
action="store_true",
|
||||||
|
help="Treat words case-sensitively",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--include-numbers",
|
||||||
|
action="store_true",
|
||||||
|
help="Include numeric words in vocabulary (filtered by default)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Output options
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
type=str,
|
||||||
|
help="Output file path (default: print to stdout)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get input text
|
||||||
|
if args.text:
|
||||||
|
text = args.text
|
||||||
|
else:
|
||||||
|
text = read_file(args.file)
|
||||||
|
|
||||||
|
# Load custom stopwords if provided
|
||||||
|
custom_stopwords = load_stopwords(args.stopwords)
|
||||||
|
|
||||||
|
# Generate lesson
|
||||||
|
lesson = generate_learning_lesson(
|
||||||
|
text,
|
||||||
|
batch_size=args.batch_size,
|
||||||
|
num_batches=args.batches,
|
||||||
|
excerpt_length=args.excerpt_length,
|
||||||
|
excerpts_per_batch=args.excerpts_per_batch,
|
||||||
|
stopwords=custom_stopwords,
|
||||||
|
skip_default_stopwords=args.no_default_stopwords,
|
||||||
|
skip_numbers=not args.include_numbers,
|
||||||
|
case_sensitive=args.case_sensitive,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Output
|
||||||
|
if args.output:
|
||||||
|
Path(args.output).write_text(lesson, encoding="utf-8")
|
||||||
|
print(f"Lesson written to {args.output}") # noqa: T201
|
||||||
|
else:
|
||||||
|
print(lesson) # noqa: T201
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
print(f"Error: File not found - {e}", file=sys.stderr) # noqa: T201
|
||||||
|
return 1
|
||||||
|
except UnicodeDecodeError as e:
|
||||||
|
print(f"Error: Could not decode file as UTF-8 - {e}", file=sys.stderr) # noqa: T201
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
99968
python_pkg/word_frequency/test_texts/bible_english.txt
Normal file
99968
python_pkg/word_frequency/test_texts/bible_english.txt
Normal file
File diff suppressed because it is too large
Load Diff
12903
python_pkg/word_frequency/test_texts/bible_latin.txt
Normal file
12903
python_pkg/word_frequency/test_texts/bible_latin.txt
Normal file
File diff suppressed because it is too large
Load Diff
70
python_pkg/word_frequency/test_texts/caesar_latin.txt
Normal file
70
python_pkg/word_frequency/test_texts/caesar_latin.txt
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
|
||||||
|
|
||||||
|
Caesar: Bellum Gallicum I
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
C. IVLI CAESARIS COMMENTARIORVM DE BELLO GALLICO LIBER PRIMVS
|
||||||
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
|
||||||
|
[1] 1 Gallia est omnis divisa in partes tres, quarum unam incolunt Belgae, aliam Aquitani, tertiam qui ipsorum lingua Celtae, nostra Galli appellantur. 2 Hi omnes lingua, institutis, legibus inter se differunt. Gallos ab Aquitanis Garumna flumen, a Belgis Matrona et Sequana dividit. 3 Horum omnium fortissimi sunt Belgae, propterea quod a cultu atque humanitate provinciae longissime absunt, minimeque ad eos mercatores saepe commeant atque ea quae ad effeminandos animos pertinent important, 4 proximique sunt Germanis, qui trans Rhenum incolunt, quibuscum continenter bellum gerunt. Qua de causa Helvetii quoque reliquos Gallos virtute praecedunt, quod fere cotidianis proeliis cum Germanis contendunt, cum aut suis finibus eos prohibent aut ipsi in eorum finibus bellum gerunt. 5 Eorum una pars, quam Gallos obtinere dictum est, initium capit a flumine Rhodano, continetur Garumna flumine, Oceano, finibus Belgarum, attingit etiam ab Sequanis et Helvetiis flumen Rhenum, vergit ad septentriones. 6 Belgae ab extremis Galliae finibus oriuntur, pertinent ad inferiorem partem fluminis Rheni, spectant in septentrionem et orientem solem. 7 Aquitania a Garumna flumine ad Pyrenaeos montes et eam partem Oceani quae est ad Hispaniam pertinet; spectat inter occasum solis et septentriones.
|
||||||
|
[2] 1 Apud Helvetios longe nobilissimus fuit et ditissimus Orgetorix. Is M. Messala, [et P.] M. Pisone consulibus regni cupiditate inductus coniurationem nobilitatis fecit et civitati persuasit ut de finibus suis cum omnibus copiis exirent: 2 perfacile esse, cum virtute omnibus praestarent, totius Galliae imperio potiri. 3 Id hoc facilius iis persuasit, quod undique loci natura Helvetii continentur: una ex parte flumine Rheno latissimo atque altissimo, qui agrum Helvetium a Germanis dividit; altera ex parte monte Iura altissimo, qui est inter Sequanos et Helvetios; tertia lacu Lemanno et flumine Rhodano, qui provinciam nostram ab Helvetiis dividit. 4 His rebus fiebat ut et minus late vagarentur et minus facile finitimis bellum inferre possent; 5 qua ex parte homines bellandi cupidi magno dolore adficiebantur. 6 Pro multitudine autem hominum et pro gloria belli atque fortitudinis angustos se fines habere arbitrabantur, qui in longitudinem milia passuum CCXL, in latitudinem CLXXX patebant.
|
||||||
|
[3] 1 His rebus adducti et auctoritate Orgetorigis permoti constituerunt ea quae ad proficiscendum pertinerent comparare, iumentorum et carrorum quam maximum numerum coemere, sementes quam maximas facere, ut in itinere copia frumenti suppeteret, cum proximis civitatibus pacem et amicitiam confirmare. 2 Ad eas res conficiendas biennium sibi satis esse duxerunt; in tertium annum profectionem lege confirmant. 3 Ad eas res conficiendas Orgetorix deligitur. Is sibi legationem ad civitates suscipit. In eo itinere persuadet Castico, Catamantaloedis filio, Sequano, cuius pater regnum in Sequanis multos annos obtinuerat et a senatu populi Romani amicus appellatus erat, ut regnum in civitate sua occuparet, quod pater ante habuerit; 4 itemque Dumnorigi Haeduo, fratri Diviciaci, qui eo tempore principatum in civitate obtinebat ac maxime plebi acceptus erat, ut idem conaretur persuadet eique filiam suam in matrimonium dat. 5 Perfacile factu esse illis probat conata perficere, propterea quod ipse suae civitatis imperium obtenturus esset: 6 non esse dubium quin totius Galliae plurimum Helvetii possent; se suis copiis suoque exercitu illis regna conciliaturum confirmat. 7 Hac oratione adducti inter se fidem et ius iurandum dant et regno occupato per tres potentissimos ac firmissimos populos totius Galliae sese potiri posse sperant.
|
||||||
|
[4] 1 Ea res est Helvetiis per indicium enuntiata. Moribus suis Orgetoricem ex vinculis causam dicere coegerunt; damnatum poenam sequi oportebat, ut igni cremaretur. 2 Die constituta causae dictionis Orgetorix ad iudicium omnem suam familiam, ad hominum milia decem, undique coegit, et omnes clientes obaeratosque suos, quorum magnum numerum habebat, eodem conduxit; per eos ne causam diceret se eripuit. 3 Cum civitas ob eam rem incitata armis ius suum exsequi conaretur multitudinemque hominum ex agris magistratus cogerent, Orgetorix mortuus est; 4 neque abest suspicio, ut Helvetii arbitrantur, quin ipse sibi mortem consciverit.
|
||||||
|
[5] 1 Post eius mortem nihilo minus Helvetii id quod constituerant facere conantur, ut e finibus suis exeant. 2 Ubi iam se ad eam rem paratos esse arbitrati sunt, oppida sua omnia, numero ad duodecim, vicos ad quadringentos, reliqua privata aedificia incendunt; 3 frumentum omne, praeter quod secum portaturi erant, comburunt, ut domum reditionis spe sublata paratiores ad omnia pericula subeunda essent; trium mensum molita cibaria sibi quemque domo efferre iubent. Persuadent Rauracis et Tulingis et Latobrigis finitimis, uti eodem usi consilio oppidis suis vicisque exustis una cum iis proficiscantur, Boiosque, qui trans Rhenum incoluerant et in agrum Noricum transierant Noreiamque oppugnabant, receptos ad se socios sibi adsciscunt.
|
||||||
|
[6] 1 Erant omnino itinera duo, quibus itineribus domo exire possent: unum per Sequanos, angustum et difficile, inter montem Iuram et flumen Rhodanum, vix qua singuli carri ducerentur, mons autem altissimus impendebat, ut facile perpauci prohibere possent; 2 alterum per provinciam nostram, multo facilius atque expeditius, propterea quod inter fines Helvetiorum et Allobrogum, qui nuper pacati erant, Rhodanus fluit isque non nullis locis vado transitur. 3 Extremum oppidum Allobrogum est proximumque Helvetiorum finibus Genava. Ex eo oppido pons ad Helvetios pertinet. Allobrogibus sese vel persuasuros, quod nondum bono animo in populum Romanum viderentur, existimabant vel vi coacturos ut per suos fines eos ire paterentur. Omnibus rebus ad profectionem comparatis diem dicunt, qua die ad ripam Rhodani omnes conveniant. Is dies erat a. d. V. Kal. Apr. L. Pisone, A. Gabinio consulibus.
|
||||||
|
[7] 1 Caesari cum id nuntiatum esset, eos per provinciam nostram iter facere conari, maturat ab urbe proficisci et quam maximis potest itineribus in Galliam ulteriorem contendit et ad Genavam pervenit. 2 Provinciae toti quam maximum potest militum numerum imperat (erat omnino in Gallia ulteriore legio una), pontem, qui erat ad Genavam, iubet rescindi. 3 Ubi de eius adventu Helvetii certiores facti sunt, legatos ad eum mittunt nobilissimos civitatis, cuius legationis Nammeius et Verucloetius principem locum obtinebant, qui dicerent sibi esse in animo sine ullo maleficio iter per provinciam facere, propterea quod aliud iter haberent nullum: rogare ut eius voluntate id sibi facere liceat. Caesar, quod memoria tenebat L. Cassium consulem occisum exercitumque eius ab Helvetiis pulsum et sub iugum missum, concedendum non putabat; 4 neque homines inimico animo, data facultate per provinciam itineris faciundi, temperaturos ab iniuria et maleficio existimabat. 5 Tamen, ut spatium intercedere posset dum milites quos imperaverat convenirent, legatis respondit diem se ad deliberandum sumpturum: si quid vellent, ad Id. April. reverterentur.
|
||||||
|
[8] 1 Interea ea legione quam secum habebat militibusque, qui ex provincia convenerant, a lacu Lemanno, qui in flumen Rhodanum influit, ad montem Iuram, qui fines Sequanorum ab Helvetiis dividit, milia passuum XVIIII murum in altitudinem pedum sedecim fossamque perducit. 2 Eo opere perfecto praesidia disponit, castella communit, quo facilius, si se invito transire conentur, prohibere possit. 3 Ubi ea dies quam constituerat cum legatis venit et legati ad eum reverterunt, negat se more et exemplo populi Romani posse iter ulli per provinciam dare et, si vim facere conentur, prohibiturum ostendit. 4 Helvetii ea spe deiecti navibus iunctis ratibusque compluribus factis, alii vadis Rhodani, qua minima altitudo fluminis erat, non numquam interdiu, saepius noctu, si perrumpere possent conati, operis munitione et militum concursu et telis repulsi, hoc conatu destiterunt.
|
||||||
|
[9] 1 Relinquebatur una per Sequanos via, qua Sequanis invitis propter angustias ire non poterant. 2 His cum sua sponte persuadere non possent, legatos ad Dumnorigem Haeduum mittunt, ut eo deprecatore a Sequanis impetrarent. 3 Dumnorix gratia et largitione apud Sequanos plurimum poterat et Helvetiis erat amicus, quod ex ea civitate Orgetorigis filiam in matrimonium duxerat, et cupiditate regni adductus novis rebus studebat et quam plurimas civitates suo beneficio habere obstrictas volebat. 4 Itaque rem suscipit et a Sequanis impetrat ut per fines suos Helvetios ire patiantur, obsidesque uti inter sese dent perficit: Sequani, ne itinere Helvetios prohibeant, Helvetii, ut sine maleficio et iniuria transeant.
|
||||||
|
[10] 1 Caesari renuntiatur Helvetiis esse in animo per agrum Sequanorum et Haeduorum iter in Santonum fines facere, qui non longe a Tolosatium finibus absunt, quae civitas est in provincia. Id si fieret, intellegebat magno cum periculo provinciae futurum ut homines bellicosos, populi Romani inimicos, locis patentibus maximeque frumentariis finitimos haberet. 3 Ob eas causas ei munitioni quam fecerat T. Labienum legatum praeficit; ipse in Italiam magnis itineribus contendit duasque ibi legiones conscribit et tres, quae circum Aquileiam hiemabant, ex hibernis educit et, qua proximum iter in ulteriorem Galliam per Alpes erat, cum his quinque legionibus ire contendit. 4 Ibi Ceutrones et Graioceli et Caturiges locis superioribus occupatis itinere exercitum prohibere conantur. Compluribus his proeliis pulsis ab Ocelo, quod est oppidum citerioris provinciae extremum, in fines Vocontiorum ulterioris provinciae die septimo pervenit; inde in Allobrogum fines, ab Allobrogibus in Segusiavos exercitum ducit. Hi sunt extra provinciam trans Rhodanum primi.
|
||||||
|
[11] 1 Helvetii iam per angustias et fines Sequanorum suas copias traduxerant et in Haeduorum fines pervenerant eorumque agros populabantur. 2 Haedui, cum se suaque ab iis defendere non possent, legatos ad Caesarem mittunt rogatum auxilium: 3 ita se omni tempore de populo Romano meritos esse ut paene in conspectu exercitus nostri agri vastari, liberi [eorum] in servitutem abduci, oppida expugnari non debuerint. Eodem tempore [quo Haedui] Ambarri, necessarii et consanguinei Haeduorum, Caesarem certiorem faciunt sese depopulatis agris non facile ab oppidis vim hostium prohibere. 4 Item Allobroges, qui trans Rhodanum vicos possessionesque habebant, fuga se ad Caesarem recipiunt et demonstrant sibi praeter agri solum nihil esse reliqui. 2 Quibus rebus adductus Caesar non expectandum sibi statuit dum, omnibus fortunis sociorum consumptis, in Santonos Helvetii pervenirent.
|
||||||
|
[12] 1 Flumen est Arar, quod per fines Haeduorum et Sequanorum in Rhodanum influit, incredibili lenitate, ita ut oculis in utram partem fluat iudicari non possit. Id Helvetii ratibus ac lintribus iunctis transibant. 2 Ubi per exploratores Caesar certior factus est tres iam partes copiarum Helvetios id flumen traduxisse, quartam vero partem citra flumen Ararim reliquam esse, de tertia vigilia cum legionibus tribus e castris profectus ad eam partem pervenit quae nondum flumen transierat. 3 Eos impeditos et inopinantes adgressus magnam partem eorum concidit; reliqui sese fugae mandarunt atque in proximas silvas abdiderunt. 4 Is pagus appellabatur Tigurinus; nam omnis civitas Helvetia in quattuor pagos divisa est. 5 Hic pagus unus, cum domo exisset, patrum nostrorum memoria L. Cassium consulem interfecerat et eius exercitum sub iugum miserat. 6 Ita sive casu sive consilio deorum immortalium quae pars civitatis Helvetiae insignem calamitatem populo Romano intulerat, ea princeps poenam persolvit. 7 Qua in re Caesar non solum publicas, sed etiam privatas iniurias ultus est, quod eius soceri L. Pisonis avum, L. Pisonem legatum, Tigurini eodem proelio quo Cassium interfecerant.
|
||||||
|
[13] 1 Hoc proelio facto, reliquas copias Helvetiorum ut consequi posset, pontem in Arari faciendum curat atque ita exercitum traducit. 2 Helvetii repentino eius adventu commoti cum id quod ipsi diebus XX aegerrime confecerant, ut flumen transirent, illum uno die fecisse intellegerent, legatos ad eum mittunt; cuius legationis Divico princeps fuit, qui bello Cassiano dux Helvetiorum fuerat. 3 Is ita cum Caesare egit: si pacem populus Romanus cum Helvetiis faceret, in eam partem ituros atque ibi futuros Helvetios ubi eos Caesar constituisset atque esse voluisset; 4 sin bello persequi perseveraret, reminisceretur et veteris incommodi populi Romani et pristinae virtutis Helvetiorum. Quod improviso unum pagum adortus esset, cum ii qui flumen transissent suis auxilium ferre non possent, ne ob eam rem aut suae magnopere virtuti tribueret aut ipsos despiceret. Se ita a patribus maioribusque suis didicisse, ut magis virtute contenderent quam dolo aut insidiis niterentur. 5 Quare ne committeret ut is locus ubi constitissent ex calamitate populi Romani et internecione exercitus nomen caperet aut memoriam proderet.
|
||||||
|
[14] 1 His Caesar ita respondit: eo sibi minus dubitationis dari, quod eas res quas legati Helvetii commemorassent memoria teneret, atque eo gravius ferre quo minus merito populi Romani accidissent; 2 qui si alicuius iniuriae sibi conscius fuisset, non fuisse difficile cavere; sed eo deceptum, quod neque commissum a se intellegeret quare timeret neque sine causa timendum putaret. 3 Quod si veteris contumeliae oblivisci vellet, num etiam recentium iniuriarum, quod eo invito iter per provinciam per vim temptassent, quod Haeduos, quod Ambarros, quod Allobrogas vexassent, memoriam deponere posse? 4 Quod sua victoria tam insolenter gloriarentur quodque tam diu se impune iniurias tulisse admirarentur, eodem pertinere. 5 Consuesse enim deos immortales, quo gravius homines ex commutatione rerum doleant, quos pro scelere eorum ulcisci velint, his secundiores interdum res et diuturniorem impunitatem concedere. 6 Cum ea ita sint, tamen, si obsides ab iis sibi dentur, uti ea quae polliceantur facturos intellegat, et si Haeduis de iniuriis quas ipsis sociisque eorum intulerint, item si Allobrogibus satisfaciant, sese cum iis pacem esse facturum. 7 Divico respondit: ita Helvetios a maioribus suis institutos esse uti obsides accipere, non dare, consuerint; eius rei populum Romanum esse testem. Hoc responso dato discessit.
|
||||||
|
[15] 1 Postero die castra ex eo loco movent. Idem facit Caesar equitatumque omnem, ad numerum quattuor milium, quem ex omni provincia et Haeduis atque eorum sociis coactum habebat, praemittit, qui videant quas in partes hostes iter faciant. Qui cupidius novissimum agmen insecuti alieno loco cum equitatu Helvetiorum proelium committunt; et pauci de nostris cadunt. 2 Quo proelio sublati Helvetii, quod quingentis equitibus tantam multitudinem equitum propulerant, audacius subsistere non numquam et novissimo agmine proelio nostros lacessere coeperunt. Caesar suos a proelio continebat, ac satis habebat in praesentia hostem rapinis, pabulationibus populationibusque prohibere. 3 Ita dies circiter XV iter fecerunt uti inter novissimum hostium agmen et nostrum primum non amplius quinis aut senis milibus passuum interesset.
|
||||||
|
[16] 1 Interim cotidie Caesar Haeduos frumentum, quod essent publice polliciti, flagitare. 2 Nam propter frigora [quod Gallia sub septentrionibus, ut ante dictum est, posita est,] non modo frumenta in agris matura non erant, sed ne pabuli quidem satis magna copia suppetebat; 3 eo autem frumento quod flumine Arari navibus subvexerat propterea uti minus poterat quod iter ab Arari Helvetii averterant, a quibus discedere nolebat. 4 Diem ex die ducere Haedui: conferri, comportari, adesse dicere. 5 Ubi se diutius duci intellexit et diem instare quo die frumentum militibus metiri oporteret, convocatis eorum principibus, quorum magnam copiam in castris habebat, in his Diviciaco et Lisco, qui summo magistratui praeerat, quem vergobretum appellant Haedui, qui creatur annuus et vitae necisque in suos habet potestatem, graviter eos accusat, 6 quod, cum neque emi neque ex agris sumi possit, tam necessario tempore, tam propinquis hostibus ab iis non sublevetur, praesertim cum magna ex parte eorum precibus adductus bellum susceperit[; multo etiam gravius quod sit destitutus queritur].
|
||||||
|
[17] 1 Tum demum Liscus oratione Caesaris adductus quod antea tacuerat proponit: esse non nullos, quorum auctoritas apud plebem plurimum valeat, qui privatim plus possint quam ipsi magistratus. 2 Hos seditiosa atque improba oratione multitudinem deterrere, ne frumentum conferant quod debeant: 3 praestare, si iam principatum Galliae obtinere non possint, Gallorum quam Romanorum imperia perferre, 4 neque dubitare [debeant] quin, si Helvetios superaverint Romani, una cum reliqua Gallia Haeduis libertatem sint erepturi. 5 Ab isdem nostra consilia quaeque in castris gerantur hostibus enuntiari; hos a se coerceri non posse. 6 Quin etiam, quod necessariam rem coactus Caesari enuntiarit, intellegere sese quanto id cum periculo fecerit, et ob eam causam quam diu potuerit tacuisse.
|
||||||
|
[18] 1 Caesar hac oratione Lisci Dumnorigem, Diviciaci fratrem, designari sentiebat, sed, quod pluribus praesentibus eas res iactari nolebat, celeriter concilium dimittit, Liscum retinet. 2 Quaerit ex solo ea quae in conventu dixerat. Dicit liberius atque audacius. Eadem secreto ab aliis quaerit; reperit esse vera: 3 ipsum esse Dumnorigem, summa audacia, magna apud plebem propter liberalitatem gratia, cupidum rerum novarum. Complures annos portoria reliquaque omnia Haeduorum vectigalia parvo pretio redempta habere, propterea quod illo licente contra liceri audeat nemo. 4 His rebus et suam rem familiarem auxisse et facultates ad largiendum magnas comparasse; 5 magnum numerum equitatus suo sumptu semper alere et circum se habere, 6 neque solum domi, sed etiam apud finitimas civitates largiter posse, atque huius potentiae causa matrem in Biturigibus homini illic nobilissimo ac potentissimo conlocasse; 7 ipsum ex Helvetiis uxorem habere, sororum ex matre et propinquas suas nuptum in alias civitates conlocasse. 8 Favere et cupere Helvetiis propter eam adfinitatem, odisse etiam suo nomine Caesarem et Romanos, quod eorum adventu potentia eius deminuta et Diviciacus frater in antiquum locum gratiae atque honoris sit restitutus. 9 Si quid accidat Romanis, summam in spem per Helvetios regni obtinendi venire; imperio populi Romani non modo de regno, sed etiam de ea quam habeat gratia desperare. 10 Reperiebat etiam in quaerendo Caesar, quod proelium equestre adversum paucis ante diebus esset factum, initium eius fugae factum a Dumnorige atque eius equitibus (nam equitatui, quem auxilio Caesari Haedui miserant, Dumnorix praeerat): eorum fuga reliquum esse equitatum perterritum.
|
||||||
|
[19] 1 Quibus rebus cognitis, cum ad has suspiciones certissimae res accederent, quod per fines Sequanorum Helvetios traduxisset, quod obsides inter eos dandos curasset, quod ea omnia non modo iniussu suo et civitatis sed etiam inscientibus ipsis fecisset, quod a magistratu Haeduorum accusaretur, satis esse causae arbitrabatur quare in eum aut ipse animadverteret aut civitatem animadvertere iuberet. 2 His omnibus rebus unum repugnabat, quod Diviciaci fratris summum in populum Romanum studium, summum in se voluntatem, egregiam fidem, iustitiam, temperantiam cognoverat; nam ne eius supplicio Diviciaci animum offenderet verebatur. 3 Itaque prius quam quicquam conaretur, Diviciacum ad se vocari iubet et, cotidianis interpretibus remotis, per C. Valerium Troucillum, principem Galliae provinciae, familiarem suum, cui summam omnium rerum fidem habebat, cum eo conloquitur; 4 simul commonefacit quae ipso praesente in concilio [Gallorum] de Dumnorige sint dicta, et ostendit quae separatim quisque de eo apud se dixerit. 5 Petit atque hortatur ut sine eius offensione animi vel ipse de eo causa cognita statuat vel civitatem statuere iubeat.
|
||||||
|
[20] 1 Diviciacus multis cum lacrimis Caesarem complexus obsecrare coepit ne quid gravius in fratrem statueret: 2 scire se illa esse vera, nec quemquam ex eo plus quam se doloris capere, propterea quod, cum ipse gratia plurimum domi atque in reliqua Gallia, ille minimum propter adulescentiam posset, per se crevisset; 3 quibus opibus ac nervis non solum ad minuendam gratiam, sed paene ad perniciem suam uteretur. Sese tamen et amore fraterno et existimatione vulgi commoveri. 4 Quod si quid ei a Caesare gravius accidisset, cum ipse eum locum amicitiae apud eum teneret, neminem existimaturum non sua voluntate factum; qua ex re futurum uti totius Galliae animi a se averterentur. 5 Haec cum pluribus verbis flens a Caesare peteret, Caesar eius dextram prendit; consolatus rogat finem orandi faciat; tanti eius apud se gratiam esse ostendit uti et rei publicae iniuriam et suum dolorem eius voluntati ac precibus condonet. Dumnorigem ad se vocat, fratrem adhibet; quae in eo reprehendat ostendit; quae ipse intellegat, quae civitas queratur proponit; monet ut in reliquum tempus omnes suspiciones vitet; praeterita se Diviciaco fratri condonare dicit. Dumnorigi custodes ponit, ut quae agat, quibuscum loquatur scire possit.
|
||||||
|
[21] 1 Eodem die ab exploratoribus certior factus hostes sub monte consedisse milia passuum ab ipsius castris octo, qualis esset natura montis et qualis in circuitu ascensus qui cognoscerent misit. 2 Renuntiatum est facilem esse. De tertia vigilia T. Labienum, legatum pro praetore, cum duabus legionibus et iis ducibus qui iter cognoverant summum iugum montis ascendere iubet; quid sui consilii sit ostendit. 3 Ipse de quarta vigilia eodem itinere quo hostes ierant ad eos contendit equitatumque omnem ante se mittit. 4 P. Considius, qui rei militaris peritissimus habebatur et in exercitu L. Sullae et postea in M. Crassi fuerat, cum exploratoribus praemittitur.
|
||||||
|
[22] 1 Prima luce, cum summus mons a [Lucio] Labieno teneretur, ipse ab hostium castris non longius mille et quingentis passibus abesset neque, ut postea ex captivis comperit, aut ipsius adventus aut Labieni cognitus esset, 2 Considius equo admisso ad eum accurrit, dicit montem, quem a Labieno occupari voluerit, ab hostibus teneri: id se a Gallicis armis atque insignibus cognovisse. 3 Caesar suas copias in proximum collem subducit, aciem instruit. Labienus, ut erat ei praeceptum a Caesare ne proelium committeret, nisi ipsius copiae prope hostium castra visae essent, ut undique uno tempore in hostes impetus fieret, monte occupato nostros expectabat proelioque abstinebat. 4 Multo denique die per exploratores Caesar cognovit et montem a suis teneri et Helvetios castra movisse et Considium timore perterritum quod non vidisset pro viso sibi renuntiavisse. Eo die quo consuerat intervallo hostes sequitur et milia passuum tria ab eorum castris castra ponit.
|
||||||
|
[23] 1 Postridie eius diei, quod omnino biduum supererat, cum exercitui frumentum metiri oporteret, et quod a Bibracte, oppido Haeduorum longe maximo et copiosissimo, non amplius milibus passuum XVIII aberat, rei frumentariae prospiciendum existimavit; itaque iter ab Helvetiis avertit ac Bibracte ire contendit. 2 Ea res per fugitivos L. Aemilii, decurionis equitum Gallorum, hostibus nuntiatur. 3 Helvetii, seu quod timore perterritos Romanos discedere a se existimarent, eo magis quod pridie superioribus locis occupatis proelium non commisissent, sive eo quod re frumentaria intercludi posse confiderent, commutato consilio atque itinere converso nostros a novissimo agmine insequi ac lacessere coeperunt.
|
||||||
|
[24] 1 Postquam id animum advertit, copias suas Caesar in proximum collem subduxit equitatumque, qui sustineret hostium impetum, misit. 2 Ipse interim in colle medio triplicem aciem instruxit legionum quattuor veteranarum; in summo iugo duas legiones quas in Gallia citeriore proxime conscripserat et omnia auxilia conlocavit, 3 ita ut supra se totum montem hominibus compleret; impedimenta sarcinasque in unum locum conferri et eum ab iis qui in superiore acie constiterant muniri iussit. 4 Helvetii cum omnibus suis carris secuti impedimenta in unum locum contulerunt; ipsi confertissima acie, reiecto nostro equitatu, phalange facta sub primam nostram aciem successerunt.
|
||||||
|
[25] 1 Caesar primum suo, deinde omnium ex conspectu remotis equis, ut aequato omnium periculo spem fugae tolleret, cohortatus suos proelium commisit. 2 Milites loco superiore pilis missis facile hostium phalangem perfregerunt. Ea disiecta gladiis destrictis in eos impetum fecerunt. 3 Gallis magno ad pugnam erat impedimento quod pluribus eorum scutis uno ictu pilorum transfixis et conligatis, cum ferrum se inflexisset, neque evellere neque sinistra impedita satis commode pugnare poterant, 4 multi ut diu iactato bracchio praeoptarent scutum manu emittere et nudo corpore pugnare. 5 Tandem vulneribus defessi et pedem referre et, quod mons suberit circiter mille passuum spatio, eo se recipere coeperunt. 6 Capto monte et succedentibus nostris, Boi et Tulingi, qui hominum milibus circiter XV agmen hostium claudebant et novissimis praesidio erant, ex itinere nostros ab latere aperto adgressi circumvenire, et id conspicati Helvetii, qui in montem sese receperant, rursus instare et proelium redintegrare coeperunt. 7 Romani conversa signa bipertito intulerunt: prima et secunda acies, ut victis ac submotis resisteret, tertia, ut venientes sustineret.
|
||||||
|
[26] 1 Ita ancipiti proelio diu atque acriter pugnatum est. Diutius cum sustinere nostrorum impetus non possent, alteri se, ut coeperant, in montem receperunt, alteri ad impedimenta et carros suos se contulerunt. 2 Nam hoc toto proelio, cum ab hora septima ad vesperum pugnatum sit, aversum hostem videre nemo potuit. 3 Ad multam noctem etiam ad impedimenta pugnatum est, propterea quod pro vallo carros obiecerunt et e loco superiore in nostros venientes tela coniciebant et non nulli inter carros rotasque mataras ac tragulas subiciebant nostrosque vulnerabant. 4 Diu cum esset pugnatum, impedimentis castrisque nostri potiti sunt. Ibi Orgetorigis filia atque unus e filiis captus est. 5 Ex eo proelio circiter hominum milia CXXX superfuerunt eaque tota nocte continenter ierunt [nullam partem noctis itinere intermisso]; in fines Lingonum die quarto pervenerunt, cum et propter vulnera militum et propter sepulturam occisorum nostri [triduum morati] eos sequi non potuissent. 6 Caesar ad Lingonas litteras nuntiosque misit, ne eos frumento neve alia re iuvarent: qui si iuvissent, se eodem loco quo Helvetios habiturum. Ipse triduo intermisso cum omnibus copiis eos sequi coepit.
|
||||||
|
[27] 1 Helvetii omnium rerum inopia adducti legatos de deditione ad eum miserunt. 2 Qui cum eum in itinere convenissent seque ad pedes proiecissent suppliciterque locuti flentes pacem petissent, atque eos in eo loco quo tum essent suum adventum expectare iussisset, paruerunt. 3 Eo postquam Caesar pervenit, obsides, arma, servos qui ad eos perfugissent, poposcit. 4 Dum ea conquiruntur et conferuntur, [nocte intermissa] circiter hominum milia VI eius pagi qui Verbigenus appellatur, sive timore perterriti, ne armis traditis supplicio adficerentur, sive spe salutis inducti, quod in tanta multitudine dediticiorum suam fugam aut occultari aut omnino ignorari posse existimarent, prima nocte e castris Helvetiorum egressi ad Rhenum finesque Germanorum contenderunt.
|
||||||
|
[28] 1 Quod ubi Caesar resciit, quorum per fines ierant his uti conquirerent et reducerent, si sibi purgati esse vellent, imperavit; reductos in hostium numero habuit; 2 reliquos omnes obsidibus, armis, perfugis traditis in deditionem accepit. 3 Helvetios, Tulingos, Latobrigos in fines suos, unde erant profecti, reverti iussit, et, quod omnibus frugibus amissis domi nihil erat quo famem tolerarent, Allobrogibus imperavit ut iis frumenti copiam facerent; ipsos oppida vicosque, quos incenderant, restituere iussit. Id ea maxime ratione fecit, quod noluit eum locum unde Helvetii discesserant vacare, ne propter bonitatem agrorum Germani, qui trans Rhenum incolunt, ex suis finibus in Helvetiorum fines transirent et finitimi Galliae provinciae Allobrogibusque essent. 4 Boios petentibus Haeduis, quod egregia virtute erant cogniti, ut in finibus suis conlocarent, concessit; quibus illi agros dederunt quosque postea in parem iuris libertatisque condicionem atque ipsi erant receperunt.
|
||||||
|
[29] 1 In castris Helvetiorum tabulae repertae sunt litteris Graecis confectae et ad Caesarem relatae, quibus in tabulis nominatim ratio confecta erat, qui numerus domo exisset eorum qui arma ferre possent, et item separatim, quot pueri, senes mulieresque. 2 [Quarum omnium rerum] summa erat capitum Helvetiorum milium CCLXIII, Tulingorum milium XXXVI, Latobrigorum XIIII, Rauracorum XXIII, Boiorum XXXII; ex his qui arma ferre possent ad milia nonaginta duo. 3 Summa omnium fuerunt ad milia CCCLXVIII. Eorum qui domum redierunt censu habito, ut Caesar imperaverat, repertus est numerus milium C et X.
|
||||||
|
[30] 1 Bello Helvetiorum confecto totius fere Galliae legati, principes civitatum, ad Caesarem gratulatum convenerunt: 2 intellegere sese, tametsi pro veteribus Helvetiorum iniuriis populi Romani ab his poenas bello repetisset, tamen eam rem non minus ex usu [terrae] Galliae quam populi Romani accidisse, 3 propterea quod eo consilio florentissimis rebus domos suas Helvetii reliquissent uti toti Galliae bellum inferrent imperioque potirentur, locumque domicilio ex magna copia deligerent quem ex omni Gallia oportunissimum ac fructuosissimum iudicassent, reliquasque civitates stipendiarias haberent. 4 Petierunt uti sibi concilium totius Galliae in diem certam indicere idque Caesaris facere voluntate liceret: sese habere quasdam res quas ex communi consensu ab eo petere vellent. 5 Ea re permissa diem concilio constituerunt et iure iurando ne quis enuntiaret, nisi quibus communi consilio mandatum esset, inter se sanxerunt.
|
||||||
|
[31] 1 Eo concilio dimisso, idem princeps civitatum qui ante fuerant ad Caesarem reverterunt petieruntque uti sibi secreto in occulto de sua omniumque salute cum eo agere liceret. 2 Ea re impetrata sese omnes flentes Caesari ad pedes proiecerunt: non minus se id contendere et laborare ne ea quae dixissent enuntiarentur quam uti ea quae vellent impetrarent, propterea quod, si enuntiatum esset, summum in cruciatum se venturos viderent. 3 Locutus est pro his Diviciacus Haeduus: Galliae totius factiones esse duas; harum alterius principatum tenere Haeduos, alterius Arvernos. 4 Hi cum tantopere de potentatu inter se multos annos contenderent, factum esse uti ab Arvernis Sequanisque Germani mercede arcesserentur. 5 Horum primo circiter milia XV Rhenum transisse; postea quam agros et cultum et copias Gallorum homines feri ac barbari adamassent, traductos plures; nunc esse in Gallia ad C et XX milium numerum. 6 Cum his Haeduos eorumque clientes semel atque iterum armis contendisse; magnam calamitatem pulsos accepisse, omnem nobilitatem, omnem senatum, omnem equitatum amisisse. 7 Quibus proeliis calamitatibusque fractos, qui et sua virtute et populi Romani hospitio atque amicitia plurimum ante in Gallia potuissent, coactos esse Sequanis obsides dare nobilissimos civitatis et iure iurando civitatem obstringere sese neque obsides repetituros neque auxilium a populo Romano imploraturos neque recusaturos quo minus perpetuo sub illorum dicione atque imperio essent. 8 Unum se esse ex omni civitate Haeduorum qui adduci non potuerit ut iuraret aut liberos suos obsides daret. 9 Ob eam rem se ex civitate profugisse et Romam ad senatum venisse auxilium postulatum, quod solus neque iure iurando neque obsidibus teneretur. 10 Sed peius victoribus Sequanis quam Haeduis victis accidisse, propterea quod Ariovistus, rex Germanorum, in eorum finibus consedisset tertiamque partem agri Sequani, qui esset optimus totius Galliae, occupavisset et nunc de altera parte tertia Sequanos decedere iuberet, propterea quod paucis mensibus ante Harudum milia hominum XXIIII ad eum venissent, quibus locus ac sedes pararentur. 11 Futurum esse paucis annis uti omnes ex Galliae finibus pellerentur atque omnes Germani Rhenum transirent; neque enim conferendum esse Gallicum cum Germanorum agro neque hanc consuetudinem victus cum illa comparandam. 12 Ariovistum autem, ut semel Gallorum copias proelio vicerit, quod proelium factum sit ad Magetobrigam, superbe et crudeliter imperare, obsides nobilissimi cuiusque liberos poscere et in eos omnia exempla cruciatusque edere, si qua res non ad nutum aut ad voluntatem eius facta sit. 13 Hominem esse barbarum, iracundum, temerarium: non posse eius imperia diutius sustineri. 14 Nisi quid in Caesare populoque Romano sit auxilii, omnibus Gallis idem esse faciendum quod Helvetii fecerint, ut domo emigrent, aliud domicilium, alias sedes, remotas a Germanis, petant fortunamque, quaecumque accidat, experiantur. Haec si enuntiata Ariovisto sint, non dubitare quin de omnibus obsidibus qui apud eum sint gravissimum supplicium sumat. 15 Caesarem vel auctoritate sua atque exercitus vel recenti victoria vel nomine populi Romani deterrere posse ne maior multitudo Germanorum Rhenum traducatur, Galliamque omnem ab Ariovisti iniuria posse defendere.
|
||||||
|
[32] 1 Hac oratione ab Diviciaco habita omnes qui aderant magno fletu auxilium a Caesare petere coeperunt. 2 Animadvertit Caesar unos ex omnibus Sequanos nihil earum rerum facere quas ceteri facerent sed tristes capite demisso terram intueri. Eius rei quae causa esset miratus ex ipsis quaesiit. 3 Nihil Sequani respondere, sed in eadem tristitia taciti permanere. Cum ab his saepius quaereret neque ullam omnino vocem exprimere posset, idem Diviacus Haeduus respondit: 4 hoc esse miseriorem et graviorem fortunam Sequanorum quam reliquorum, quod soli ne in occulto quidem queri neque auxilium implorare auderent absentisque Ariovisti crudelitatem, 5 velut si coram adesset, horrerent, propterea quod reliquis tamen fugae facultas daretur, Sequanis vero, qui intra fines suos Ariovistum recepissent, quorum oppida omnia in potestate eius essent, omnes cruciatus essent perferendi.
|
||||||
|
[33] 1 His rebus cognitis Caesar Gallorum animos verbis confirmavit pollicitusque est sibi eam rem curae futuram; magnam se habere spem et beneficio suo et auctoritate adductum Ariovistum finem iniuriis facturum. Hac oratione habita, concilium dimisit. 2 Et secundum ea multae res eum hortabantur quare sibi eam rem cogitandam et suscipiendam putaret, in primis quod Haeduos, fratres consanguineosque saepe numero a senatu appellatos, in servitute atque [in] dicione videbat Germanorum teneri eorumque obsides esse apud Ariovistum ac Sequanos intellegebat; quod in tanto imperio populi Romani turpissimum sibi et rei publicae esse arbitrabatur. 3 Paulatim autem Germanos consuescere Rhenum transire et in Galliam magnam eorum multitudinem venire populo Romano periculosum videbat, neque sibi homines feros ac barbaros temperaturos existimabat quin, cum omnem Galliam occupavissent, ut ante Cimbri Teutonique fecissent, in provinciam exirent atque inde in Italiam contenderent [, praesertim cum Sequanos a provincia nostra Rhodanus divideret]; quibus rebus quam maturrime occurrendum putabat. 4 Ipse autem Ariovistus tantos sibi spiritus, tantam arrogantiam sumpserat, ut ferendus non videretur.
|
||||||
|
[34] 1 Quam ob rem placuit ei ut ad Ariovistum legatos mitteret, qui ab eo postularent uti aliquem locum medium utrisque conloquio deligeret: velle sese de re publica et summis utriusque rebus cum eo agere. 2 Ei legationi Ariovistus respondit: si quid ipsi a Caesare opus esset, sese ad eum venturum fuisse; si quid ille se velit, illum ad se venire oportere. 3 Praeterea se neque sine exercitu in eas partes Galliae venire audere quas Caesar possideret, neque exercitum sine magno commeatu atque molimento in unum locum contrahere posse. 4 Sibi autem mirum videri quid in sua Gallia, quam bello vicisset, aut Caesari aut omnino populo Romano negotii esset.
|
||||||
|
[35] 1 His responsis ad Caesarem relatis, iterum ad eum Caesar legatos cum his mandatis mittit: 2 quoniam tanto suo populique Romani beneficio adfectus, cum in consulatu suo rex atque amicus a senatu appellatus esset, hanc sibi populoque Romano gratiam referret ut in conloquium venire invitatus gravaretur neque de communi re dicendum sibi et cognoscendum putaret, haec esse quae ab eo postularet: 3 primum ne quam multitudinem hominum amplius trans Rhenum in Galliam traduceret; deinde obsides quos haberet ab Haeduis redderet Sequanisque permitteret ut quos illi haberent voluntate eius reddere illis liceret; neve Haeduos iniuria lacesseret neve his sociisque eorum bellum inferret. 4 Si [id] ita fecisset, sibi populoque Romano perpetuam gratiam atque amicitiam cum eo futuram; si non impetraret, sese, quoniam M. Messala, M. Pisone consulibus senatus censuisset uti quicumque Galliam provinciam obtineret, quod commodo rei publicae facere posset, Haeduos ceterosque amicos populi Romani defenderet, se Haeduorum iniurias non neglecturum.
|
||||||
|
[36] 1 Ad haec Ariovistus respondit: ius esse belli ut qui vicissent iis quos vicissent quem ad modum vellent imperarent. Item populum Romanum victis non ad alterius praescriptum, sed ad suum arbitrium imperare consuesse. 2 Si ipse populo Romano non praescriberet quem ad modum suo iure uteretur, non oportere se a populo Romano in suo iure impediri. 3 Haeduos sibi, quoniam belli fortunam temptassent et armis congressi ac superati essent, stipendiarios esse factos. 4 Magnam Caesarem iniuriam facere, qui suo adventu vectigalia sibi deteriora faceret. 5 Haeduis se obsides redditurum non esse neque his neque eorum sociis iniuria bellum inlaturum, si in eo manerent quod convenisset stipendiumque quotannis penderent; si id non fecissent, longe iis fraternum nomen populi Romani afuturum. 6 Quod sibi Caesar denuntiaret se Haeduorum iniurias non neglecturum, neminem secum sine sua pernicie contendisse. Cum vellet, congrederetur: intellecturum quid invicti Germani, exercitatissimi in armis, qui inter annos XIIII tectum non subissent, virtute possent.
|
||||||
|
[37] 1 Haec eodem tempore Caesari mandata referebantur et legati ab Haeduis et a Treveris veniebant: 2 Haedui questum quod Harudes, qui nuper in Galliam transportati essent, fines eorum popularentur: sese ne obsidibus quidem datis pacem Ariovisti redimere potuisse; 3 Treveri autem, pagos centum Sueborum ad ripas Rheni consedisse, qui Rhenum transire conarentur; his praeesse Nasuam et Cimberium fratres. Quibus rebus Caesar vehementer commotus maturandum sibi existimavit, ne, si nova manus Sueborum cum veteribus copiis Ariovisti sese coniunxisset, minus facile resisti posset. 4 Itaque re frumentaria quam celerrime potuit comparata magnis itineribus ad Ariovistum contendit.
|
||||||
|
[38] 1 Cum tridui viam processisset, nuntiatum est ei Ariovistum cum suis omnibus copiis ad occupandum Vesontionem, quod est oppidum maximum Sequanorum, contendere [triduique viam a suis finibus processisse]. Id ne accideret, magnopere sibi praecavendum Caesar existimabat. Namque omnium rerum quae ad bellum usui erant summa erat in eo oppido facultas, 2 idque natura loci sic muniebatur ut magnam ad ducendum bellum daret facultatem, propterea quod flumen [alduas] Dubis ut circino circumductum paene totum oppidum cingit, 3 reliquum spatium, quod est non amplius pedum MDC, qua flumen intermittit, mons continet magna altitudine, ita ut radices eius montis ex utraque parte ripae fluminis contingant, 4 hunc murus circumdatus arcem efficit et cum oppido coniungit. 5 Huc Caesar magnis nocturnis diurnisque itineribus contendit occupatoque oppido ibi praesidium conlocat.
|
||||||
|
[39] 1 Dum paucos dies ad Vesontionem rei frumentariae commeatusque causa moratur, ex percontatione nostrorum vocibusque Gallorum ac mercatorum, qui ingenti magnitudine corporum Germanos, incredibili virtute atque exercitatione in armis esse praedicabant (saepe numero sese cum his congressos ne vultum quidem atque aciem oculorum dicebant ferre potuisse), tantus subito timor omnem exercitum occupavit ut non mediocriter omnium mentes animosque perturbaret. 2 Hic primum ortus est a tribunis militum, praefectis, reliquisque qui ex urbe amicitiae causa Caesarem secuti non magnum in re militari usum habebant: 3 quorum alius alia causa inlata, quam sibi ad proficiscendum necessariam esse diceret, petebat ut eius voluntate discedere liceret; non nulli pudore adducti, ut timoris suspicionem vitarent, remanebant. 4 Hi neque vultum fingere neque interdum lacrimas tenere poterant: abditi in tabernaculis aut suum fatum querebantur aut cum familiaribus suis commune periculum miserabantur. Vulgo totis castris testamenta obsignabantur. 5 Horum vocibus ac timore paulatim etiam ii qui magnum in castris usum habebant, milites centurionesque quique equitatui praeerant, perturbabantur. 6 Qui se ex his minus timidos existimari volebant, non se hostem vereri, sed angustias itineris et magnitudinem silvarum quae intercederent inter ipsos atque Ariovistum, aut rem frumentariam, ut satis commode supportari posset, timere dicebant. 7 Non nulli etiam Caesari nuntiabant, cum castra moveri ac signa ferri iussisset, non fore dicto audientes milites neque propter timorem signa laturos.
|
||||||
|
[40] 1 Haec cum animadvertisset, convocato consilio omniumque ordinum ad id consilium adhibitis centurionibus, vehementer eos incusavit: primum, quod aut quam in partem aut quo consilio ducerentur sibi quaerendum aut cogitandum putarent. 2 Ariovistum se consule cupidissime populi Romani amicitiam adpetisse; cur hunc tam temere quisquam ab officio discessurum iudicaret? 3 Sibi quidem persuaderi cognitis suis postulatis atque aequitate condicionum perspecta eum neque suam neque populi Romani gratiam repudiaturum. 4 Quod si furore atque amentia impulsum bellum intulisset, quid tandem vererentur? Aut cur de sua virtute aut de ipsius diligentia desperarent? 5 Factum eius hostis periculum patrum nostrorum memoria Cimbris et Teutonis a C. Mario pulsis [cum non minorem laudem exercitus quam ipse imperator meritus videbatur]; factum etiam nuper in Italia servili tumultu, quos tamen aliquid usus ac disciplina, quam a nobis accepissent, sublevarint. 6 Ex quo iudicari posse quantum haberet in se boni constantia, propterea quod quos aliquamdiu inermes sine causa timuissent hos postea armatos ac victores superassent. 7 Denique hos esse eosdem Germanos quibuscum saepe numero Helvetii congressi non solum in suis sed etiam in illorum finibus plerumque superarint, qui tamen pares esse nostro exercitui non potuerint. 8 Si quos adversum proelium et fuga Gallorum commoveret, hos, si quaererent, reperire posse diuturnitate belli defatigatis Gallis Ariovistum, cum multos menses castris se ac paludibus tenuisset neque sui potestatem fecisset, desperantes iam de pugna et dispersos subito adortum magis ratione et consilio quam virtute vicisse. 9 Cui rationi contra homines barbaros atque imperitos locus fuisset, hac ne ipsum quidem sperare nostros exercitus capi posse. 10 Qui suum timorem in rei frumentariae simulationem angustiasque itineris conferrent, facere arroganter, cum aut de officio imperatoris desperare aut praescribere viderentur. 11 Haec sibi esse curae; frumentum Sequanos, Leucos, Lingones subministrare, iamque esse in agris frumenta matura; de itinere ipsos brevi tempore iudicaturos. 12 Quod non fore dicto audientes neque signa laturi dicantur, nihil se ea re commoveri: scire enim, quibuscumque exercitus dicto audiens non fuerit, aut male re gesta fortunam defuisse aut aliquo facinore comperto avaritiam esse convictam. 13 Suam innocentiam perpetua vita, felicitatem Helvetiorum bello esse perspectam. 14 Itaque se quod in longiorem diem conlaturus fuisset repraesentaturum et proxima nocte de quarta vigilia castra moturum, ut quam primum intellegere posset utrum apud eos pudor atque officium an timor plus valeret. 15 Quod si praeterea nemo sequatur, tamen se cum sola decima legione iturum, de qua non dubitet, sibique eam praetoriam cohortem futuram. Huic legioni Caesar et indulserat praecipue et propter virtutem confidebat maxime.
|
||||||
|
[41] 1 Hac oratione habita mirum in modum conversae sunt omnium mentes summaque alacritas et cupiditas belli gerendi innata est, 2 princepsque X. legio per tribunos militum ei gratias egit quod de se optimum iudicium fecisset, seque esse ad bellum gerendum paratissimam confirmavit. 3 Deinde reliquae legiones cum tribunis militum et primorum ordinum centurionibus egerunt uti Caesari satis facerent: se neque umquam dubitasse neque timuisse neque de summa belli suum iudicium sed imperatoris esse existimavisse. 4 Eorum satisfactione accepta et itinere exquisito per Diviciacum, quod ex Gallis ei maximam fidem habebat, ut milium amplius quinquaginta circuitu locis apertis exercitum duceret, de quarta vigilia, ut dixerat, profectus est. 5 Septimo die, cum iter non intermitteret, ab exploratoribus certior factus est Ariovisti copias a nostris milia passuum IIII et XX abesse.
|
||||||
|
[42] 1 Cognito Caesaris adventu Ariovistus legatos ad eum mittit: quod antea de conloquio postulasset, id per se fieri licere, quoniam propius accessisset seque id sine periculo facere posse existimaret. 2 Non respuit condicionem Caesar iamque eum ad sanitatem reverti arbitrabatur, cum id quod antea petenti denegasset ultro polliceretur, 3 magnamque in spem veniebat pro suis tantis populique Romani in eum beneficiis cognitis suis postulatis fore uti pertinacia desisteret. 4 Dies conloquio dictus est ex eo die quintus. 5 Interim saepe cum legati ultro citroque inter eos mitterentur, Ariovistus postulavit ne quem peditem ad conloquium Caesar adduceret: vereri se ne per insidias ab eo circumveniretur; uterque cum equitatu veniret: alia ratione sese non esse venturum. 6 Caesar, quod neque conloquium interposita causa tolli volebat neque salutem suam Gallorum equitatui committere audebat, commodissimum esse statuit omnibus equis Gallis equitibus detractis eo legionarios milites legionis X., cui quam maxime confidebat, imponere, ut praesidium quam amicissimum, si quid opus facto esset, haberet. 7 Quod cum fieret, non inridicule quidam ex militibus X. legionis dixit: plus quam pollicitus esset Caesarem facere; pollicitum se in cohortis praetoriae loco X. legionem habiturum ad equum rescribere.
|
||||||
|
[43] 1 Planities erat magna et in ea tumulus terrenus satis grandis. Hic locus aequum fere spatium a castris Ariovisti et Caesaris aberat. Eo, ut erat dictum, ad conloquium venerunt. 2 Legionem Caesar, quam equis devexerat, passibus CC ab eo tumulo constituit. Item equites Ariovisti pari intervallo constiterunt. 3 Ariovistus ex equis ut conloquerentur et praeter se denos ad conloquium adducerent postulavit. 4 Ubi eo ventum est, Caesar initio orationis sua senatusque in eum beneficia commemoravit, quod rex appellatus esset a senatu, quod amicus, quod munera amplissime missa; quam rem et paucis contigisse et pro magnis hominum officiis consuesse tribui docebat; 5 illum, cum neque aditum neque causam postulandi iustam haberet, beneficio ac liberalitate sua ac senatus ea praemia consecutum. 6 Docebat etiam quam veteres quamque iustae causae necessitudinis ipsis cum Haeduis intercederent, 7 quae senatus consulta quotiens quamque honorifica in eos facta essent, ut omni tempore totius Galliae principatum Haedui tenuissent, prius etiam quam nostram amicitiam adpetissent. 8 Populi Romani hanc esse consuetudinem, ut socios atque amicos non modo sui nihil deperdere, sed gratia, dignitate, honore auctiores velit esse; quod vero ad amicitiam populi Romani attulissent, id iis eripi quis pati posset? 9 Postulavit deinde eadem quae legatis in mandatis dederat: ne aut Haeduis aut eorum sociis bellum inferret, obsides redderet, si nullam partem Germanorum domum remittere posset, at ne quos amplius Rhenum transire pateretur.
|
||||||
|
[44] 1 Ariovistus ad postulata Caesaris pauca respondit, de suis virtutibus multa praedicavit: 2 transisse Rhenum sese non sua sponte, sed rogatum et arcessitum a Gallis; non sine magna spe magnisque praemiis domum propinquosque reliquisse; sedes habere in Gallia ab ipsis concessas, obsides ipsorum voluntate datos; stipendium capere iure belli, quod victores victis imponere consuerint. 3 Non sese Gallis sed Gallos sibi bellum intulisse: omnes Galliae civitates ad se oppugnandum venisse ac contra se castra habuisse; eas omnes copias a se uno proelio pulsas ac superatas esse. 4 Si iterum experiri velint, se iterum paratum esse decertare; si pace uti velint, iniquum esse de stipendio recusare, quod sua voluntate ad id tempus pependerint. 5 Amicitiam populi Romani sibi ornamento et praesidio, non detrimento esse oportere, atque se hac spe petisse. Si per populum Romanum stipendium remittatur et dediticii subtrahantur, non minus libenter sese recusaturum populi Romani amicitiam quam adpetierit. 6 Quod multitudinem Germanorum in Galliam traducat, id se sui muniendi, non Galliae oppugnandae causa facere; eius rei testimonium esse quod nisi rogatus non venerit et quod bellum non intulerit sed defenderit. 7 Se prius in Galliam venisse quam populum Romanum. Numquam ante hoc tempus exercitum populi Romani Galliae provinciae finibus egressum. 8 Quid sibi vellet? Cur in suas possessiones veniret? Provinciam suam hanc esse Galliam, sicut illam nostram. Ut ipsi concedi non oporteret, si in nostros fines impetum faceret, sic item nos esse iniquos, quod in suo iure se interpellaremus. 9 Quod fratres a senatu Haeduos appellatos diceret, non se tam barbarum neque tam imperitum esse rerum ut non sciret neque bello Allobrogum proximo Haeduos Romanis auxilium tulisse neque ipsos in iis contentionibus quas Haedui secum et cum Sequanis habuissent auxilio populi Romani usos esse. 10 Debere se suspicari simulata Caesarem amicitia, quod exercitum in Gallia habeat, sui opprimendi causa habere. 11 Qui nisi decedat atque exercitum deducat ex his regionibus, sese illum non pro amico sed pro hoste habiturum. 12 Quod si eum interfecerit, multis sese nobilibus principibusque populi Romani gratum esse facturum (id se ab ipsis per eorum nuntios compertum habere), quorum omnium gratiam atque amicitiam eius morte redimere posset. 13 Quod si decessisset et liberam possessionem Galliae sibi tradidisset, magno se illum praemio remuneraturum et quaecumque bella geri vellet sine ullo eius labore et periculo confecturum.
|
||||||
|
[45] 1 Multa a Caesare in eam sententiam dicta sunt quare negotio desistere non posset: neque suam neque populi Romani consuetudinem pati ut optime meritos socios desereret, neque se iudicare Galliam potius esse Ariovisti quam populi Romani. 2 Bello superatos esse Arvernos et Rutenos a Q. Fabio Maximo, quibus populus Romanus ignovisset neque in provinciam redegisset neque stipendium posuisset. 3 Quod si antiquissimum quodque tempus spectari oporteret, populi Romani iustissimum esse in Gallia imperium; si iudicium senatus observari oporteret, liberam debere esse Galliam, quam bello victam suis legibus uti voluisset.
|
||||||
|
[46] 1 Dum haec in conloquio geruntur, Caesari nuntiatum est equites Ariovisti propius tumulum accedere et ad nostros adequitare, lapides telaque in nostros coicere. 2 Caesar loquendi finem fecit seque ad suos recepit suisque imperavit ne quod omnino telum in hostes reicerent. 3 Nam etsi sine ullo periculo legionis delectae cum equitatu proelium fore videbat, tamen committendum non putabat ut, pulsis hostibus, dici posset eos ab se per fidem in conloquio circumventos. 4 Postea quam in vulgus militum elatum est qua arrogantia in conloquio Ariovistus usus omni Gallia Romanis interdixisset, impetumque in nostros eius equites fecissent, eaque res conloquium ut diremisset, multo maior alacritas studiumque pugnandi maius exercitui iniectum est.
|
||||||
|
[47] 1 Biduo post Ariovistus ad Caesarem legatos misit: velle se de iis rebus quae inter eos agi coeptae neque perfectae essent agere cum eo: uti aut iterum conloquio diem constitueret aut, si id minus vellet, ex suis legatis aliquem ad se mitteret. 2 Conloquendi Caesari causa visa non est, et eo magis quod pridie eius diei Germani retineri non potuerant quin tela in nostros coicerent. 3 Legatum ex suis sese magno cum periculo ad eum missurum et hominibus feris obiecturum existimabat. 4 Commodissimum visum est C. Valerium Procillum, C. Valerii Caburi filium, summa virtute et humanitate adulescentem, cuius pater a C. Valerio Flacco civitate donatus erat, et propter fidem et propter linguae Gallicae scientiam, qua multa iam Ariovistus longinqua consuetudine utebatur, et quod in eo peccandi Germanis causa non esset, ad eum mittere, et una M. Metium, qui hospitio Ariovisti utebatur. 5 His mandavit quae diceret Ariovistus cognoscerent et ad se referrent. Quos cum apud se in castris Ariovistus conspexisset, exercitu suo praesente conclamavit: quid ad se venirent? an speculandi causa? Conantes dicere prohibuit et in catenas coniecit.
|
||||||
|
[48] 1 Eodem die castra promovit et milibus passuum VI a Caesaris castris sub monte consedit. 2 Postridie eius diei praeter castra Caesaris suas copias traduxit et milibus passuum duobus ultra eum castra fecit eo consilio uti frumento commeatuque qui ex Sequanis et Haeduis supportaretur Caesarem intercluderet. 3 Ex eo die dies continuos V Caesar pro castris suas copias produxit et aciem instructam habuit, ut, si vellet Ariovistus proelio contendere, ei potestas non deesset. 4 Ariovistus his omnibus diebus exercitum castris continuit, equestri proelio cotidie contendit. Genus hoc erat pugnae, quo se Germani exercuerant: 5 equitum milia erant VI, totidem numero pedites velocissimi ac fortissimi, quos ex omni copia singuli singulos suae salutis causa delegerant: 6 cum his in proeliis versabantur, ad eos se equites recipiebant; hi, si quid erat durius, concurrebant, si qui graviore vulnere accepto equo deciderat, circumsistebant; 7 si quo erat longius prodeundum aut celerius recipiendum, tanta erat horum exercitatione celeritas ut iubis sublevati equorum cursum adaequarent.
|
||||||
|
[49] 1 Ubi eum castris se tenere Caesar intellexit, ne diutius commeatu prohiberetur, ultra eum locum, quo in loco Germani consederant, circiter passus DC ab his, castris idoneum locum delegit acieque triplici instructa ad eum locum venit. 2 Primam et secundam aciem in armis esse, tertiam castra munire iussit. 3 [Hic locus ab hoste circiter passus DC, uti dictum est, aberat.] Eo circiter hominum XVI milia expedita cum omni equitatu Ariovistus misit, quae copiae nostros terrerent et munitione prohiberent. 4 Nihilo setius Caesar, ut ante constituerat, duas acies hostem propulsare, tertiam opus perficere iussit. Munitis castris duas ibi legiones reliquit et partem auxiliorum, quattuor reliquas legiones in castra maiora reduxit.
|
||||||
|
[50] 1 Proximo die instituto suo Caesar ex castris utrisque copias suas eduxit paulumque a maioribus castris progressus aciem instruxit hostibusque pugnandi potestatem fecit. 2 Ubi ne tum quidem eos prodire intellexit, circiter meridiem exercitum in castra reduxit. Tum demum Ariovistus partem suarum copiarum, quae castra minora oppugnaret, misit. Acriter utrimque usque ad vesperum pugnatum est. Solis occasu suas copias Ariovistus multis et inlatis et acceptis vulneribus in castra reduxit. 3 Cum ex captivis quaereret Caesar quam ob rem Ariovistus proelio non decertaret, hanc reperiebat causam, quod apud Germanos ea consuetudo esset ut matres familiae eorum sortibus et vaticinationibus declararent utrum proelium committi ex usu esset necne; eas ita dicere: 4 non esse fas Germanos superare, si ante novam lunam proelio contendissent.
|
||||||
|
[51] 1 Postridie eius diei Caesar praesidio utrisque castris quod satis esse visum est reliquit, alarios omnes in conspectu hostium pro castris minoribus constituit, quod minus multitudine militum legionariorum pro hostium numero valebat, ut ad speciem alariis uteretur; ipse triplici instructa acie usque ad castra hostium accessit. 2 Tum demum necessario Germani suas copias castris eduxerunt generatimque constituerunt paribus intervallis, Harudes, Marcomanos, Tribocos, Vangiones, Nemetes, Sedusios, Suebos, omnemque aciem suam raedis et carris circumdederunt, ne qua spes in fuga relinqueretur. 3 Eo mulieres imposuerunt, quae ad proelium proficiscentes milites passis manibus flentes implorabant ne se in servitutem Romanis traderent.
|
||||||
|
[52] 1 Caesar singulis legionibus singulos legatos et quaestorem praefecit, uti eos testes suae quisque virtutis haberet; 2 ipse a dextro cornu, quod eam partem minime firmam hostium esse animadverterat, proelium commisit. 3 Ita nostri acriter in hostes signo dato impetum fecerunt itaque hostes repente celeriterque procurrerunt, ut spatium pila in hostes coiciendi non daretur. 4 Relictis pilis comminus gladiis pugnatum est. At Germani celeriter ex consuetudine sua phalange facta impetus gladiorum exceperunt. 5 Reperti sunt complures nostri qui in phalanga insilirent et scuta manibus revellerent et desuper vulnerarent. 6 Cum hostium acies a sinistro cornu pulsa atque in fugam coniecta esset, a dextro cornu vehementer multitudine suorum nostram aciem premebant. 7 Id cum animadvertisset P. Crassus adulescens, qui equitatui praeerat, quod expeditior erat quam ii qui inter aciem versabantur, tertiam aciem laborantibus nostris subsidio misit.
|
||||||
|
[53] 1 Ita proelium restitutum est, atque omnes hostes terga verterunt nec prius fugere destiterunt quam ad flumen Rhenum milia passuum ex eo loco circiter L pervenerunt. 2 Ibi perpauci aut viribus confisi tranare contenderunt aut lintribus inventis sibi salutem reppererunt. 3 In his fuit Ariovistus, qui naviculam deligatam ad ripam nactus ea profugit; reliquos omnes consecuti equites nostri interfecerunt. 4 Duae fuerunt Ariovisti uxores, una Sueba natione, quam domo secum eduxerat, altera Norica, regis Voccionis soror, quam in Gallia duxerat a fratre missam: utraque in ea fuga periit; duae filiae: harum altera occisa, altera capta est. 5 C. Valerius Procillus, cum a custodibus in fuga trinis catenis vinctus traheretur, in ipsum Caesarem hostes equitatu insequentem incidit. 6 Quae quidem res Caesari non minorem quam ipsa victoria voluptatem attulit, quod hominem honestissimum provinciae Galliae, suum familiarem et hospitem, ereptum ex manibus hostium sibi restitutum videbat neque eius calamitate de tanta voluptate et gratulatione quicquam fortuna deminuerat. 7 Is se praesente de se ter sortibus consultum dicebat, utrum igni statim necaretur an in aliud tempus reservaretur: sortium beneficio se esse incolumem. 8 Item M. Metius repertus et ad eum reductus est.
|
||||||
|
[54] 1 Hoc proelio trans Rhenum nuntiato, Suebi, qui ad ripas Rheni venerant, domum reverti coeperunt; quos ubi qui proximi Rhenum incolunt perterritos senserunt, insecuti magnum ex iis numerum occiderunt. 2 Caesar una aestate duobus maximis bellis confectis maturius paulo quam tempus anni postulabat in hiberna in Sequanos exercitum deduxit; hibernis Labienum praeposuit; 3 ipse in citeriorem Galliam ad conventus agendos profectus est.
|
||||||
|
|
||||||
|
Caesar
|
||||||
|
The Latin Library
|
||||||
|
The Classics Page
|
||||||
|
|
||||||
10851
python_pkg/word_frequency/test_texts/polish_pan_tadeusz.txt
Normal file
10851
python_pkg/word_frequency/test_texts/polish_pan_tadeusz.txt
Normal file
File diff suppressed because it is too large
Load Diff
1
python_pkg/word_frequency/tests/__init__.py
Normal file
1
python_pkg/word_frequency/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for word_frequency module."""
|
||||||
306
python_pkg/word_frequency/tests/test_analyzer.py
Normal file
306
python_pkg/word_frequency/tests/test_analyzer.py
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
"""Tests for word_frequency.analyzer module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from python_pkg.word_frequency.analyzer import (
|
||||||
|
analyze_and_format,
|
||||||
|
analyze_text,
|
||||||
|
extract_words,
|
||||||
|
format_results,
|
||||||
|
main,
|
||||||
|
read_file,
|
||||||
|
read_files,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractWords:
|
||||||
|
"""Tests for extract_words function."""
|
||||||
|
|
||||||
|
def test_basic_extraction(self) -> None:
|
||||||
|
"""Test basic word extraction."""
|
||||||
|
text = "Hello world"
|
||||||
|
result = extract_words(text)
|
||||||
|
assert result == ["hello", "world"]
|
||||||
|
|
||||||
|
def test_case_insensitive_default(self) -> None:
|
||||||
|
"""Test that extraction is case-insensitive by default."""
|
||||||
|
text = "Hello WORLD HeLLo"
|
||||||
|
result = extract_words(text)
|
||||||
|
assert result == ["hello", "world", "hello"]
|
||||||
|
|
||||||
|
def test_case_sensitive(self) -> None:
|
||||||
|
"""Test case-sensitive extraction."""
|
||||||
|
text = "Hello WORLD HeLLo"
|
||||||
|
result = extract_words(text, case_sensitive=True)
|
||||||
|
assert result == ["Hello", "WORLD", "HeLLo"]
|
||||||
|
|
||||||
|
def test_unicode_words(self) -> None:
|
||||||
|
"""Test extraction of unicode words (Polish, Latin accents)."""
|
||||||
|
text = "zażółć gęślą jaźń"
|
||||||
|
result = extract_words(text)
|
||||||
|
assert result == ["zażółć", "gęślą", "jaźń"]
|
||||||
|
|
||||||
|
def test_punctuation_removal(self) -> None:
|
||||||
|
"""Test that punctuation is not included in words."""
|
||||||
|
text = "Hello, world! How are you?"
|
||||||
|
result = extract_words(text)
|
||||||
|
assert result == ["hello", "world", "how", "are", "you"]
|
||||||
|
|
||||||
|
def test_numbers_included(self) -> None:
|
||||||
|
"""Test that numbers are included as words."""
|
||||||
|
text = "There are 123 apples and 456 oranges"
|
||||||
|
result = extract_words(text)
|
||||||
|
assert "123" in result
|
||||||
|
assert "456" in result
|
||||||
|
|
||||||
|
def test_empty_string(self) -> None:
|
||||||
|
"""Test extraction from empty string."""
|
||||||
|
result = extract_words("")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_only_punctuation(self) -> None:
|
||||||
|
"""Test extraction from string with only punctuation."""
|
||||||
|
result = extract_words("!@#$%^&*()")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_hyphenated_words(self) -> None:
|
||||||
|
"""Test handling of hyphenated words (split into parts)."""
|
||||||
|
text = "well-known self-aware"
|
||||||
|
result = extract_words(text)
|
||||||
|
# Hyphens act as word boundaries with \b
|
||||||
|
assert "well" in result
|
||||||
|
assert "known" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyzeText:
|
||||||
|
"""Tests for analyze_text function."""
|
||||||
|
|
||||||
|
def test_basic_counting(self) -> None:
|
||||||
|
"""Test basic word counting."""
|
||||||
|
text = "hello world hello"
|
||||||
|
result = analyze_text(text)
|
||||||
|
assert result["hello"] == 2
|
||||||
|
assert result["world"] == 1
|
||||||
|
|
||||||
|
def test_case_insensitive_counting(self) -> None:
|
||||||
|
"""Test case-insensitive counting."""
|
||||||
|
text = "Hello HELLO hello"
|
||||||
|
result = analyze_text(text)
|
||||||
|
assert result["hello"] == 3
|
||||||
|
|
||||||
|
def test_case_sensitive_counting(self) -> None:
|
||||||
|
"""Test case-sensitive counting."""
|
||||||
|
text = "Hello HELLO hello"
|
||||||
|
result = analyze_text(text, case_sensitive=True)
|
||||||
|
assert result["Hello"] == 1
|
||||||
|
assert result["HELLO"] == 1
|
||||||
|
assert result["hello"] == 1
|
||||||
|
|
||||||
|
def test_returns_counter(self) -> None:
|
||||||
|
"""Test that result is a Counter object."""
|
||||||
|
result = analyze_text("test")
|
||||||
|
assert isinstance(result, Counter)
|
||||||
|
|
||||||
|
def test_empty_text(self) -> None:
|
||||||
|
"""Test analysis of empty text."""
|
||||||
|
result = analyze_text("")
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadFile:
|
||||||
|
"""Tests for read_file function."""
|
||||||
|
|
||||||
|
def test_read_existing_file(self, tmp_path: Path) -> None:
|
||||||
|
"""Test reading an existing file."""
|
||||||
|
test_file = tmp_path / "test.txt"
|
||||||
|
test_file.write_text("Hello world", encoding="utf-8")
|
||||||
|
result = read_file(test_file)
|
||||||
|
assert result == "Hello world"
|
||||||
|
|
||||||
|
def test_read_utf8_content(self, tmp_path: Path) -> None:
|
||||||
|
"""Test reading UTF-8 content with special characters."""
|
||||||
|
test_file = tmp_path / "test.txt"
|
||||||
|
test_file.write_text("zażółć gęślą jaźń", encoding="utf-8")
|
||||||
|
result = read_file(test_file)
|
||||||
|
assert result == "zażółć gęślą jaźń"
|
||||||
|
|
||||||
|
def test_file_not_found(self) -> None:
|
||||||
|
"""Test that FileNotFoundError is raised for missing file."""
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
read_file("/nonexistent/path/file.txt")
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadFiles:
|
||||||
|
"""Tests for read_files function."""
|
||||||
|
|
||||||
|
def test_read_multiple_files(self, tmp_path: Path) -> None:
|
||||||
|
"""Test reading multiple files."""
|
||||||
|
file1 = tmp_path / "file1.txt"
|
||||||
|
file2 = tmp_path / "file2.txt"
|
||||||
|
file1.write_text("Hello", encoding="utf-8")
|
||||||
|
file2.write_text("World", encoding="utf-8")
|
||||||
|
result = read_files([file1, file2])
|
||||||
|
assert "Hello" in result
|
||||||
|
assert "World" in result
|
||||||
|
|
||||||
|
def test_empty_list(self) -> None:
|
||||||
|
"""Test reading empty list of files."""
|
||||||
|
result = read_files([])
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatResults:
|
||||||
|
"""Tests for format_results function."""
|
||||||
|
|
||||||
|
def test_basic_formatting(self) -> None:
|
||||||
|
"""Test basic result formatting."""
|
||||||
|
counter: Counter[str] = Counter({"hello": 3, "world": 2})
|
||||||
|
result = format_results(counter)
|
||||||
|
assert "Total words: 5" in result
|
||||||
|
assert "Unique words: 2" in result
|
||||||
|
assert "hello" in result
|
||||||
|
assert "world" in result
|
||||||
|
assert "60.00%" in result # hello percentage
|
||||||
|
assert "40.00%" in result # world percentage
|
||||||
|
|
||||||
|
def test_top_n_limit(self) -> None:
|
||||||
|
"""Test limiting results to top N."""
|
||||||
|
counter: Counter[str] = Counter({"a": 10, "b": 5, "c": 3, "d": 1})
|
||||||
|
result = format_results(counter, top_n=2)
|
||||||
|
assert "a" in result
|
||||||
|
assert "b" in result
|
||||||
|
# c and d should not appear in the data rows
|
||||||
|
lines = result.split("\n")
|
||||||
|
data_lines = [line for line in lines if line.strip() and "%" in line]
|
||||||
|
assert len(data_lines) == 2
|
||||||
|
|
||||||
|
def test_empty_counter(self) -> None:
|
||||||
|
"""Test formatting empty counter."""
|
||||||
|
counter: Counter[str] = Counter()
|
||||||
|
result = format_results(counter)
|
||||||
|
assert "No words found" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyzeAndFormat:
|
||||||
|
"""Tests for analyze_and_format function."""
|
||||||
|
|
||||||
|
def test_full_pipeline(self) -> None:
|
||||||
|
"""Test the full analyze and format pipeline."""
|
||||||
|
text = "hello world hello"
|
||||||
|
result = analyze_and_format(text)
|
||||||
|
assert "Total words: 3" in result
|
||||||
|
assert "hello" in result
|
||||||
|
assert "66.67%" in result # hello appears 2/3 times
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
"""Tests for main CLI function."""
|
||||||
|
|
||||||
|
def test_text_input(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Test --text input option."""
|
||||||
|
exit_code = main(["--text", "hello world hello"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert exit_code == 0
|
||||||
|
assert "hello" in captured.out
|
||||||
|
assert "world" in captured.out
|
||||||
|
|
||||||
|
def test_file_input(
|
||||||
|
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Test --file input option."""
|
||||||
|
test_file = tmp_path / "test.txt"
|
||||||
|
test_file.write_text("hello world hello", encoding="utf-8")
|
||||||
|
exit_code = main(["--file", str(test_file)])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert exit_code == 0
|
||||||
|
assert "hello" in captured.out
|
||||||
|
|
||||||
|
def test_files_input(
|
||||||
|
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Test --files input option."""
|
||||||
|
file1 = tmp_path / "file1.txt"
|
||||||
|
file2 = tmp_path / "file2.txt"
|
||||||
|
file1.write_text("hello hello", encoding="utf-8")
|
||||||
|
file2.write_text("world world world", encoding="utf-8")
|
||||||
|
exit_code = main(["--files", str(file1), str(file2)])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert exit_code == 0
|
||||||
|
assert "hello" in captured.out
|
||||||
|
assert "world" in captured.out
|
||||||
|
|
||||||
|
def test_top_n_option(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Test --top option to limit results."""
|
||||||
|
exit_code = main(["--text", "a a a b b c d e f g", "--top", "2"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert exit_code == 0
|
||||||
|
# Count data lines with percentages
|
||||||
|
lines = [line for line in captured.out.split("\n") if "%" in line]
|
||||||
|
assert len(lines) == 2
|
||||||
|
|
||||||
|
def test_case_sensitive_option(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Test --case-sensitive option."""
|
||||||
|
exit_code = main(["--text", "Hello HELLO hello", "--case-sensitive"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert exit_code == 0
|
||||||
|
assert "Unique words: 3" in captured.out
|
||||||
|
|
||||||
|
def test_file_not_found_error(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Test error handling for missing file."""
|
||||||
|
exit_code = main(["--file", "/nonexistent/file.txt"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert exit_code == 1
|
||||||
|
assert "Error" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
class TestPerformance:
|
||||||
|
"""Performance tests for word frequency analyzer."""
|
||||||
|
|
||||||
|
def test_large_text_performance(self) -> None:
|
||||||
|
"""Test that analyzing large text with 10k top words completes in < 10s."""
|
||||||
|
# Generate a large text with many unique words
|
||||||
|
# We'll create ~100k words to ensure a good stress test
|
||||||
|
words = [f"word{i}" for i in range(10000)]
|
||||||
|
# Repeat each word a varying number of times
|
||||||
|
text_parts = []
|
||||||
|
for i, word in enumerate(words):
|
||||||
|
# More common words appear more often
|
||||||
|
count = 10000 - i
|
||||||
|
text_parts.extend([word] * max(1, count // 100))
|
||||||
|
|
||||||
|
large_text = " ".join(text_parts)
|
||||||
|
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
result = analyze_and_format(large_text, top_n=10000)
|
||||||
|
elapsed = time.perf_counter() - start_time
|
||||||
|
|
||||||
|
assert elapsed < 10.0, f"Analysis took {elapsed:.2f}s, expected < 10s"
|
||||||
|
assert "word0" in result # Most common word should be present
|
||||||
|
|
||||||
|
def test_bible_sized_text_performance(self, tmp_path: Path) -> None:
|
||||||
|
"""Test with Bible-sized text (~800k words)."""
|
||||||
|
# Generate text similar in size to the Bible
|
||||||
|
base_words = ["the", "and", "of", "to", "in", "a", "that", "is", "was", "for"]
|
||||||
|
text_parts = []
|
||||||
|
for _ in range(80000): # ~800k words total
|
||||||
|
text_parts.extend(base_words)
|
||||||
|
|
||||||
|
large_text = " ".join(text_parts)
|
||||||
|
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
result = analyze_and_format(large_text, top_n=10000)
|
||||||
|
elapsed = time.perf_counter() - start_time
|
||||||
|
|
||||||
|
assert elapsed < 10.0, f"Analysis took {elapsed:.2f}s, expected < 10s"
|
||||||
|
assert "the" in result
|
||||||
431
python_pkg/word_frequency/tests/test_excerpt_finder.py
Normal file
431
python_pkg/word_frequency/tests/test_excerpt_finder.py
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
"""Tests for word_frequency.excerpt_finder module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from python_pkg.word_frequency.excerpt_finder import (
|
||||||
|
ExcerptResult,
|
||||||
|
find_best_excerpt,
|
||||||
|
find_best_excerpt_with_context,
|
||||||
|
format_excerpt_results,
|
||||||
|
main,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindBestExcerpt:
|
||||||
|
"""Tests for find_best_excerpt function."""
|
||||||
|
|
||||||
|
def test_basic_example(self) -> None:
|
||||||
|
"""Test the example from the user request."""
|
||||||
|
text = "they went somewhere he and she and the guy"
|
||||||
|
result = find_best_excerpt(text, ["and", "the"], excerpt_length=3)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should find an excerpt with 66.67% match (2/3)
|
||||||
|
assert result[0].match_count == 2
|
||||||
|
assert result[0].match_percentage == pytest.approx(66.67, rel=0.01)
|
||||||
|
|
||||||
|
def test_all_matching_words(self) -> None:
|
||||||
|
"""Test when all words in excerpt match target words."""
|
||||||
|
text = "the and the and the"
|
||||||
|
result = find_best_excerpt(text, ["the", "and"], excerpt_length=3)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].match_count == 3
|
||||||
|
assert result[0].match_percentage == 100.0
|
||||||
|
|
||||||
|
def test_no_matching_words(self) -> None:
|
||||||
|
"""Test when no words match target words."""
|
||||||
|
text = "hello world foo bar"
|
||||||
|
result = find_best_excerpt(text, ["xyz", "abc"], excerpt_length=2)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].match_count == 0
|
||||||
|
assert result[0].match_percentage == 0.0
|
||||||
|
|
||||||
|
def test_top_n_results(self) -> None:
|
||||||
|
"""Test getting multiple top results."""
|
||||||
|
text = "they went somewhere he and she and the guy"
|
||||||
|
result = find_best_excerpt(text, ["and", "the"], excerpt_length=3, top_n=5)
|
||||||
|
|
||||||
|
# Should have multiple results
|
||||||
|
assert len(result) >= 3
|
||||||
|
# First results should have higher or equal match counts than later ones
|
||||||
|
for i in range(len(result) - 1):
|
||||||
|
assert result[i].match_count >= result[i + 1].match_count
|
||||||
|
|
||||||
|
def test_case_insensitive_default(self) -> None:
|
||||||
|
"""Test case-insensitive matching by default."""
|
||||||
|
text = "THE And THE and THE"
|
||||||
|
result = find_best_excerpt(text, ["the", "AND"], excerpt_length=3)
|
||||||
|
|
||||||
|
assert result[0].match_count == 3
|
||||||
|
|
||||||
|
def test_case_sensitive(self) -> None:
|
||||||
|
"""Test case-sensitive matching."""
|
||||||
|
text = "THE And THE and THE"
|
||||||
|
result = find_best_excerpt(
|
||||||
|
text, ["the", "and"], excerpt_length=3, case_sensitive=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# "THE" won't match "the", "And" won't match "and"
|
||||||
|
# Only "and" matches in position 3
|
||||||
|
assert result[0].match_count < 3
|
||||||
|
|
||||||
|
def test_empty_text(self) -> None:
|
||||||
|
"""Test with empty text."""
|
||||||
|
result = find_best_excerpt("", ["the"], excerpt_length=3)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_text_shorter_than_excerpt(self) -> None:
|
||||||
|
"""Test when text is shorter than requested excerpt."""
|
||||||
|
result = find_best_excerpt("hello world", ["hello"], excerpt_length=5)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_zero_excerpt_length(self) -> None:
|
||||||
|
"""Test with zero excerpt length."""
|
||||||
|
result = find_best_excerpt("hello world", ["hello"], excerpt_length=0)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_negative_excerpt_length(self) -> None:
|
||||||
|
"""Test with negative excerpt length."""
|
||||||
|
result = find_best_excerpt("hello world", ["hello"], excerpt_length=-1)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_excerpt_at_text_boundaries(self) -> None:
|
||||||
|
"""Test that excerpts at start and end of text are found."""
|
||||||
|
text = "the the the middle words here end end end"
|
||||||
|
result = find_best_excerpt(text, ["the"], excerpt_length=3, top_n=10)
|
||||||
|
|
||||||
|
# Check that we find the "the the the" at the start
|
||||||
|
excerpts = [r.excerpt for r in result]
|
||||||
|
assert "the the the" in excerpts
|
||||||
|
|
||||||
|
def test_unicode_words(self) -> None:
|
||||||
|
"""Test with Polish/unicode words."""
|
||||||
|
text = "zażółć gęślą jaźń i w się nie"
|
||||||
|
result = find_best_excerpt(text, ["zażółć", "jaźń"], excerpt_length=3)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
# "zażółć gęślą jaźń" should have 2 matches
|
||||||
|
assert result[0].match_count == 2
|
||||||
|
|
||||||
|
def test_result_structure(self) -> None:
|
||||||
|
"""Test that result has correct structure."""
|
||||||
|
text = "hello world test"
|
||||||
|
result = find_best_excerpt(text, ["hello"], excerpt_length=2)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert isinstance(result[0], ExcerptResult)
|
||||||
|
assert isinstance(result[0].excerpt, str)
|
||||||
|
assert isinstance(result[0].words, list)
|
||||||
|
assert isinstance(result[0].start_index, int)
|
||||||
|
assert isinstance(result[0].end_index, int)
|
||||||
|
assert isinstance(result[0].match_count, int)
|
||||||
|
assert isinstance(result[0].match_percentage, float)
|
||||||
|
|
||||||
|
def test_word_indices(self) -> None:
|
||||||
|
"""Test that word indices are correct."""
|
||||||
|
text = "a b c d e"
|
||||||
|
result = find_best_excerpt(text, ["c"], excerpt_length=1)
|
||||||
|
|
||||||
|
# "c" is at index 2
|
||||||
|
assert result[0].start_index == 2
|
||||||
|
assert result[0].end_index == 3
|
||||||
|
assert result[0].excerpt == "c"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindBestExcerptWithContext:
|
||||||
|
"""Tests for find_best_excerpt_with_context function."""
|
||||||
|
|
||||||
|
def test_no_context(self) -> None:
|
||||||
|
"""Test with zero context (should behave like find_best_excerpt)."""
|
||||||
|
text = "a b c d e f g"
|
||||||
|
result = find_best_excerpt_with_context(
|
||||||
|
text, ["c"], excerpt_length=1, context_words=0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result[0].excerpt == "c"
|
||||||
|
|
||||||
|
def test_with_context(self) -> None:
|
||||||
|
"""Test with context words."""
|
||||||
|
text = "a b c d e f g"
|
||||||
|
result = find_best_excerpt_with_context(
|
||||||
|
text, ["d"], excerpt_length=1, context_words=2
|
||||||
|
)
|
||||||
|
|
||||||
|
# "d" at index 3, with context should include 2 words before and after
|
||||||
|
# Result should be "b c d e f"
|
||||||
|
assert "d" in result[0].excerpt
|
||||||
|
assert len(result[0].words) == 5
|
||||||
|
|
||||||
|
def test_context_at_start(self) -> None:
|
||||||
|
"""Test context doesn't go before start of text."""
|
||||||
|
text = "a b c d e"
|
||||||
|
result = find_best_excerpt_with_context(
|
||||||
|
text, ["a"], excerpt_length=1, context_words=3
|
||||||
|
)
|
||||||
|
|
||||||
|
# Can't go before "a", so just get words after
|
||||||
|
assert result[0].start_index == 0
|
||||||
|
assert result[0].words[0] == "a"
|
||||||
|
|
||||||
|
def test_context_at_end(self) -> None:
|
||||||
|
"""Test context doesn't go beyond end of text."""
|
||||||
|
text = "a b c d e"
|
||||||
|
result = find_best_excerpt_with_context(
|
||||||
|
text, ["e"], excerpt_length=1, context_words=3
|
||||||
|
)
|
||||||
|
|
||||||
|
# Can't go beyond "e"
|
||||||
|
assert result[0].words[-1] == "e"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatExcerptResults:
|
||||||
|
"""Tests for format_excerpt_results function."""
|
||||||
|
|
||||||
|
def test_single_result(self) -> None:
|
||||||
|
"""Test formatting a single result."""
|
||||||
|
results = [
|
||||||
|
ExcerptResult(
|
||||||
|
excerpt="hello world",
|
||||||
|
words=["hello", "world"],
|
||||||
|
start_index=0,
|
||||||
|
end_index=2,
|
||||||
|
match_count=1,
|
||||||
|
match_percentage=50.0,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
output = format_excerpt_results(results, ["hello"])
|
||||||
|
|
||||||
|
assert "hello" in output
|
||||||
|
assert "50.00%" in output
|
||||||
|
assert "hello world" in output
|
||||||
|
|
||||||
|
def test_multiple_results(self) -> None:
|
||||||
|
"""Test formatting multiple results."""
|
||||||
|
results = [
|
||||||
|
ExcerptResult(
|
||||||
|
excerpt="a b",
|
||||||
|
words=["a", "b"],
|
||||||
|
start_index=0,
|
||||||
|
end_index=2,
|
||||||
|
match_count=2,
|
||||||
|
match_percentage=100.0,
|
||||||
|
),
|
||||||
|
ExcerptResult(
|
||||||
|
excerpt="c d",
|
||||||
|
words=["c", "d"],
|
||||||
|
start_index=2,
|
||||||
|
end_index=4,
|
||||||
|
match_count=1,
|
||||||
|
match_percentage=50.0,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
output = format_excerpt_results(results, ["a", "b"])
|
||||||
|
|
||||||
|
assert "Result #1" in output
|
||||||
|
assert "Result #2" in output
|
||||||
|
|
||||||
|
def test_empty_results(self) -> None:
|
||||||
|
"""Test formatting empty results."""
|
||||||
|
output = format_excerpt_results([], ["hello"])
|
||||||
|
assert "No excerpts found" in output
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
"""Tests for main CLI function."""
|
||||||
|
|
||||||
|
def test_text_and_words_input(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Test --text and --words options."""
|
||||||
|
exit_code = main(
|
||||||
|
["--text", "hello world hello", "--words", "hello", "--length", "2"]
|
||||||
|
)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
assert "hello" in captured.out
|
||||||
|
|
||||||
|
def test_file_input(
|
||||||
|
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Test --file input option."""
|
||||||
|
test_file = tmp_path / "test.txt"
|
||||||
|
test_file.write_text("hello world hello world", encoding="utf-8")
|
||||||
|
|
||||||
|
exit_code = main(
|
||||||
|
["--file", str(test_file), "--words", "hello", "--length", "2"]
|
||||||
|
)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
assert "hello" in captured.out
|
||||||
|
|
||||||
|
def test_words_file_input(
|
||||||
|
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Test --words-file option."""
|
||||||
|
text_file = tmp_path / "text.txt"
|
||||||
|
words_file = tmp_path / "words.txt"
|
||||||
|
text_file.write_text("hello world hello world", encoding="utf-8")
|
||||||
|
words_file.write_text("hello\nworld\n", encoding="utf-8")
|
||||||
|
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"--file",
|
||||||
|
str(text_file),
|
||||||
|
"--words-file",
|
||||||
|
str(words_file),
|
||||||
|
"--length",
|
||||||
|
"2",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
assert "100.00%" in captured.out # Both words match
|
||||||
|
|
||||||
|
def test_top_option(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Test --top option."""
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"--text",
|
||||||
|
"a b c d e f",
|
||||||
|
"--words",
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
"--length",
|
||||||
|
"2",
|
||||||
|
"--top",
|
||||||
|
"3",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
# Should show multiple results
|
||||||
|
assert "Result #1" in captured.out
|
||||||
|
|
||||||
|
def test_context_option(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Test --context option."""
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"--text",
|
||||||
|
"a b c d e f g",
|
||||||
|
"--words",
|
||||||
|
"d",
|
||||||
|
"--length",
|
||||||
|
"1",
|
||||||
|
"--context",
|
||||||
|
"2",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
# Excerpt should include context words
|
||||||
|
|
||||||
|
def test_case_sensitive_option(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Test --case-sensitive option."""
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"--text",
|
||||||
|
"Hello HELLO hello",
|
||||||
|
"--words",
|
||||||
|
"hello",
|
||||||
|
"--length",
|
||||||
|
"1",
|
||||||
|
"--case-sensitive",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
# Only lowercase "hello" should match
|
||||||
|
|
||||||
|
def test_file_not_found(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Test error handling for missing file."""
|
||||||
|
exit_code = main(
|
||||||
|
["--file", "/nonexistent/file.txt", "--words", "hello", "--length", "2"]
|
||||||
|
)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert exit_code == 1
|
||||||
|
assert "Error" in captured.err
|
||||||
|
|
||||||
|
def test_empty_words_file(
|
||||||
|
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Test error when words file is empty."""
|
||||||
|
text_file = tmp_path / "text.txt"
|
||||||
|
words_file = tmp_path / "words.txt"
|
||||||
|
text_file.write_text("hello world", encoding="utf-8")
|
||||||
|
words_file.write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"--file",
|
||||||
|
str(text_file),
|
||||||
|
"--words-file",
|
||||||
|
str(words_file),
|
||||||
|
"--length",
|
||||||
|
"2",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert exit_code == 1
|
||||||
|
assert "No target words" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
class TestPerformance:
|
||||||
|
"""Performance tests for excerpt finder."""
|
||||||
|
|
||||||
|
def test_large_text_performance(self) -> None:
|
||||||
|
"""Test that finding excerpts in large text completes quickly."""
|
||||||
|
# Generate large text (~100k words)
|
||||||
|
words = ["the", "and", "of", "to", "in", "a", "that", "is", "was", "for"]
|
||||||
|
large_text = " ".join(words * 10000)
|
||||||
|
|
||||||
|
target_words = ["the", "and", "of"]
|
||||||
|
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
result = find_best_excerpt(
|
||||||
|
large_text, target_words, excerpt_length=100, top_n=10
|
||||||
|
)
|
||||||
|
elapsed = time.perf_counter() - start_time
|
||||||
|
|
||||||
|
assert elapsed < 5.0, f"Search took {elapsed:.2f}s, expected < 5s"
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
def test_many_target_words_performance(self) -> None:
|
||||||
|
"""Test performance with many target words."""
|
||||||
|
# Generate text
|
||||||
|
text_words = [f"word{i}" for i in range(1000)] * 100
|
||||||
|
large_text = " ".join(text_words)
|
||||||
|
|
||||||
|
# Many target words
|
||||||
|
target_words = [f"word{i}" for i in range(500)]
|
||||||
|
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
result = find_best_excerpt(large_text, target_words, excerpt_length=50, top_n=5)
|
||||||
|
elapsed = time.perf_counter() - start_time
|
||||||
|
|
||||||
|
assert elapsed < 10.0, f"Search took {elapsed:.2f}s, expected < 10s"
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
def test_long_excerpt_performance(self) -> None:
|
||||||
|
"""Test performance with long excerpt length."""
|
||||||
|
words = ["a", "b", "c", "d", "e"] * 10000
|
||||||
|
large_text = " ".join(words)
|
||||||
|
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
result = find_best_excerpt(large_text, ["a", "b"], excerpt_length=1000, top_n=5)
|
||||||
|
elapsed = time.perf_counter() - start_time
|
||||||
|
|
||||||
|
assert elapsed < 5.0, f"Search took {elapsed:.2f}s, expected < 5s"
|
||||||
|
assert len(result) > 0
|
||||||
311
python_pkg/word_frequency/tests/test_learning_pipe.py
Normal file
311
python_pkg/word_frequency/tests/test_learning_pipe.py
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
"""Tests for word_frequency.learning_pipe module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from python_pkg.word_frequency.learning_pipe import (
|
||||||
|
DEFAULT_STOPWORDS_EN,
|
||||||
|
generate_learning_lesson,
|
||||||
|
load_stopwords,
|
||||||
|
main,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadStopwords:
|
||||||
|
"""Tests for load_stopwords function."""
|
||||||
|
|
||||||
|
def test_load_from_file(self, tmp_path: Path) -> None:
|
||||||
|
"""Test loading stopwords from file."""
|
||||||
|
stopwords_file = tmp_path / "stopwords.txt"
|
||||||
|
stopwords_file.write_text("word1\nword2\nword3\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = load_stopwords(stopwords_file)
|
||||||
|
|
||||||
|
assert "word1" in result
|
||||||
|
assert "word2" in result
|
||||||
|
assert "word3" in result
|
||||||
|
|
||||||
|
def test_load_none_returns_empty(self) -> None:
|
||||||
|
"""Test that None returns empty frozenset."""
|
||||||
|
result = load_stopwords(None)
|
||||||
|
assert result == frozenset()
|
||||||
|
|
||||||
|
def test_load_nonexistent_returns_empty(self) -> None:
|
||||||
|
"""Test that nonexistent file returns empty frozenset."""
|
||||||
|
result = load_stopwords("/nonexistent/file.txt")
|
||||||
|
assert result == frozenset()
|
||||||
|
|
||||||
|
def test_lowercase_conversion(self, tmp_path: Path) -> None:
|
||||||
|
"""Test that stopwords are converted to lowercase."""
|
||||||
|
stopwords_file = tmp_path / "stopwords.txt"
|
||||||
|
stopwords_file.write_text("UPPER\nMixed\nlower\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = load_stopwords(stopwords_file)
|
||||||
|
|
||||||
|
assert "upper" in result
|
||||||
|
assert "mixed" in result
|
||||||
|
assert "lower" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateLearningLesson:
|
||||||
|
"""Tests for generate_learning_lesson function."""
|
||||||
|
|
||||||
|
def test_basic_generation(self) -> None:
|
||||||
|
"""Test basic lesson generation."""
|
||||||
|
text = "hello world hello hello world test test test test"
|
||||||
|
result = generate_learning_lesson(
|
||||||
|
text, batch_size=3, num_batches=1, skip_default_stopwords=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "LANGUAGE LEARNING LESSON" in result
|
||||||
|
assert "VOCABULARY TO LEARN" in result
|
||||||
|
assert "test" in result # Most common word
|
||||||
|
|
||||||
|
def test_multiple_batches(self) -> None:
|
||||||
|
"""Test generation with multiple batches."""
|
||||||
|
text = " ".join(f"word{i}" * (100 - i) for i in range(20))
|
||||||
|
result = generate_learning_lesson(
|
||||||
|
text, batch_size=5, num_batches=3, skip_default_stopwords=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "BATCH 1" in result
|
||||||
|
assert "BATCH 2" in result
|
||||||
|
assert "BATCH 3" in result
|
||||||
|
|
||||||
|
def test_stopwords_filtering(self) -> None:
|
||||||
|
"""Test that default stopwords are filtered."""
|
||||||
|
text = "the the the hello world"
|
||||||
|
result = generate_learning_lesson(text, batch_size=5, num_batches=1)
|
||||||
|
|
||||||
|
# "the" should be filtered, "hello" and "world" should appear
|
||||||
|
lines = result.split("\n")
|
||||||
|
vocab_section = False
|
||||||
|
found_words = []
|
||||||
|
for line in lines:
|
||||||
|
if "VOCABULARY TO LEARN" in line:
|
||||||
|
vocab_section = True
|
||||||
|
elif vocab_section and ". " in line and "(" in line:
|
||||||
|
# Extract word from line like " 1. hello (1 occurrences..."
|
||||||
|
word = line.split(".")[1].split("(")[0].strip()
|
||||||
|
found_words.append(word)
|
||||||
|
elif vocab_section and "PRACTICE" in line:
|
||||||
|
break
|
||||||
|
|
||||||
|
assert "the" not in found_words
|
||||||
|
assert "hello" in found_words or "world" in found_words
|
||||||
|
|
||||||
|
def test_skip_default_stopwords(self) -> None:
|
||||||
|
"""Test disabling default stopword filtering."""
|
||||||
|
text = "the the the hello"
|
||||||
|
result = generate_learning_lesson(
|
||||||
|
text, batch_size=5, num_batches=1, skip_default_stopwords=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "the" in result.lower()
|
||||||
|
|
||||||
|
def test_numbers_filtered_by_default(self) -> None:
|
||||||
|
"""Test that numbers are filtered by default."""
|
||||||
|
text = "123 123 123 hello world"
|
||||||
|
result = generate_learning_lesson(
|
||||||
|
text, batch_size=5, num_batches=1, skip_default_stopwords=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check vocabulary section doesn't include "123"
|
||||||
|
lines = result.split("\n")
|
||||||
|
for line in lines:
|
||||||
|
if ". 123" in line and "occurrences" in line:
|
||||||
|
pytest.fail("Number '123' should be filtered from vocabulary")
|
||||||
|
|
||||||
|
def test_numbers_included_when_requested(self) -> None:
|
||||||
|
"""Test including numbers in vocabulary."""
|
||||||
|
text = "123 123 123 hello"
|
||||||
|
result = generate_learning_lesson(
|
||||||
|
text,
|
||||||
|
batch_size=5,
|
||||||
|
num_batches=1,
|
||||||
|
skip_default_stopwords=True,
|
||||||
|
skip_numbers=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "123" in result
|
||||||
|
|
||||||
|
def test_coverage_calculation(self) -> None:
|
||||||
|
"""Test that coverage percentage is calculated."""
|
||||||
|
text = "hello hello hello world world test"
|
||||||
|
result = generate_learning_lesson(
|
||||||
|
text, batch_size=3, num_batches=1, skip_default_stopwords=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "recognize" in result.lower()
|
||||||
|
assert "%" in result
|
||||||
|
|
||||||
|
def test_excerpts_included(self) -> None:
|
||||||
|
"""Test that practice excerpts are included."""
|
||||||
|
text = "hello world hello world hello world test test test"
|
||||||
|
result = generate_learning_lesson(
|
||||||
|
text,
|
||||||
|
batch_size=2,
|
||||||
|
num_batches=1,
|
||||||
|
excerpt_length=3,
|
||||||
|
excerpts_per_batch=2,
|
||||||
|
skip_default_stopwords=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "PRACTICE EXCERPTS" in result
|
||||||
|
assert "Excerpt 1" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
"""Tests for main CLI function."""
|
||||||
|
|
||||||
|
def test_basic_text_input(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Test with text input."""
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"--text",
|
||||||
|
"hello world hello world test test test",
|
||||||
|
"--batch-size",
|
||||||
|
"3",
|
||||||
|
"--no-default-stopwords",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
assert "LANGUAGE LEARNING LESSON" in captured.out
|
||||||
|
|
||||||
|
def test_file_input(
|
||||||
|
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Test with file input."""
|
||||||
|
test_file = tmp_path / "test.txt"
|
||||||
|
test_file.write_text("hello world hello world test", encoding="utf-8")
|
||||||
|
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"--file",
|
||||||
|
str(test_file),
|
||||||
|
"--batch-size",
|
||||||
|
"3",
|
||||||
|
"--no-default-stopwords",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
assert "hello" in captured.out.lower()
|
||||||
|
|
||||||
|
def test_output_to_file(self, tmp_path: Path) -> None:
|
||||||
|
"""Test outputting to file."""
|
||||||
|
output_file = tmp_path / "lesson.txt"
|
||||||
|
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"--text",
|
||||||
|
"hello world hello world",
|
||||||
|
"--output",
|
||||||
|
str(output_file),
|
||||||
|
"--no-default-stopwords",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
assert output_file.exists()
|
||||||
|
content = output_file.read_text(encoding="utf-8")
|
||||||
|
assert "LANGUAGE LEARNING LESSON" in content
|
||||||
|
|
||||||
|
def test_custom_stopwords(
|
||||||
|
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Test with custom stopwords file."""
|
||||||
|
stopwords_file = tmp_path / "stop.txt"
|
||||||
|
stopwords_file.write_text("hello\n", encoding="utf-8")
|
||||||
|
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"--text",
|
||||||
|
"hello hello hello world world",
|
||||||
|
"--stopwords",
|
||||||
|
str(stopwords_file),
|
||||||
|
"--no-default-stopwords",
|
||||||
|
"--batch-size",
|
||||||
|
"5",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
# "hello" should be filtered by custom stopwords
|
||||||
|
|
||||||
|
def test_multiple_batches_option(
|
||||||
|
self, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Test --batches option."""
|
||||||
|
text = " ".join(f"word{i}" * (50 - i) for i in range(30))
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"--text",
|
||||||
|
text,
|
||||||
|
"--batch-size",
|
||||||
|
"5",
|
||||||
|
"--batches",
|
||||||
|
"3",
|
||||||
|
"--no-default-stopwords",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
assert "BATCH 1" in captured.out
|
||||||
|
assert "BATCH 2" in captured.out
|
||||||
|
assert "BATCH 3" in captured.out
|
||||||
|
|
||||||
|
def test_file_not_found(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Test error handling for missing file."""
|
||||||
|
exit_code = main(["--file", "/nonexistent/file.txt"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert exit_code == 1
|
||||||
|
assert "Error" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
class TestPerformance:
|
||||||
|
"""Performance tests for learning pipe."""
|
||||||
|
|
||||||
|
def test_large_text_performance(self) -> None:
|
||||||
|
"""Test performance with large text."""
|
||||||
|
# Generate large text with enough unique words for 5 batches
|
||||||
|
words = ["word" + str(i) for i in range(500)]
|
||||||
|
large_text = " ".join(words * 200)
|
||||||
|
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
result = generate_learning_lesson(
|
||||||
|
large_text,
|
||||||
|
batch_size=50,
|
||||||
|
num_batches=5,
|
||||||
|
excerpt_length=30,
|
||||||
|
skip_default_stopwords=True,
|
||||||
|
)
|
||||||
|
elapsed = time.perf_counter() - start_time
|
||||||
|
|
||||||
|
assert elapsed < 10.0, f"Generation took {elapsed:.2f}s, expected < 10s"
|
||||||
|
assert "BATCH 5" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultStopwords:
|
||||||
|
"""Tests for default stopwords."""
|
||||||
|
|
||||||
|
def test_common_words_in_stopwords(self) -> None:
|
||||||
|
"""Test that common words are in default stopwords."""
|
||||||
|
common = ["the", "a", "an", "and", "or", "but", "in", "on", "is", "are"]
|
||||||
|
for word in common:
|
||||||
|
assert word in DEFAULT_STOPWORDS_EN
|
||||||
|
|
||||||
|
def test_stopwords_are_lowercase(self) -> None:
|
||||||
|
"""Test that all stopwords are lowercase."""
|
||||||
|
for word in DEFAULT_STOPWORDS_EN:
|
||||||
|
assert word == word.lower()
|
||||||
@ -18,27 +18,42 @@ require_cmd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
install_arch_from_aur_git() {
|
install_arch_from_aur_git() {
|
||||||
# Build and install from AUR git repo directly (sonic-pi.git)
|
# Build and install from AUR using an AUR helper (handles recursive AUR deps)
|
||||||
local AUR_URL="https://aur.archlinux.org/sonic-pi.git"
|
|
||||||
if [ "$EUID" -eq 0 ]; then
|
if [ "$EUID" -eq 0 ]; then
|
||||||
echo "Do not run the AUR build as root. Re-run this script as a regular user." >&2
|
echo "Do not run the AUR build as root. Re-run this script as a regular user." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
echo "Preparing to build Sonic Pi from AUR..."
|
|
||||||
# Common build deps that often are needed
|
# Try AUR helpers first (they handle recursive AUR deps)
|
||||||
sudo pacman -S --needed --noconfirm base-devel git cmake boost boost-libs qt6-base qt6-svg qt6-declarative qt6-tools ruby || true
|
if require_cmd yay; then
|
||||||
|
echo "Installing Sonic Pi via yay..."
|
||||||
|
yay -S --needed --noconfirm sonic-pi
|
||||||
|
return $?
|
||||||
|
elif require_cmd paru; then
|
||||||
|
echo "Installing Sonic Pi via paru..."
|
||||||
|
paru -S --needed --noconfirm sonic-pi
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "No AUR helper found. Installing yay first..."
|
||||||
|
sudo pacman -S --needed --noconfirm base-devel git || true
|
||||||
local TMPDIR
|
local TMPDIR
|
||||||
TMPDIR=$(mktemp -d -t sonicpi-aur-XXXXXX)
|
TMPDIR=$(mktemp -d -t yay-install-XXXXXX)
|
||||||
echo "Using temp dir: $TMPDIR"
|
|
||||||
(
|
(
|
||||||
set -e
|
set -e
|
||||||
cd "$TMPDIR"
|
cd "$TMPDIR"
|
||||||
repo_name=$(basename "$AUR_URL" .git)
|
git clone https://aur.archlinux.org/yay.git
|
||||||
echo "Cloning $AUR_URL"
|
cd yay
|
||||||
git clone "$AUR_URL"
|
|
||||||
cd "$repo_name"
|
|
||||||
makepkg -si --noconfirm
|
makepkg -si --noconfirm
|
||||||
)
|
)
|
||||||
|
# Now use yay to install sonic-pi
|
||||||
|
if require_cmd yay; then
|
||||||
|
echo "Installing Sonic Pi via yay..."
|
||||||
|
yay -S --needed --noconfirm sonic-pi
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
echo "Failed to install yay." >&2
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
install_sonic_pi() {
|
install_sonic_pi() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user