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 +110 -0
- queryearth/_version.py +24 -0
- queryearth/dimensions.py +532 -0
- queryearth/emit.py +190 -0
- queryearth/errors.py +36 -0
- queryearth/index.py +847 -0
- queryearth/objects.py +516 -0
- queryearth/packs.py +216 -0
- queryearth/rules.py +497 -0
- queryearth/space.py +306 -0
- queryearth/time.py +312 -0
- queryearth-0.0.1.dist-info/METADATA +55 -0
- queryearth-0.0.1.dist-info/RECORD +16 -0
- queryearth-0.0.1.dist-info/WHEEL +5 -0
- queryearth-0.0.1.dist-info/licenses/LICENSE +201 -0
- queryearth-0.0.1.dist-info/top_level.txt +1 -0
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'
|
queryearth/dimensions.py
ADDED
|
@@ -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))
|