queryearth 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
queryearth/__init__.py ADDED
@@ -0,0 +1,110 @@
1
+ """queryearth: match rules scoped by area, time and other dimensions to a request.
2
+
3
+ queryearth.time holds time keys, intervals and their measures, and
4
+ queryearth.space points, polygons and closed-set measures (imported as
5
+ ``from queryearth import space as sp``). The rule engine is at the top level:
6
+ an Index of dimensions, products, regions and rules answers queries with
7
+ Finding objects, and emit() reports findings under a policy. See docs/design.md
8
+ for the design.
9
+ """
10
+
11
+ try:
12
+ from queryearth._version import __version__
13
+ except ImportError: # running from a source tree that was never built or installed
14
+ __version__ = "0.0.0"
15
+
16
+ from queryearth import space
17
+ from queryearth.dimensions import (
18
+ Axis,
19
+ DimMatch,
20
+ Dimension,
21
+ Labels,
22
+ Prefilter,
23
+ Versions,
24
+ )
25
+ from queryearth.emit import (
26
+ AdvisoryError,
27
+ AdvisoryWarning,
28
+ InfoAdvisory,
29
+ Policy,
30
+ SevereAdvisory,
31
+ Sink,
32
+ WarningAdvisory,
33
+ emit,
34
+ policy,
35
+ )
36
+ from queryearth.errors import DuplicateCodeError, LoadError, UnknownNameError
37
+ from queryearth.index import Index, PointResult, ScoreFn, Suitability, default_score
38
+ from queryearth.objects import (
39
+ Action,
40
+ AxisOverlap,
41
+ Confidence,
42
+ Coverage,
43
+ Finding,
44
+ Kind,
45
+ Match,
46
+ Outcome,
47
+ Product,
48
+ Query,
49
+ Request,
50
+ Scope,
51
+ Severity,
52
+ Status,
53
+ Suggestion,
54
+ )
55
+ from queryearth.rules import CheckResult, ComputedRule, Rule, RuleFunc, RuleKind
56
+ from queryearth.space import Space
57
+ from queryearth.time import Interval, TimeSpec, interval, parse_time
58
+
59
+ __all__ = [
60
+ "Action",
61
+ "AdvisoryError",
62
+ "AdvisoryWarning",
63
+ "Axis",
64
+ "AxisOverlap",
65
+ "CheckResult",
66
+ "ComputedRule",
67
+ "Confidence",
68
+ "Coverage",
69
+ "DimMatch",
70
+ "Dimension",
71
+ "DuplicateCodeError",
72
+ "Finding",
73
+ "Index",
74
+ "InfoAdvisory",
75
+ "Interval",
76
+ "Kind",
77
+ "Labels",
78
+ "LoadError",
79
+ "Match",
80
+ "Outcome",
81
+ "PointResult",
82
+ "Policy",
83
+ "Prefilter",
84
+ "Product",
85
+ "Query",
86
+ "Request",
87
+ "Rule",
88
+ "RuleFunc",
89
+ "RuleKind",
90
+ "Scope",
91
+ "ScoreFn",
92
+ "SevereAdvisory",
93
+ "Severity",
94
+ "Sink",
95
+ "Space",
96
+ "Status",
97
+ "Suggestion",
98
+ "Suitability",
99
+ "TimeSpec",
100
+ "UnknownNameError",
101
+ "Versions",
102
+ "WarningAdvisory",
103
+ "__version__",
104
+ "default_score",
105
+ "emit",
106
+ "interval",
107
+ "parse_time",
108
+ "policy",
109
+ "space",
110
+ ]
queryearth/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.0.1'
22
+ __version_tuple__ = version_tuple = (0, 0, 1)
23
+
24
+ __commit_id__ = commit_id = 'g6432531f0'
@@ -0,0 +1,532 @@
1
+ """Dimensions: every kind of scope, through one interface (section 4.13).
2
+
3
+ A dimension decides only whether one rule value meets one query value; the
4
+ Index does everything else (conditional findings, unknown names, requests,
5
+ sorting). Space and time are built in on every Index and cannot be replaced.
6
+ Axis (numeric ranges), Labels (facets matched by glob patterns) and Versions
7
+ (OSV-style version ranges) are the built-in plugins, and a user plugin
8
+ implements the same four members.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ import re
15
+ from collections.abc import Callable, Iterable, Mapping, Sequence
16
+ from dataclasses import dataclass, field
17
+ from fnmatch import fnmatchcase
18
+ from typing import Any, ClassVar, Protocol, runtime_checkable
19
+
20
+ import numpy as np
21
+
22
+ from queryearth import space as sp
23
+ from queryearth.objects import AxisOverlap
24
+ from queryearth.time import TimeSpec, overlap, time_fraction
25
+
26
+ __all__ = [
27
+ "Axis",
28
+ "DimMatch",
29
+ "Dimension",
30
+ "Labels",
31
+ "Patterns",
32
+ "Prefilter",
33
+ "SpaceDimension",
34
+ "TimeDimension",
35
+ "VersionRange",
36
+ "Versions",
37
+ ]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class DimMatch:
42
+ """How one rule value met one query value on one dimension."""
43
+
44
+ overlap: Any = None # dimension-specific; serialised with the plugin's to_json
45
+ fraction: float | None = None # share of the query's value covered, if measurable
46
+ to_json: Callable[[Any], Any] | None = field(
47
+ default=None, compare=False, repr=False
48
+ )
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ """For Finding.to_dict(): the overlap through the plugin's to_json, if any."""
52
+ overlap = self.overlap if self.to_json is None else self.to_json(self.overlap)
53
+ return {"overlap": overlap, "fraction": self.fraction}
54
+
55
+
56
+ @runtime_checkable
57
+ class Dimension(Protocol):
58
+ """One kind of scope, declared on an Index under a unique name.
59
+
60
+ Optional members, looked up with getattr: comparable(rule, query) -> bool
61
+ (False makes the finding conditional on the dimension); build(pairs) ->
62
+ Prefilter over the (position, rule value) pairs of the rules that constrain
63
+ it; to_json(overlap) for Finding.to_dict(); late = True to run after the
64
+ exact spatial test; extent(rule) for what an undecided finding reports;
65
+ always = True to call match(None, query) when a rule leaves it unconstrained.
66
+ """
67
+
68
+ name: str # key in scopes, queries, records and conditional_on
69
+ schema: Mapping[str, Any] # JSON Schema for the rule-side value in records
70
+
71
+ def parse_rule(self, value: Any) -> Any: ... # at load; ValueError if invalid
72
+
73
+ def parse_query(self, value: Any) -> Any: ... # at query time; ValueError
74
+
75
+ def match(self, rule: Any, query: Any) -> DimMatch | None: ... # None: no overlap
76
+
77
+
78
+ class Prefilter(Protocol):
79
+ """Narrows the rules before match() runs; may return extra positions."""
80
+
81
+ def candidates(self, query: Any) -> set[int]: ...
82
+
83
+
84
+ # ---- space and time: built in on every Index --------------------------------------
85
+ class SpaceDimension:
86
+ """Space: closed-set meeting, then the area fraction and overlap in km².
87
+
88
+ It runs late, after the cheaper dimensions, and also measures the query
89
+ against a rule that leaves space unconstrained. Records carry space in the
90
+ GeoJSON geometry, so it has no properties schema.
91
+ """
92
+
93
+ name = "space"
94
+ schema: ClassVar[Mapping[str, Any]] = {}
95
+ late = True
96
+ always = True
97
+
98
+ def parse_rule(self, value: sp.Space) -> sp.Space:
99
+ return value
100
+
101
+ parse_query = parse_rule
102
+
103
+ def match(self, rule: sp.Space | None, query: sp.Space) -> DimMatch | None:
104
+ if rule is not None and not sp.meets(rule, query):
105
+ return None
106
+ fraction, km2 = sp.measure(rule, query)
107
+ return DimMatch(km2, fraction)
108
+
109
+
110
+ class TimeDimension:
111
+ """Time: the overlapping periods and the share of the query they cover.
112
+
113
+ It also measures the query against a rule that leaves time unconstrained,
114
+ and an undecided rule reports its own periods. Records carry time in the
115
+ JSON-FG time member, so it has no properties schema.
116
+ """
117
+
118
+ name = "time"
119
+ schema: ClassVar[Mapping[str, Any]] = {}
120
+ always = True
121
+
122
+ def parse_rule(self, value: TimeSpec) -> TimeSpec:
123
+ return value
124
+
125
+ parse_query = parse_rule
126
+
127
+ def match(self, rule: TimeSpec | None, query: TimeSpec) -> DimMatch | None:
128
+ both = query if rule is None else overlap(rule, query)
129
+ if both is None:
130
+ return None
131
+ return DimMatch(both.to_wire(), time_fraction(both, query))
132
+
133
+ def extent(self, rule: TimeSpec) -> tuple[tuple[str, str], ...]:
134
+ """The rule's own periods, reported when the query gives no time."""
135
+ return () if rule.extent == (None, None) else rule.to_wire()
136
+
137
+
138
+ # ---- plugin schemas -------------------------------------------------------------
139
+ _TYPES = dict(object=Mapping, array=(list, tuple), string=str, boolean=bool)
140
+ _TYPES |= {"number": (int, float), "integer": int, "null": type(None)}
141
+ _KEYWORDS = set(
142
+ "type enum pattern minLength minimum maximum items minItems maxItems properties "
143
+ "required additionalProperties anyOf oneOf not title description $comment".split()
144
+ )
145
+
146
+
147
+ def schema_problem(value: Any, schema: Any) -> str | None:
148
+ """Why a record's value fails a plugin dimension's schema, or None.
149
+
150
+ Implements the JSON Schema keywords in _KEYWORDS, which plugin schemas are
151
+ limited to; any other keyword raises ValueError rather than pass silently.
152
+ """
153
+ if isinstance(schema, bool):
154
+ return None if schema else f"{value!r} is not allowed here"
155
+ if set(schema) - _KEYWORDS:
156
+ raise ValueError(f"unsupported schema keywords {set(schema) - _KEYWORDS}")
157
+ kinds = schema.get("type", list(_TYPES))
158
+ kinds = [kinds] if isinstance(kinds, str) else kinds
159
+ number = isinstance(value, (int, float)) and not isinstance(value, bool)
160
+ numeric = ("number", "integer")
161
+ if not any(
162
+ isinstance(value, _TYPES[k]) for k in kinds if number or k not in numeric
163
+ ):
164
+ return f"{value!r} is not of type {kinds}"
165
+ if "enum" in schema and value not in schema["enum"]:
166
+ return f"{value!r} is not one of {schema['enum']}"
167
+ if isinstance(value, str) and (
168
+ len(value) < schema.get("minLength", 0)
169
+ or not re.search(schema.get("pattern", ""), value)
170
+ ):
171
+ return f"{value!r} does not match {schema}"
172
+ if number and not (
173
+ schema.get("minimum", -math.inf) <= value <= schema.get("maximum", math.inf)
174
+ ):
175
+ return f"{value!r} is out of range"
176
+ nested: list[tuple[Any, Any]] = []
177
+ if isinstance(value, (list, tuple)):
178
+ if not schema.get("minItems", 0) <= len(value) <= schema.get("maxItems", 1e9):
179
+ return f"{value!r} has the wrong number of items"
180
+ nested += [(v, schema.get("items", True)) for v in value]
181
+ if isinstance(value, Mapping):
182
+ if [k for k in schema.get("required", ()) if k not in value]:
183
+ return f"{dict(value)!r} lacks one of {schema['required']}"
184
+ props = schema.get("properties", {})
185
+ extra = schema.get("additionalProperties", True)
186
+ nested += [(v, props.get(k, extra)) for k, v in value.items()]
187
+ problem = next(filter(None, (schema_problem(v, sub) for v, sub in nested)), None)
188
+ if problem:
189
+ return problem
190
+ if not any(schema_problem(value, s) is None for s in schema.get("anyOf", [True])):
191
+ return f"{value!r} matches none of anyOf"
192
+ if sum(schema_problem(value, s) is None for s in schema.get("oneOf", [True])) != 1:
193
+ return f"{value!r} does not match exactly one of oneOf"
194
+ if "not" in schema and schema_problem(value, schema["not"]) is None:
195
+ return f"{value!r} matches a schema it must not"
196
+ return None
197
+
198
+
199
+ def _number(value: Any, what: str) -> float:
200
+ if isinstance(value, bool) or not isinstance(value, (int, float, np.number)):
201
+ raise ValueError(f"{what} must be a number, not {value!r}")
202
+ number = float(value)
203
+ if math.isnan(number):
204
+ raise ValueError(f"{what} is not a number")
205
+ return number
206
+
207
+
208
+ def _string_list(value: Any, what: str) -> tuple[str, ...]:
209
+ if isinstance(value, str):
210
+ value = [value] # in Python a bare string is a one-item list
211
+ if isinstance(value, Mapping) or not isinstance(value, Iterable):
212
+ raise ValueError(f"{what} must be a list of strings, not {value!r}")
213
+ items = tuple(value)
214
+ if not items or not all(isinstance(v, str) and v for v in items):
215
+ raise ValueError(f"{what} must be a non-empty list of non-empty strings")
216
+ return items
217
+
218
+
219
+ # ---- Axis: a named ordered dimension in one unit ----------------------------------
220
+ _AXIS_SCHEMA: Mapping[str, Any] = {
221
+ "type": "object",
222
+ "properties": {
223
+ "gt": {"type": "number"},
224
+ "ge": {"type": "number"},
225
+ "lt": {"type": "number"},
226
+ "le": {"type": "number"},
227
+ "unit": {"type": "string", "minLength": 1},
228
+ },
229
+ "additionalProperties": False,
230
+ "anyOf": [
231
+ {"required": ["gt"]},
232
+ {"required": ["ge"]},
233
+ {"required": ["lt"]},
234
+ {"required": ["le"]},
235
+ ],
236
+ "not": {"anyOf": [{"required": ["gt", "ge"]}, {"required": ["lt", "le"]}]},
237
+ }
238
+
239
+
240
+ def axis_intersection(rule: AxisOverlap, lo: float, hi: float) -> AxisOverlap | None:
241
+ """The intersection of a rule interval with the closed range [lo, hi]."""
242
+ start = max(rule.lo, lo)
243
+ end = min(rule.hi, hi)
244
+ start_closed = rule.lo_closed if rule.lo >= lo else True
245
+ end_closed = rule.hi_closed if rule.hi <= hi else True
246
+ if start < end or (start == end and start_closed and end_closed):
247
+ return AxisOverlap(start, end, start_closed, end_closed)
248
+ return None
249
+
250
+
251
+ @dataclass(frozen=True)
252
+ class Axis:
253
+ """A named, ordered, numeric dimension with exactly one unit.
254
+
255
+ A rule states {gt, ge, lt, le, unit}; a query gives a number or a closed
256
+ range (lo, hi). The overlap is the intersection, keeping open ends.
257
+ """
258
+
259
+ name: str
260
+ unit: str
261
+ description: str = ""
262
+ schema: ClassVar[Mapping[str, Any]] = _AXIS_SCHEMA
263
+
264
+ def __post_init__(self) -> None:
265
+ if not isinstance(self.unit, str) or not self.unit:
266
+ raise ValueError(f"axis {self.name!r} needs a unit")
267
+
268
+ def parse_rule(self, value: Any) -> AxisOverlap:
269
+ if not isinstance(value, Mapping):
270
+ raise ValueError(f"an axis bound is a mapping of gt, ge, lt, le, unit")
271
+ unknown = set(value) - {"gt", "ge", "lt", "le", "unit"}
272
+ if unknown:
273
+ raise ValueError(f"unknown axis bound keys {sorted(unknown)}")
274
+ if {"gt", "ge"} <= set(value) or {"lt", "le"} <= set(value):
275
+ raise ValueError("give at most one of gt and ge, and one of lt and le")
276
+ if not set(value) & {"gt", "ge", "lt", "le"}:
277
+ raise ValueError("an axis bound needs at least one of gt, ge, lt, le")
278
+ unit = value.get("unit")
279
+ if unit is not None and unit != self.unit:
280
+ raise ValueError(
281
+ f"unit {unit!r} does not match the unit {self.unit!r} of axis "
282
+ f"{self.name!r}; the core does no unit conversion"
283
+ )
284
+ lo, lo_closed, hi, hi_closed = -math.inf, False, math.inf, False
285
+ if "gt" in value:
286
+ lo = _number(value["gt"], "gt")
287
+ if "ge" in value:
288
+ lo, lo_closed = _number(value["ge"], "ge"), True
289
+ if "lt" in value:
290
+ hi = _number(value["lt"], "lt")
291
+ if "le" in value:
292
+ hi, hi_closed = _number(value["le"], "le"), True
293
+ if lo > hi or (lo == hi and not (lo_closed and hi_closed)):
294
+ raise ValueError(f"the axis bounds {dict(value)} leave no value")
295
+ return AxisOverlap(lo, hi, lo_closed, hi_closed)
296
+
297
+ def parse_query(self, value: Any) -> tuple[float, float]:
298
+ if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
299
+ v = _number(value, f"axis {self.name!r}")
300
+ lo, hi = v, v
301
+ elif len(value) != 2:
302
+ raise ValueError(f"axis {self.name!r} takes a number or a range (lo, hi)")
303
+ else:
304
+ lo, hi = (_number(v, f"axis {self.name!r}") for v in value)
305
+ if not (math.isfinite(lo) and math.isfinite(hi)) or lo > hi:
306
+ raise ValueError(f"axis {self.name!r} needs a finite range lo <= hi")
307
+ return lo, hi
308
+
309
+ def match(self, rule: AxisOverlap, query: tuple[float, float]) -> DimMatch | None:
310
+ lo, hi = query
311
+ found = axis_intersection(rule, lo, hi)
312
+ if found is None:
313
+ return None
314
+ fraction = 1.0 if hi == lo else (found.hi - found.lo) / (hi - lo)
315
+ return DimMatch(found, fraction)
316
+
317
+ def extent(self, rule: AxisOverlap) -> AxisOverlap:
318
+ """The rule's own interval, reported when the query omits the axis."""
319
+ return rule
320
+
321
+
322
+ # ---- Labels: a facet matched by case-sensitive fnmatch globs ----------------------
323
+ _PATTERNS = {
324
+ "type": "array",
325
+ "minItems": 1,
326
+ "items": {"type": "string", "minLength": 1},
327
+ }
328
+ _LABELS_SCHEMA: Mapping[str, Any] = {
329
+ "oneOf": [
330
+ _PATTERNS,
331
+ {
332
+ "type": "object",
333
+ "properties": {"any_of": _PATTERNS, "none_of": _PATTERNS},
334
+ "additionalProperties": False,
335
+ "minProperties": 1,
336
+ },
337
+ ]
338
+ }
339
+ _GLOB = frozenset("*?[")
340
+
341
+
342
+ def is_glob(pattern: str) -> bool:
343
+ """Whether a facet pattern uses fnmatch syntax rather than a literal value."""
344
+ return not _GLOB.isdisjoint(pattern)
345
+
346
+
347
+ @dataclass(frozen=True)
348
+ class Patterns:
349
+ """A parsed facet rule: values must match some any_of and no none_of."""
350
+
351
+ any_of: tuple[str, ...] | None # None: any value not excluded
352
+ none_of: tuple[str, ...] = ()
353
+
354
+ def admits(self, value: str) -> bool:
355
+ if any(fnmatchcase(value, p) for p in self.none_of):
356
+ return False
357
+ return self.any_of is None or any(fnmatchcase(value, p) for p in self.any_of)
358
+
359
+
360
+ @dataclass(frozen=True)
361
+ class Labels:
362
+ """A categorical facet: rule patterns against query values (section 4.7)."""
363
+
364
+ name: str
365
+ description: str = ""
366
+ schema: ClassVar[Mapping[str, Any]] = _LABELS_SCHEMA
367
+
368
+ def parse_rule(self, value: Any) -> Patterns:
369
+ if isinstance(value, Patterns):
370
+ return value
371
+ if not isinstance(value, Mapping):
372
+ return Patterns(_string_list(value, f"facet {self.name!r}"))
373
+ unknown = set(value) - {"any_of", "none_of"}
374
+ if unknown or not value:
375
+ raise ValueError(
376
+ f"facet {self.name!r} takes a list of patterns or "
377
+ f"{{'any_of': [...], 'none_of': [...]}}, got {dict(value)!r}"
378
+ )
379
+ any_of = None
380
+ if "any_of" in value:
381
+ any_of = _string_list(value["any_of"], f"facet {self.name!r} any_of")
382
+ none_of = ()
383
+ if "none_of" in value:
384
+ none_of = _string_list(value["none_of"], f"facet {self.name!r} none_of")
385
+ return Patterns(any_of, none_of)
386
+
387
+ def parse_query(self, value: Any) -> tuple[str, ...]:
388
+ return tuple(dict.fromkeys(_string_list(value, f"facet {self.name!r}")))
389
+
390
+ def match(self, rule: Patterns, query: tuple[str, ...]) -> DimMatch | None:
391
+ hits = tuple(v for v in query if rule.admits(v))
392
+ if not hits:
393
+ return None
394
+ return DimMatch(hits, len(hits) / len(query))
395
+
396
+
397
+ # ---- Versions: OSV-style ranges ----------------------------------------------------
398
+ _DOTTED = re.compile(r"[0-9]+(?:\.[0-9]+)*")
399
+ _RANGE = {
400
+ "type": "object",
401
+ "properties": {
402
+ "introduced": {"type": "string", "minLength": 1},
403
+ "fixed": {"type": "string", "minLength": 1},
404
+ "last_affected": {"type": "string", "minLength": 1},
405
+ "versions": {
406
+ "type": "array",
407
+ "minItems": 1,
408
+ "items": {"type": "string", "minLength": 1},
409
+ },
410
+ },
411
+ "additionalProperties": False,
412
+ "minProperties": 1,
413
+ "not": {"required": ["fixed", "last_affected"]},
414
+ }
415
+ _VERSIONS_SCHEMA: Mapping[str, Any] = {
416
+ "oneOf": [_RANGE, {"type": "array", "minItems": 1, "items": _RANGE}]
417
+ }
418
+
419
+
420
+ def dotted(version: str) -> tuple[int, ...] | None:
421
+ """The integers of a dotted-integer version, or None for any other string."""
422
+ if not isinstance(version, str) or not _DOTTED.fullmatch(version):
423
+ return None
424
+ return tuple(int(part) for part in version.split("."))
425
+
426
+
427
+ def compare_versions(a: tuple[int, ...], b: tuple[int, ...]) -> int:
428
+ """-1, 0 or 1, comparing dotted integers padded with zeros: 4.07 == 4.7.0."""
429
+ size = max(len(a), len(b))
430
+ a = a + (0,) * (size - len(a))
431
+ b = b + (0,) * (size - len(b))
432
+ return (a > b) - (a < b)
433
+
434
+
435
+ @dataclass(frozen=True)
436
+ class VersionRange:
437
+ """One OSV-style range: introduced (in), fixed (out) or last_affected (in),
438
+ plus exact versions. Bounds are dotted integers; anything else goes in
439
+ versions, where it matches literally."""
440
+
441
+ introduced: str | None = None
442
+ fixed: str | None = None
443
+ last_affected: str | None = None
444
+ versions: tuple[str, ...] = ()
445
+
446
+ def __post_init__(self) -> None:
447
+ if self.fixed is not None and self.last_affected is not None:
448
+ raise ValueError("a version range has fixed or last_affected, not both")
449
+ for name in ("introduced", "fixed", "last_affected"):
450
+ bound = getattr(self, name)
451
+ if bound is not None and dotted(bound) is None:
452
+ raise ValueError(
453
+ f"version bound {name}={bound!r} is not a dotted integer; "
454
+ "list other versions in 'versions'"
455
+ )
456
+ object.__setattr__(self, "versions", tuple(self.versions))
457
+ if not all(isinstance(v, str) and v for v in self.versions):
458
+ raise ValueError("versions must be non-empty strings")
459
+ if not self.bounded and not self.versions:
460
+ raise ValueError("a version range needs at least one field")
461
+
462
+ @property
463
+ def bounded(self) -> bool:
464
+ return (self.introduced, self.fixed, self.last_affected) != (None, None, None)
465
+
466
+ def contains(self, version: str) -> bool:
467
+ number = dotted(version)
468
+ for exact in self.versions:
469
+ other = dotted(exact)
470
+ if exact == version or (
471
+ number is not None
472
+ and other is not None
473
+ and compare_versions(number, other) == 0
474
+ ):
475
+ return True
476
+ if not self.bounded or number is None:
477
+ return False
478
+ if self.introduced is not None:
479
+ if compare_versions(number, dotted(self.introduced)) < 0:
480
+ return False
481
+ if self.fixed is not None:
482
+ if compare_versions(number, dotted(self.fixed)) >= 0:
483
+ return False
484
+ if self.last_affected is not None:
485
+ if compare_versions(number, dotted(self.last_affected)) > 0:
486
+ return False
487
+ return True
488
+
489
+
490
+ @dataclass(frozen=True)
491
+ class Versions:
492
+ """The version facet: a union of OSV-style ranges (section 4.8)."""
493
+
494
+ name: str = "version"
495
+ schema: ClassVar[Mapping[str, Any]] = _VERSIONS_SCHEMA
496
+
497
+ def parse_rule(self, value: Any) -> tuple[VersionRange, ...]:
498
+ if isinstance(value, Mapping):
499
+ value = [value]
500
+ if isinstance(value, str) or not isinstance(value, Iterable):
501
+ raise ValueError(
502
+ f"facet {self.name!r} takes a version range or a list of them, "
503
+ f"not {value!r}"
504
+ )
505
+ ranges = []
506
+ for item in value:
507
+ if isinstance(item, VersionRange):
508
+ ranges.append(item)
509
+ continue
510
+ if not isinstance(item, Mapping):
511
+ raise ValueError(f"a version range is a mapping, not {item!r}")
512
+ unknown = set(item) - {"introduced", "fixed", "last_affected", "versions"}
513
+ if unknown:
514
+ raise ValueError(f"unknown version range keys {sorted(unknown)}")
515
+ fields = dict(item)
516
+ if "versions" in fields:
517
+ fields["versions"] = _string_list(fields["versions"], "versions")
518
+ ranges.append(VersionRange(**fields))
519
+ if not ranges:
520
+ raise ValueError(f"facet {self.name!r} needs at least one range")
521
+ return tuple(ranges)
522
+
523
+ def parse_query(self, value: Any) -> tuple[str, ...]:
524
+ return tuple(dict.fromkeys(_string_list(value, f"facet {self.name!r}")))
525
+
526
+ def match(
527
+ self, rule: tuple[VersionRange, ...], query: tuple[str, ...]
528
+ ) -> DimMatch | None:
529
+ hits = tuple(v for v in query if any(r.contains(v) for r in rule))
530
+ if not hits:
531
+ return None
532
+ return DimMatch(hits, len(hits) / len(query))