tabalyst 0.4.0__tar.gz → 0.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tabalyst-0.4.0/src/tabalyst.egg-info → tabalyst-0.4.1}/PKG-INFO +1 -1
- {tabalyst-0.4.0 → tabalyst-0.4.1}/pyproject.toml +1 -1
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/models.py +1 -1
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/config.py +6 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/base.py +7 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/boolean.py +1 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/currency.py +1 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/email.py +1 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/ip.py +1 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/phone.py +1 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/postal.py +1 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/quantity.py +1 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/registry.py +164 -15
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/temporal.py +1 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/url.py +1 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/uuid.py +1 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/measures.py +11 -6
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/models.py +12 -1
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/records.py +106 -5
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/structure.py +35 -11
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/values.py +79 -10
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/service.py +6 -2
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/templates/report.html +1 -1
- {tabalyst-0.4.0 → tabalyst-0.4.1/src/tabalyst.egg-info}/PKG-INFO +1 -1
- {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_cli.py +2 -2
- {tabalyst-0.4.0 → tabalyst-0.4.1}/LICENSE +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/MANIFEST.in +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/README.md +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/examples/README.md +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/examples/config.json +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/examples/input/basic.csv +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/examples/input/insurance-customers.csv +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/setup.cfg +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/__init__.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/__main__.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/_version.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/batch.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/cli/__init__.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/cli/app.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/cli/report.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/cli/sample.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/cli/scan.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/cli/terminal.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/config.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/errors.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/execution_log.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/progress.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/report_config.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/report_profile.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/report_service.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/reporting.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/sampling.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/sampling_service.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scan_reuse.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scan_service.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/__init__.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/api.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/__init__.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/amount.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/enumeration.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/names.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/number.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/pattern.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/percentage.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/shape.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/diagnostics.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/engine.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/exposure.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/field.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/normalization.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/observations.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/paths.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/readers/__init__.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/readers/base.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/readers/csv_reader.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/readers/json_reader.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/technical.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/static/report.js +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/static/theme.css +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst.egg-info/SOURCES.txt +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst.egg-info/dependency_links.txt +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst.egg-info/entry_points.txt +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst.egg-info/requires.txt +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst.egg-info/top_level.txt +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_public_api.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_reporting.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_sampling.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scan_command.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_amounts.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_config.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_csv.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_email_url.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_exposure.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_paths.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_phone.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_postal.py +0 -0
- {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_uuid_ip.py +0 -0
|
@@ -378,7 +378,7 @@ class DatasetProfile(ResultModel):
|
|
|
378
378
|
|
|
379
379
|
class ReportProfile(ResultModel):
|
|
380
380
|
format_version: Literal["0.1.0a"] = "0.1.0a"
|
|
381
|
-
format_revision: Literal[
|
|
381
|
+
format_revision: Literal[7] = 7
|
|
382
382
|
generated_at: datetime
|
|
383
383
|
processing_seconds: FiniteFloat
|
|
384
384
|
source: SourceInfo
|
|
@@ -52,6 +52,9 @@ MAX_PREVIEW_RECORDS = 1_000
|
|
|
52
52
|
MAX_PATTERNS = 200
|
|
53
53
|
MAX_PATTERN_LENGTH = 1_000
|
|
54
54
|
MAX_PATTERN_INPUT_LENGTH = 10_000
|
|
55
|
+
# A streamed field keeps its warm-up values: the warm-up is bounded.
|
|
56
|
+
MAX_WARMUP_VALUES = 1_000_000
|
|
57
|
+
MAX_PROBE_INTERVAL = 1_000_000
|
|
55
58
|
|
|
56
59
|
MissingCategory = Literal["absent", "null", "empty", "blank", "marker"]
|
|
57
60
|
ScalarType = Literal["string", "integer", "number", "boolean"]
|
|
@@ -176,6 +179,9 @@ class TypeSettings(_Settings):
|
|
|
176
179
|
|
|
177
180
|
class DetectionSettings(_Settings):
|
|
178
181
|
minimum_share: float = Field(default=0.95, gt=0, le=1)
|
|
182
|
+
# Adaptive detection (design 13): 0 keeps detection exhaustive.
|
|
183
|
+
warmup_values: int = Field(default=10_000, ge=0, le=MAX_WARMUP_VALUES)
|
|
184
|
+
probe_interval: int = Field(default=100, ge=0, le=MAX_PROBE_INTERVAL)
|
|
179
185
|
|
|
180
186
|
|
|
181
187
|
NumberConvention = Literal["dot", "comma"]
|
|
@@ -69,9 +69,15 @@ class DetectorAccumulator:
|
|
|
69
69
|
``add`` receives every tested value with its classification (``None`` when
|
|
70
70
|
not matched) and its count. In both execution modes it sees the same
|
|
71
71
|
distinct values, first seen first, with the same total counts.
|
|
72
|
+
|
|
73
|
+
A class whose ``add`` does nothing for unmatched values (``None``) sets
|
|
74
|
+
``ignores_unmatched`` in its own body: the engine then skips those calls.
|
|
75
|
+
The flag applies only to the class that defines ``add``, so a subclass
|
|
76
|
+
that overrides ``add`` receives every value unless it sets it again.
|
|
72
77
|
"""
|
|
73
78
|
|
|
74
79
|
__slots__ = ()
|
|
80
|
+
ignores_unmatched: ClassVar[bool] = True
|
|
75
81
|
|
|
76
82
|
def add(
|
|
77
83
|
self, value: str, classification: Classification | None, count: int
|
|
@@ -135,6 +141,7 @@ class AmbiguityAccumulator(DetectorAccumulator):
|
|
|
135
141
|
"""
|
|
136
142
|
|
|
137
143
|
__slots__ = ("candidates", "count", "evidence", "resolution")
|
|
144
|
+
ignores_unmatched = True
|
|
138
145
|
|
|
139
146
|
def __init__(self, readings: list[str], resolution: dict[str, str] | None) -> None:
|
|
140
147
|
self.count = 0
|
|
@@ -114,6 +114,7 @@ class IpAddressDetector(Detector):
|
|
|
114
114
|
|
|
115
115
|
class IpAddressAccumulator(DetectorAccumulator):
|
|
116
116
|
__slots__ = ("versions",)
|
|
117
|
+
ignores_unmatched = True
|
|
117
118
|
|
|
118
119
|
def __init__(self, versions: tuple[str, ...]) -> None:
|
|
119
120
|
self.versions = dict.fromkeys(sorted(versions), 0)
|
|
@@ -126,6 +126,7 @@ class PhoneDetector(Detector):
|
|
|
126
126
|
|
|
127
127
|
class PhoneAccumulator(DetectorAccumulator):
|
|
128
128
|
__slots__ = ("regions",)
|
|
129
|
+
ignores_unmatched = True
|
|
129
130
|
|
|
130
131
|
def __init__(self, regions: tuple[str, ...]) -> None:
|
|
131
132
|
self.regions = dict.fromkeys(sorted(regions), 0)
|
|
@@ -37,6 +37,7 @@ from tabalyst.scanner.detectors.uuid import UuidDetector
|
|
|
37
37
|
from tabalyst.scanner.exposure import SHOW, ExposureGate
|
|
38
38
|
from tabalyst.scanner.measures import UNCONVERTIBLE
|
|
39
39
|
from tabalyst.scanner.models import (
|
|
40
|
+
Adaptive,
|
|
40
41
|
Coverage,
|
|
41
42
|
DetectorComplete,
|
|
42
43
|
DetectorDisabled,
|
|
@@ -70,10 +71,15 @@ BUILT_INS: tuple[type[Detector], ...] = (
|
|
|
70
71
|
_NATIVE_SCALARS = ("string", "integer", "number", "boolean")
|
|
71
72
|
_NO_VALUES = NotApplicable(reason="no_values")
|
|
72
73
|
_STATES = ("matched", "ambiguous", "invalid", "not_matched")
|
|
74
|
+
# Distinct shapes memoized by a detector set (``DetectorSet._fits``).
|
|
75
|
+
SHAPE_CACHE_SIZE = 4_096
|
|
73
76
|
|
|
74
77
|
# ``report_failure(detector_id, error)`` records a ``detector_failed``
|
|
75
78
|
# diagnostic and returns its index.
|
|
76
79
|
FailureReporter = Callable[[str, str], int]
|
|
80
|
+
# ``report_probe(detector_id, count)`` records a ``detector_skipped_reacted``
|
|
81
|
+
# warning for ``count`` probed occurrences and returns its index.
|
|
82
|
+
ProbeReporter = Callable[[str, int], int]
|
|
77
83
|
|
|
78
84
|
|
|
79
85
|
class _NotEligible:
|
|
@@ -88,6 +94,22 @@ class _NotEligible:
|
|
|
88
94
|
NOT_ELIGIBLE = _NotEligible()
|
|
89
95
|
|
|
90
96
|
|
|
97
|
+
class _Skipped:
|
|
98
|
+
"""Adaptive detection did not test the value (design 13)."""
|
|
99
|
+
|
|
100
|
+
__slots__ = ()
|
|
101
|
+
|
|
102
|
+
def __repr__(self) -> str:
|
|
103
|
+
return "SKIPPED"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
SKIPPED = _Skipped()
|
|
107
|
+
# The plan of a skipped detector, for the native types it accepts.
|
|
108
|
+
_SKIP = ("skip",)
|
|
109
|
+
# Per native type, one plan per active detector (``DetectorSet._plans``).
|
|
110
|
+
Plans = dict[str, tuple[object, ...]]
|
|
111
|
+
|
|
112
|
+
|
|
91
113
|
class DetectorRegistry:
|
|
92
114
|
"""Detector classes by ``id``, in registration order."""
|
|
93
115
|
|
|
@@ -121,6 +143,15 @@ def default_registry() -> DetectorRegistry:
|
|
|
121
143
|
return DetectorRegistry(BUILT_INS)
|
|
122
144
|
|
|
123
145
|
|
|
146
|
+
def _ignores_unmatched(accumulator: object) -> bool:
|
|
147
|
+
"""Whether the class that defines ``accumulator.add`` declares, in its own
|
|
148
|
+
body, that ``add`` does nothing for unmatched values."""
|
|
149
|
+
for cls in type(accumulator).__mro__:
|
|
150
|
+
if "add" in cls.__dict__:
|
|
151
|
+
return cls.__dict__.get("ignores_unmatched", False) is True
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
124
155
|
class DetectorTally:
|
|
125
156
|
"""Coverage, formats and evidence of one detector on one field."""
|
|
126
157
|
|
|
@@ -138,6 +169,10 @@ class DetectorTally:
|
|
|
138
169
|
"max_length",
|
|
139
170
|
"not_matched",
|
|
140
171
|
"not_tested",
|
|
172
|
+
"skipped",
|
|
173
|
+
"skipped_after",
|
|
174
|
+
"unmatched_examples",
|
|
175
|
+
"unmatched_to_accumulator",
|
|
141
176
|
)
|
|
142
177
|
|
|
143
178
|
def __init__(self, detector: Detector, max_examples: int, max_length: int) -> None:
|
|
@@ -150,22 +185,49 @@ class DetectorTally:
|
|
|
150
185
|
self.invalid = 0
|
|
151
186
|
self.not_matched = 0
|
|
152
187
|
self.not_tested = 0
|
|
188
|
+
# Adaptive detection: values not tested, and the warm-up size once
|
|
189
|
+
# the detector is skipped for the field.
|
|
190
|
+
self.skipped = 0
|
|
191
|
+
self.skipped_after: int | None = None
|
|
153
192
|
self.formats: dict[str, int] = {}
|
|
154
193
|
self.examples: dict[str, list[str]] = {state: [] for state in _STATES}
|
|
155
194
|
self.error: str | None = None
|
|
195
|
+
self.unmatched_examples = self.examples["not_matched"]
|
|
196
|
+
self.unmatched_to_accumulator = True
|
|
156
197
|
try:
|
|
157
198
|
self.accumulator = detector.accumulator()
|
|
158
199
|
except Exception as exc: # noqa: BLE001 - detector code is isolated (CA19)
|
|
159
200
|
self.error = type(exc).__name__
|
|
201
|
+
else:
|
|
202
|
+
self.unmatched_to_accumulator = not _ignores_unmatched(self.accumulator)
|
|
160
203
|
|
|
161
204
|
def add(self, value: str, classification: object, count: int) -> None:
|
|
205
|
+
if classification is None: # the most frequent case
|
|
206
|
+
if self.error is not None:
|
|
207
|
+
return
|
|
208
|
+
self.eligible += count
|
|
209
|
+
self.not_matched += count
|
|
210
|
+
examples = self.unmatched_examples
|
|
211
|
+
if (
|
|
212
|
+
len(examples) < self.max_examples
|
|
213
|
+
and len(value) <= self.max_length
|
|
214
|
+
and value not in examples
|
|
215
|
+
):
|
|
216
|
+
examples.append(value)
|
|
217
|
+
if self.unmatched_to_accumulator:
|
|
218
|
+
try:
|
|
219
|
+
self.accumulator.add(value, None, count)
|
|
220
|
+
except Exception as exc: # noqa: BLE001 - detector code is isolated (CA19)
|
|
221
|
+
self.error = type(exc).__name__
|
|
222
|
+
return
|
|
162
223
|
if classification is NOT_ELIGIBLE or self.error is not None:
|
|
163
224
|
return
|
|
164
225
|
self.eligible += count
|
|
165
|
-
if classification is
|
|
166
|
-
|
|
167
|
-
self.
|
|
168
|
-
|
|
226
|
+
if classification is SKIPPED:
|
|
227
|
+
self.not_tested += count
|
|
228
|
+
self.skipped += count
|
|
229
|
+
return
|
|
230
|
+
if classification is NOT_TESTED:
|
|
169
231
|
self.not_tested += count
|
|
170
232
|
return
|
|
171
233
|
elif type(classification) is DetectorFailure:
|
|
@@ -197,12 +259,23 @@ class DetectorTally:
|
|
|
197
259
|
except Exception as exc: # noqa: BLE001 - detector code is isolated (CA19)
|
|
198
260
|
self.error = type(exc).__name__
|
|
199
261
|
|
|
262
|
+
def reacted(self) -> bool:
|
|
263
|
+
"""A value matched, was ambiguous or invalid, or the detector failed."""
|
|
264
|
+
return bool(
|
|
265
|
+
self.matched or self.ambiguous or self.invalid or self.error is not None
|
|
266
|
+
)
|
|
267
|
+
|
|
200
268
|
def exposes_sensitive_values(self) -> bool:
|
|
201
269
|
"""A sensitive detector matched a value of the field, or failed on it,
|
|
202
270
|
which cannot prove that nothing matched (design 12.8)."""
|
|
203
271
|
return self.detector.sensitive and (self.matched > 0 or self.error is not None)
|
|
204
272
|
|
|
205
|
-
def result(
|
|
273
|
+
def result(
|
|
274
|
+
self,
|
|
275
|
+
report_failure: FailureReporter,
|
|
276
|
+
gate: ExposureGate = SHOW,
|
|
277
|
+
report_probe: ProbeReporter | None = None,
|
|
278
|
+
):
|
|
206
279
|
detector = self.detector
|
|
207
280
|
identity = {"id": detector.id, "version": detector.version}
|
|
208
281
|
if self.error is None and self.eligible:
|
|
@@ -229,6 +302,19 @@ class DetectorTally:
|
|
|
229
302
|
moved = (examples["matched"] + examples["not_matched"])[: self.max_examples]
|
|
230
303
|
examples = {**examples, "matched": [], "not_matched": moved}
|
|
231
304
|
tested = self.eligible - self.not_tested
|
|
305
|
+
adaptive = None
|
|
306
|
+
if self.skipped_after is not None:
|
|
307
|
+
# The warm-up saw no reaction: every reaction comes from a probe.
|
|
308
|
+
reactions = matched + self.ambiguous + self.invalid
|
|
309
|
+
adaptive = Adaptive(
|
|
310
|
+
skipped_after=self.skipped_after,
|
|
311
|
+
not_tested=self.skipped,
|
|
312
|
+
diagnostic=(
|
|
313
|
+
report_probe(detector.id, reactions)
|
|
314
|
+
if reactions and report_probe is not None
|
|
315
|
+
else None
|
|
316
|
+
),
|
|
317
|
+
)
|
|
232
318
|
return DetectorComplete(
|
|
233
319
|
**identity,
|
|
234
320
|
coverage=Coverage(
|
|
@@ -252,6 +338,7 @@ class DetectorTally:
|
|
|
252
338
|
**{state: gate.examples(values) for state, values in examples.items()}
|
|
253
339
|
),
|
|
254
340
|
details=details,
|
|
341
|
+
adaptive=adaptive,
|
|
255
342
|
)
|
|
256
343
|
|
|
257
344
|
|
|
@@ -263,16 +350,20 @@ class DetectorSet:
|
|
|
263
350
|
"""The detectors of one scan, in registry order."""
|
|
264
351
|
|
|
265
352
|
__slots__ = (
|
|
353
|
+
"_adaptive",
|
|
266
354
|
"_date",
|
|
267
355
|
"_exposure",
|
|
268
356
|
"_idle",
|
|
269
357
|
"_number",
|
|
270
358
|
"_plans",
|
|
359
|
+
"_shape_cache",
|
|
271
360
|
"active",
|
|
272
361
|
"entries",
|
|
273
362
|
"max_examples",
|
|
274
363
|
"max_length",
|
|
275
364
|
"minimum_share",
|
|
365
|
+
"probe_interval",
|
|
366
|
+
"warmup_values",
|
|
276
367
|
)
|
|
277
368
|
|
|
278
369
|
def __init__(self, registry: DetectorRegistry, config: ScanConfig) -> None:
|
|
@@ -300,6 +391,13 @@ class DetectorSet:
|
|
|
300
391
|
self.max_examples = config.limits.max_evidence_examples
|
|
301
392
|
self.max_length = config.limits.max_stored_value_length
|
|
302
393
|
self.minimum_share = exact_share(config.detection.minimum_share)
|
|
394
|
+
self.warmup_values = config.detection.warmup_values
|
|
395
|
+
self.probe_interval = config.detection.probe_interval
|
|
396
|
+
# The number and date detectors give every value its technical type
|
|
397
|
+
# family (design 9.7): adaptive detection never skips them.
|
|
398
|
+
self._adaptive = tuple(
|
|
399
|
+
i not in (self._number, self._date) for i in range(len(active))
|
|
400
|
+
)
|
|
303
401
|
self._exposure = ExposureGate(config.exposure.sensitive_values)
|
|
304
402
|
# Native types that no active detector accepts need no work.
|
|
305
403
|
self._idle = {
|
|
@@ -318,30 +416,37 @@ class DetectorSet:
|
|
|
318
416
|
)
|
|
319
417
|
for native_type in _NATIVE_SCALARS
|
|
320
418
|
}
|
|
419
|
+
self._shape_cache: dict[str, tuple[bool, ...]] = {}
|
|
321
420
|
|
|
322
421
|
def versions(self) -> dict[str, int]:
|
|
323
422
|
return {detector.id: detector.version for detector in self.active}
|
|
324
423
|
|
|
325
|
-
def classify(
|
|
424
|
+
def classify(
|
|
425
|
+
self, native_type: str, text: str, plans: Plans | None = None
|
|
426
|
+
) -> tuple[object, ...]:
|
|
326
427
|
"""One entry per active detector: a classification, ``None`` (not
|
|
327
|
-
matched), ``NOT_TESTED``, ``NOT_ELIGIBLE`` or a
|
|
428
|
+
matched), ``NOT_TESTED``, ``SKIPPED``, ``NOT_ELIGIBLE`` or a
|
|
429
|
+
``DetectorFailure``. ``plans`` skips detectors (``skipping``)."""
|
|
328
430
|
idle = self._idle.get(native_type)
|
|
329
431
|
if idle is not None:
|
|
330
432
|
return idle
|
|
331
433
|
results: list[object] = []
|
|
332
|
-
|
|
333
|
-
for plan in self._plans[native_type]:
|
|
434
|
+
fits = None
|
|
435
|
+
for i, plan in enumerate((plans or self._plans)[native_type]):
|
|
334
436
|
if plan is None:
|
|
335
437
|
results.append(NOT_ELIGIBLE)
|
|
336
438
|
continue
|
|
439
|
+
if plan is _SKIP:
|
|
440
|
+
results.append(SKIPPED)
|
|
441
|
+
continue
|
|
337
442
|
classify, cap, pattern = plan
|
|
338
443
|
if cap is not None and len(text) > cap:
|
|
339
444
|
results.append(NOT_TESTED)
|
|
340
445
|
continue
|
|
341
446
|
if pattern is not None:
|
|
342
|
-
if
|
|
343
|
-
|
|
344
|
-
if
|
|
447
|
+
if fits is None:
|
|
448
|
+
fits = self._fits(shape(text))
|
|
449
|
+
if not fits[i]:
|
|
345
450
|
results.append(None)
|
|
346
451
|
continue
|
|
347
452
|
try:
|
|
@@ -354,11 +459,52 @@ class DetectorSet:
|
|
|
354
459
|
results.append(found)
|
|
355
460
|
return tuple(results)
|
|
356
461
|
|
|
357
|
-
def
|
|
462
|
+
def _fits(self, value_shape: str) -> tuple[bool, ...]:
|
|
463
|
+
"""Whether ``value_shape`` fits the shapes of each active detector.
|
|
464
|
+
|
|
465
|
+
Shapes repeat across values far more than values do: the matches are
|
|
466
|
+
memoized per shape, in a bounded cache.
|
|
467
|
+
"""
|
|
468
|
+
cache = self._shape_cache
|
|
469
|
+
fits = cache.get(value_shape)
|
|
470
|
+
if fits is None:
|
|
471
|
+
if len(cache) >= SHAPE_CACHE_SIZE:
|
|
472
|
+
cache.clear()
|
|
473
|
+
fits = cache[value_shape] = tuple(
|
|
474
|
+
detector.shapes is None
|
|
475
|
+
or detector.shapes.fullmatch(value_shape) is not None
|
|
476
|
+
for detector in self.active
|
|
477
|
+
)
|
|
478
|
+
return fits
|
|
479
|
+
|
|
480
|
+
def skipping(self, tallies: list[DetectorTally]) -> Plans | None:
|
|
481
|
+
"""Plans that skip, for the rest of a field, the adaptive detectors
|
|
482
|
+
that reacted to none of its warm-up values; ``None`` when every
|
|
483
|
+
detector reacted (design 13). Skipped tallies record the warm-up."""
|
|
484
|
+
skip = [
|
|
485
|
+
adaptive and not tally.reacted()
|
|
486
|
+
for adaptive, tally in zip(self._adaptive, tallies)
|
|
487
|
+
]
|
|
488
|
+
if not any(skip):
|
|
489
|
+
return None
|
|
490
|
+
for tally, skipped in zip(tallies, skip):
|
|
491
|
+
if skipped:
|
|
492
|
+
tally.skipped_after = self.warmup_values
|
|
493
|
+
return {
|
|
494
|
+
native_type: tuple(
|
|
495
|
+
_SKIP if skipped and plan is not None else plan
|
|
496
|
+
for plan, skipped in zip(plans, skip)
|
|
497
|
+
)
|
|
498
|
+
for native_type, plans in self._plans.items()
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
def analyze(
|
|
502
|
+
self, text: str, plans: Plans | None = None
|
|
503
|
+
) -> tuple[tuple[object, ...], object, bool, str]:
|
|
358
504
|
"""Classifications of an analytical string, the number the number
|
|
359
505
|
detector reads without ambiguity, whether it has a decimal form, and
|
|
360
506
|
the technical type family."""
|
|
361
|
-
classifications = self.classify("string", text)
|
|
507
|
+
classifications = self.classify("string", text, plans)
|
|
362
508
|
number, decimal_form = None, False
|
|
363
509
|
if self._number is not None:
|
|
364
510
|
found = classifications[self._number]
|
|
@@ -389,6 +535,7 @@ class DetectorSet:
|
|
|
389
535
|
tallies: list[DetectorTally] | None,
|
|
390
536
|
report_failure: FailureReporter,
|
|
391
537
|
gate: ExposureGate = SHOW,
|
|
538
|
+
report_probe: ProbeReporter | None = None,
|
|
392
539
|
) -> list:
|
|
393
540
|
"""Every registered detector in order; ``tallies`` is ``None`` for a
|
|
394
541
|
field without analyzable values."""
|
|
@@ -409,7 +556,9 @@ class DetectorSet:
|
|
|
409
556
|
)
|
|
410
557
|
)
|
|
411
558
|
else:
|
|
412
|
-
results.append(
|
|
559
|
+
results.append(
|
|
560
|
+
tallies[position].result(report_failure, gate, report_probe)
|
|
561
|
+
)
|
|
413
562
|
position += 1
|
|
414
563
|
return results
|
|
415
564
|
|
|
@@ -347,6 +347,7 @@ class DateAccumulator(AmbiguityAccumulator):
|
|
|
347
347
|
9.6): ambiguous values are counted, never compared."""
|
|
348
348
|
|
|
349
349
|
__slots__ = ("kinds", "unresolved")
|
|
350
|
+
ignores_unmatched = True
|
|
350
351
|
|
|
351
352
|
def __init__(self, readings: list[str], resolution: dict[str, str] | None) -> None:
|
|
352
353
|
super().__init__(readings, resolution)
|
|
@@ -37,7 +37,7 @@ from tabalyst.scanner.models import (
|
|
|
37
37
|
from tabalyst.scanner.normalization import ANALYTICAL_STAGE, STAGES, Normalizer
|
|
38
38
|
|
|
39
39
|
if TYPE_CHECKING: # detectors import this module
|
|
40
|
-
from tabalyst.scanner.detectors.registry import DetectorSet
|
|
40
|
+
from tabalyst.scanner.detectors.registry import DetectorSet, Plans
|
|
41
41
|
|
|
42
42
|
# Exact decimal arithmetic: any rounding or exponent outside the range makes
|
|
43
43
|
# the numeric envelope ``limited`` with reason ``precision``.
|
|
@@ -151,11 +151,14 @@ def decimal_or_unconvertible(text: str) -> Decimal | _Unconvertible:
|
|
|
151
151
|
return UNCONVERTIBLE
|
|
152
152
|
|
|
153
153
|
|
|
154
|
-
def string_facts(
|
|
155
|
-
|
|
154
|
+
def string_facts(
|
|
155
|
+
raw: str, normalizer: Normalizer, detectors: DetectorSet, plans: Plans | None = None
|
|
156
|
+
) -> Facts:
|
|
157
|
+
"""Facts of a content string; detectors see its analytical value, and
|
|
158
|
+
``plans`` may skip some of them (design 13)."""
|
|
156
159
|
forms, changes = normalizer.run(raw)
|
|
157
160
|
text = forms[ANALYTICAL_STAGE]
|
|
158
|
-
classifications, number, decimal_form, family = detectors.analyze(text)
|
|
161
|
+
classifications, number, decimal_form, family = detectors.analyze(text, plans)
|
|
159
162
|
return (
|
|
160
163
|
"string",
|
|
161
164
|
text,
|
|
@@ -177,11 +180,13 @@ def canonical_text(native_type: str, value: object) -> str:
|
|
|
177
180
|
return str(value)
|
|
178
181
|
|
|
179
182
|
|
|
180
|
-
def scalar_facts(
|
|
183
|
+
def scalar_facts(
|
|
184
|
+
native_type: str, value: object, detectors: DetectorSet, plans: Plans | None = None
|
|
185
|
+
) -> Facts:
|
|
181
186
|
"""Facts of an integer, number (``Decimal``) or boolean; detectors that
|
|
182
187
|
accept its native type see its canonical text."""
|
|
183
188
|
text = canonical_text(native_type, value)
|
|
184
|
-
classifications = detectors.classify(native_type, text)
|
|
189
|
+
classifications = detectors.classify(native_type, text, plans)
|
|
185
190
|
if native_type == "boolean":
|
|
186
191
|
return (native_type, text, 0, -1, None, False, 0, None, classifications, "boolean")
|
|
187
192
|
return (
|
|
@@ -18,7 +18,7 @@ T = TypeVar("T")
|
|
|
18
18
|
|
|
19
19
|
FORMAT = "tabalyst.scan"
|
|
20
20
|
FORMAT_VERSION = "0.1.0a"
|
|
21
|
-
FORMAT_REVISION =
|
|
21
|
+
FORMAT_REVISION = 3
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
class ScanModel(BaseModel):
|
|
@@ -341,12 +341,23 @@ class _Detector(ScanModel):
|
|
|
341
341
|
version: int
|
|
342
342
|
|
|
343
343
|
|
|
344
|
+
class Adaptive(ScanModel):
|
|
345
|
+
"""Design 13: the detector reacted to none of the first ``skipped_after``
|
|
346
|
+
distinct values of the field, so later values other than probes were not
|
|
347
|
+
tested. ``diagnostic`` points to the warning raised when a probe reacted."""
|
|
348
|
+
|
|
349
|
+
skipped_after: int
|
|
350
|
+
not_tested: int
|
|
351
|
+
diagnostic: int | None
|
|
352
|
+
|
|
353
|
+
|
|
344
354
|
class DetectorComplete(_Detector):
|
|
345
355
|
status: Literal["complete"] = "complete"
|
|
346
356
|
coverage: Coverage
|
|
347
357
|
formats: list[FormatCount]
|
|
348
358
|
evidence: Evidence
|
|
349
359
|
details: dict[str, Any]
|
|
360
|
+
adaptive: Adaptive | None
|
|
350
361
|
|
|
351
362
|
|
|
352
363
|
class DetectorNotApplicable(_Detector):
|