Coverage for presidio_analyzer / chunkers / character_based_text_chunker.py: 100%
42 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-29 09:03 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-29 09:03 +0000
1"""Character-based text chunker with word boundary preservation.
3Based on gliner-spacy implementation:
4https://github.com/theirstory/gliner-spacy/blob/main/gliner_spacy/pipeline.py#L60-L96
5"""
6import logging
7from typing import Iterable, List, Tuple
9from presidio_analyzer.chunkers.base_chunker import BaseTextChunker, TextChunk
11logger = logging.getLogger("presidio-analyzer")
14WORD_BOUNDARY_CHARS: Tuple[str, ...] = (" ", "\n")
17class CharacterBasedTextChunker(BaseTextChunker):
18 """Character-based text chunker with word boundary preservation."""
20 def __init__(
21 self,
22 chunk_size: int = 250,
23 chunk_overlap: int = 50,
24 boundary_chars: Iterable[str] | None = None,
25 ):
26 """Initialize the character-based text chunker.
28 Note: Chunks may slightly exceed chunk_size to preserve complete words.
29 When this occurs, the actual overlap may vary from the specified value.
31 :param chunk_size: Target maximum characters per chunk (must be > 0)
32 :param chunk_overlap: Target characters to overlap between chunks
33 (must be >= 0 and < chunk_size)
34 :param boundary_chars: Characters that count as word boundaries.
35 Defaults to space/newline to keep current behavior.
36 """
37 if chunk_size <= 0:
38 logger.error("Invalid chunk_size: %d. Must be greater than 0.", chunk_size)
39 raise ValueError("chunk_size must be greater than 0")
40 if chunk_overlap < 0 or chunk_overlap >= chunk_size:
41 logger.error(
42 "Invalid chunk_overlap. Must be non-negative and less than chunk_size"
43 )
44 raise ValueError(
45 "chunk_overlap must be non-negative and less than chunk_size"
46 )
48 self._chunk_size = chunk_size
49 self._chunk_overlap = chunk_overlap
50 # Allow callers to tune boundaries
51 # (e.g., punctuation, tabs) without changing defaults.
52 self._boundary_chars: Tuple[str, ...] = (
53 tuple(boundary_chars) if boundary_chars is not None else WORD_BOUNDARY_CHARS
54 )
56 @property
57 def chunk_size(self) -> int:
58 """Get the chunk size.
60 :return: The chunk size
61 """
62 return self._chunk_size
64 @property
65 def chunk_overlap(self) -> int:
66 """Get the chunk overlap.
68 :return: The chunk overlap
69 """
70 return self._chunk_overlap
72 @property
73 def boundary_chars(self) -> Tuple[str, ...]:
74 """Characters treated as word boundaries when extending chunks."""
76 return self._boundary_chars
78 def chunk(self, text: str) -> List[TextChunk]:
79 """Split text into overlapping chunks at word boundaries.
81 Chunks are extended to the nearest word boundary (space or newline)
82 to avoid splitting words. This means chunks may slightly exceed
83 chunk_size. For texts without spaces (e.g., CJK languages), chunks
84 may extend to end of text.
86 :param text: The input text to chunk
87 :return: List of TextChunk objects with text and position information
88 """
89 if not text:
90 logger.debug("Empty text provided, returning empty chunk list")
91 return []
93 logger.debug(
94 "Chunking text: length=%d, chunk_size=%d, overlap=%d",
95 len(text),
96 self._chunk_size,
97 self._chunk_overlap,
98 )
100 chunks = []
101 start = 0
103 while start < len(text):
104 # Calculate end position
105 end = (
106 start + self._chunk_size
107 if start + self._chunk_size < len(text)
108 else len(text)
109 )
111 # Extend to complete word boundary (space or newline by default)
112 while end < len(text) and text[end] not in self._boundary_chars:
113 end += 1
115 chunks.append(TextChunk(text=text[start:end], start=start, end=end))
117 # Move start position with overlap (stop if we've covered all text)
118 if end >= len(text):
119 break
120 start = end - self._chunk_overlap
122 logger.debug("Created %d chunks from text", len(chunks))
123 return chunks