jevframe 0.1.0__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.
- jevframe/__init__.py +9 -0
- jevframe/_accessor.py +126 -0
- jevframe/_cache.py +66 -0
- jevframe/_context.py +103 -0
- jevframe/_engine.py +206 -0
- jevframe/_schema.py +189 -0
- jevframe/_types.py +70 -0
- jevframe/pandas.py +73 -0
- jevframe/polars.py +85 -0
- jevframe/py.typed +0 -0
- jevframe-0.1.0.dist-info/METADATA +289 -0
- jevframe-0.1.0.dist-info/RECORD +14 -0
- jevframe-0.1.0.dist-info/WHEEL +4 -0
- jevframe-0.1.0.dist-info/licenses/LICENSE +21 -0
jevframe/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Semantic dataframe evaluation using TypeSafe Jev.
|
|
2
|
+
|
|
3
|
+
Import ``jevframe.pandas`` or ``jevframe.polars`` to register the native accessor.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ._cache import MemoryCache
|
|
7
|
+
from ._types import EvaluationError, EvaluationWarning, Progress, RowFailure
|
|
8
|
+
|
|
9
|
+
__all__ = ["EvaluationError", "EvaluationWarning", "MemoryCache", "Progress", "RowFailure"]
|
jevframe/_accessor.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""The common public API; integrations supply only row and result conversion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from typing import Any, Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
from typesafe_sdk import AsyncTypeSafeClient, JSONContent, Noul, Score
|
|
10
|
+
|
|
11
|
+
from ._cache import MemoryCache
|
|
12
|
+
from ._context import RowSource
|
|
13
|
+
from ._engine import Options, evaluate_rows
|
|
14
|
+
from ._schema import QuestionPlan, choice_question, make_plan
|
|
15
|
+
from ._types import Errors, Nulls, Output, ProgressCallback, Question, State
|
|
16
|
+
|
|
17
|
+
FrameT = TypeVar("FrameT")
|
|
18
|
+
SeriesT = TypeVar("SeriesT")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _validate_output(output: Output) -> None:
|
|
22
|
+
if output not in ("columns", "struct"):
|
|
23
|
+
raise ValueError("output must be 'columns' or 'struct'")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BaseAccessor(ABC, Generic[FrameT, SeriesT]):
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def _source(self, state: State) -> RowSource: ...
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def _frame(
|
|
32
|
+
self, values: list[tuple[Any, ...]], plan: QuestionPlan, source: RowSource, output: Output
|
|
33
|
+
) -> FrameT: ...
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def _series(self, values: list[tuple[Any, ...]], source: RowSource) -> SeriesT: ...
|
|
37
|
+
|
|
38
|
+
async def noul(
|
|
39
|
+
self,
|
|
40
|
+
question: str,
|
|
41
|
+
*,
|
|
42
|
+
state: State,
|
|
43
|
+
client: AsyncTypeSafeClient | None = None,
|
|
44
|
+
model: str | None = None,
|
|
45
|
+
max_concurrency: int = 16,
|
|
46
|
+
cache: MemoryCache | None = None,
|
|
47
|
+
progress: bool | ProgressCallback = False,
|
|
48
|
+
errors: Errors = "raise",
|
|
49
|
+
nulls: Nulls = "include",
|
|
50
|
+
) -> SeriesT:
|
|
51
|
+
"""Return a yes-probability Series, without thresholding or changing rows."""
|
|
52
|
+
options = Options(client, model, max_concurrency, cache, progress, errors, nulls)
|
|
53
|
+
plan = make_plan({"result": Noul(instructions=question)}, prefix=False)
|
|
54
|
+
source = self._source(state)
|
|
55
|
+
values = await evaluate_rows(source, state, plan, options)
|
|
56
|
+
return self._series(values, source)
|
|
57
|
+
|
|
58
|
+
async def choice(
|
|
59
|
+
self,
|
|
60
|
+
question: str,
|
|
61
|
+
*,
|
|
62
|
+
choices: Sequence[str] | Mapping[str, JSONContent | None],
|
|
63
|
+
state: State,
|
|
64
|
+
output: Output = "columns",
|
|
65
|
+
client: AsyncTypeSafeClient | None = None,
|
|
66
|
+
model: str | None = None,
|
|
67
|
+
max_concurrency: int = 16,
|
|
68
|
+
cache: MemoryCache | None = None,
|
|
69
|
+
progress: bool | ProgressCallback = False,
|
|
70
|
+
errors: Errors = "raise",
|
|
71
|
+
nulls: Nulls = "include",
|
|
72
|
+
) -> FrameT:
|
|
73
|
+
"""Return labels, confidence, and probabilities as columns or one result struct."""
|
|
74
|
+
_validate_output(output)
|
|
75
|
+
options = Options(client, model, max_concurrency, cache, progress, errors, nulls)
|
|
76
|
+
plan = make_plan({"result": choice_question(question, choices)}, prefix=False)
|
|
77
|
+
source = self._source(state)
|
|
78
|
+
values = await evaluate_rows(source, state, plan, options)
|
|
79
|
+
return self._frame(values, plan, source, output)
|
|
80
|
+
|
|
81
|
+
async def score(
|
|
82
|
+
self,
|
|
83
|
+
question: str,
|
|
84
|
+
*,
|
|
85
|
+
levels: Sequence[JSONContent],
|
|
86
|
+
state: State,
|
|
87
|
+
output: Output = "columns",
|
|
88
|
+
client: AsyncTypeSafeClient | None = None,
|
|
89
|
+
model: str | None = None,
|
|
90
|
+
max_concurrency: int = 16,
|
|
91
|
+
cache: MemoryCache | None = None,
|
|
92
|
+
progress: bool | ProgressCallback = False,
|
|
93
|
+
errors: Errors = "raise",
|
|
94
|
+
nulls: Nulls = "include",
|
|
95
|
+
) -> FrameT:
|
|
96
|
+
"""Return level/label, expected score, and probabilities as columns or a struct."""
|
|
97
|
+
_validate_output(output)
|
|
98
|
+
options = Options(client, model, max_concurrency, cache, progress, errors, nulls)
|
|
99
|
+
if isinstance(levels, (str, bytes)) or not isinstance(levels, Sequence):
|
|
100
|
+
raise TypeError("levels must be an ordered sequence of descriptions")
|
|
101
|
+
plan = make_plan({"result": Score(instructions=question, criteria=levels)}, prefix=False)
|
|
102
|
+
source = self._source(state)
|
|
103
|
+
values = await evaluate_rows(source, state, plan, options)
|
|
104
|
+
return self._frame(values, plan, source, output)
|
|
105
|
+
|
|
106
|
+
async def evaluate(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
state: State,
|
|
110
|
+
questions: Mapping[str, Question],
|
|
111
|
+
output: Output = "columns",
|
|
112
|
+
client: AsyncTypeSafeClient | None = None,
|
|
113
|
+
model: str | None = None,
|
|
114
|
+
max_concurrency: int = 16,
|
|
115
|
+
cache: MemoryCache | None = None,
|
|
116
|
+
progress: bool | ProgressCallback = False,
|
|
117
|
+
errors: Errors = "raise",
|
|
118
|
+
nulls: Nulls = "include",
|
|
119
|
+
) -> FrameT:
|
|
120
|
+
"""Evaluate questions per row, with prefixed fields in columns or a result struct."""
|
|
121
|
+
_validate_output(output)
|
|
122
|
+
options = Options(client, model, max_concurrency, cache, progress, errors, nulls)
|
|
123
|
+
plan = make_plan(questions, prefix=True)
|
|
124
|
+
source = self._source(state)
|
|
125
|
+
values = await evaluate_rows(source, state, plan, options)
|
|
126
|
+
return self._frame(values, plan, source, output)
|
jevframe/_cache.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Explicit, bounded process-local caching; no credentials or row text as keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
from threading import RLock
|
|
7
|
+
from typing import Any
|
|
8
|
+
from weakref import WeakKeyDictionary
|
|
9
|
+
|
|
10
|
+
_client_tokens: WeakKeyDictionary[Any, object] = WeakKeyDictionary()
|
|
11
|
+
_client_lock = RLock()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def client_token(client: Any) -> object:
|
|
15
|
+
# Keep an opaque token in keys: a collected client's id must never be reused.
|
|
16
|
+
with _client_lock:
|
|
17
|
+
if client not in _client_tokens:
|
|
18
|
+
_client_tokens[client] = object()
|
|
19
|
+
return _client_tokens[client]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MemoryCache:
|
|
23
|
+
"""LRU cache of successful complete row evaluations.
|
|
24
|
+
|
|
25
|
+
Pass the same instance to subsequent calls to reuse results. Model aliases
|
|
26
|
+
can move; call ``clear()`` when fresh inference is required. Cache contents
|
|
27
|
+
are process-local and never written to disk.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, max_entries: int = 10_000):
|
|
31
|
+
if isinstance(max_entries, bool) or not isinstance(max_entries, int) or max_entries < 1:
|
|
32
|
+
raise ValueError("max_entries must be a positive integer")
|
|
33
|
+
self._max_entries = max_entries
|
|
34
|
+
self._entries: OrderedDict[tuple[Any, ...], tuple[Any, ...]] = OrderedDict()
|
|
35
|
+
self._lock = RLock()
|
|
36
|
+
self._generation = 0
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def max_entries(self) -> int:
|
|
40
|
+
return self._max_entries
|
|
41
|
+
|
|
42
|
+
def __len__(self) -> int:
|
|
43
|
+
with self._lock:
|
|
44
|
+
return len(self._entries)
|
|
45
|
+
|
|
46
|
+
def clear(self) -> None:
|
|
47
|
+
"""Discard entries, including results of requests already in progress."""
|
|
48
|
+
with self._lock:
|
|
49
|
+
self._entries.clear()
|
|
50
|
+
self._generation += 1
|
|
51
|
+
|
|
52
|
+
def _get(self, key: tuple[Any, ...]) -> tuple[Any, ...] | None:
|
|
53
|
+
with self._lock:
|
|
54
|
+
result = self._entries.get(key)
|
|
55
|
+
if result is not None:
|
|
56
|
+
self._entries.move_to_end(key)
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
def _put(self, key: tuple[Any, ...], value: tuple[Any, ...], generation: int) -> None:
|
|
60
|
+
with self._lock:
|
|
61
|
+
if generation != self._generation:
|
|
62
|
+
return
|
|
63
|
+
self._entries[key] = value
|
|
64
|
+
self._entries.move_to_end(key)
|
|
65
|
+
while len(self._entries) > self.max_entries:
|
|
66
|
+
self._entries.popitem(last=False)
|
jevframe/_context.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Context construction without importing either dataframe library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import inspect
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ._types import State
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def identity(value: Any) -> Any:
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class RowSource:
|
|
22
|
+
rows: Iterator[dict[str, Any]]
|
|
23
|
+
count: int
|
|
24
|
+
convert_scalar: Callable[[Any], Any] = identity
|
|
25
|
+
index: Any = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def selected_columns(columns: Sequence[Any], state: State) -> list[str]:
|
|
29
|
+
"""Validate selection before inference; callable context sees every column."""
|
|
30
|
+
if callable(state):
|
|
31
|
+
if inspect.iscoroutinefunction(state):
|
|
32
|
+
raise TypeError("state must be a synchronous callable")
|
|
33
|
+
selected = list(columns)
|
|
34
|
+
elif isinstance(state, str):
|
|
35
|
+
selected = [state]
|
|
36
|
+
elif isinstance(state, Sequence) and not isinstance(state, bytes):
|
|
37
|
+
selected = list(state)
|
|
38
|
+
else:
|
|
39
|
+
raise TypeError("state must be a column name, sequence of names, or synchronous callable")
|
|
40
|
+
if not selected and not callable(state):
|
|
41
|
+
raise ValueError("state must select at least one column")
|
|
42
|
+
if any(not isinstance(name, str) for name in selected):
|
|
43
|
+
raise TypeError("Context column names must be strings")
|
|
44
|
+
if len(set(selected)) != len(selected):
|
|
45
|
+
raise ValueError("Context column names must be unique")
|
|
46
|
+
for name in selected:
|
|
47
|
+
occurrences = list(columns).count(name)
|
|
48
|
+
if occurrences == 0:
|
|
49
|
+
raise ValueError(f"Unknown context column: {name!r}")
|
|
50
|
+
if occurrences > 1:
|
|
51
|
+
raise ValueError(f"Ambiguous context column: {name!r}")
|
|
52
|
+
return selected
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def normalize(value: Any, convert_scalar: Callable[[Any], Any] = identity) -> Any:
|
|
56
|
+
"""Return strict JSON values, keeping map/list order and temporal precision."""
|
|
57
|
+
value = convert_scalar(value)
|
|
58
|
+
if value is None or isinstance(value, (str, bool, int)):
|
|
59
|
+
return value
|
|
60
|
+
if isinstance(value, float):
|
|
61
|
+
if math.isnan(value):
|
|
62
|
+
return None
|
|
63
|
+
if not math.isfinite(value):
|
|
64
|
+
raise ValueError("Infinite numbers are not valid context")
|
|
65
|
+
return value
|
|
66
|
+
if isinstance(value, (dt.datetime, dt.date, dt.time)):
|
|
67
|
+
return value.isoformat()
|
|
68
|
+
if isinstance(value, dt.timedelta):
|
|
69
|
+
# Timedeltas are exact at Python's microsecond resolution, including negatives.
|
|
70
|
+
microseconds = (value.days * 86400 + value.seconds) * 1_000_000 + value.microseconds
|
|
71
|
+
sign = "-" if microseconds < 0 else ""
|
|
72
|
+
seconds, micros = divmod(abs(microseconds), 1_000_000)
|
|
73
|
+
return f"{sign}PT{seconds}.{micros:06d}S"
|
|
74
|
+
if isinstance(value, Mapping):
|
|
75
|
+
if any(not isinstance(key, str) for key in value):
|
|
76
|
+
raise TypeError("Context object keys must be strings")
|
|
77
|
+
return {key: normalize(item, convert_scalar) for key, item in value.items()}
|
|
78
|
+
if isinstance(value, (list, tuple)):
|
|
79
|
+
return [normalize(item, convert_scalar) for item in value]
|
|
80
|
+
raise TypeError(f"Unsupported context type: {type(value).__name__}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def construct_context(row: Mapping[str, Any], state: State, source: RowSource) -> Any:
|
|
84
|
+
# Deep copying is necessary: pandas object columns can contain lists/dicts.
|
|
85
|
+
value = state(deepcopy(dict(row))) if callable(state) else row
|
|
86
|
+
if inspect.isawaitable(value):
|
|
87
|
+
if inspect.iscoroutine(value):
|
|
88
|
+
value.close()
|
|
89
|
+
raise TypeError("state must return context synchronously")
|
|
90
|
+
result = normalize(value, source.convert_scalar)
|
|
91
|
+
if not isinstance(result, (str, dict, list)):
|
|
92
|
+
raise TypeError("Constructed state must be text, an object, or an array")
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def contains_null(value: Any) -> bool:
|
|
97
|
+
if value is None:
|
|
98
|
+
return True
|
|
99
|
+
if isinstance(value, dict):
|
|
100
|
+
return any(contains_null(item) for item in value.values())
|
|
101
|
+
if isinstance(value, list):
|
|
102
|
+
return any(contains_null(item) for item in value)
|
|
103
|
+
return False
|
jevframe/_engine.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Bounded async row evaluation, independent of pandas and Polars."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import inspect
|
|
8
|
+
import os
|
|
9
|
+
import warnings
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from typesafe_sdk import AsyncTypeSafeClient, constants
|
|
14
|
+
|
|
15
|
+
from ._cache import MemoryCache, client_token
|
|
16
|
+
from ._context import RowSource, construct_context, contains_null
|
|
17
|
+
from ._schema import QuestionPlan, encode, flatten
|
|
18
|
+
from ._types import (
|
|
19
|
+
Errors,
|
|
20
|
+
EvaluationError,
|
|
21
|
+
EvaluationWarning,
|
|
22
|
+
Nulls,
|
|
23
|
+
Progress,
|
|
24
|
+
ProgressCallback,
|
|
25
|
+
RowFailure,
|
|
26
|
+
State,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Options:
|
|
32
|
+
client: AsyncTypeSafeClient | None = None
|
|
33
|
+
model: str | None = None
|
|
34
|
+
max_concurrency: int = 16
|
|
35
|
+
cache: MemoryCache | None = None
|
|
36
|
+
progress: bool | ProgressCallback = False
|
|
37
|
+
errors: Errors = "raise"
|
|
38
|
+
nulls: Nulls = "include"
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
if self.client is not None and not isinstance(self.client, AsyncTypeSafeClient):
|
|
42
|
+
raise TypeError("client must be an official AsyncTypeSafeClient")
|
|
43
|
+
if self.model is not None and (not isinstance(self.model, str) or not self.model.strip()):
|
|
44
|
+
raise ValueError("model must be a nonempty string")
|
|
45
|
+
if (
|
|
46
|
+
isinstance(self.max_concurrency, bool)
|
|
47
|
+
or not isinstance(self.max_concurrency, int)
|
|
48
|
+
or self.max_concurrency < 1
|
|
49
|
+
):
|
|
50
|
+
raise ValueError("max_concurrency must be a positive integer")
|
|
51
|
+
if self.cache is not None and not isinstance(self.cache, MemoryCache):
|
|
52
|
+
raise TypeError("cache must be a MemoryCache instance or None")
|
|
53
|
+
if not isinstance(self.progress, bool) and not callable(self.progress):
|
|
54
|
+
raise TypeError("progress must be a bool or synchronous callback")
|
|
55
|
+
if inspect.iscoroutinefunction(self.progress):
|
|
56
|
+
raise TypeError("progress callback must be synchronous")
|
|
57
|
+
if self.errors not in ("raise", "coerce"):
|
|
58
|
+
raise ValueError("errors must be 'raise' or 'coerce'")
|
|
59
|
+
if self.nulls not in ("include", "skip", "raise"):
|
|
60
|
+
raise ValueError("nulls must be 'include', 'skip', or 'raise'")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _create_client(**kwargs: Any) -> AsyncTypeSafeClient:
|
|
64
|
+
return AsyncTypeSafeClient(**kwargs)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def evaluate_rows(
|
|
68
|
+
source: RowSource, state: State, plan: QuestionPlan, options: Options
|
|
69
|
+
) -> list[tuple[Any, ...]]:
|
|
70
|
+
"""One request per row, with a fixed number of workers and positional output."""
|
|
71
|
+
missing = (None,) * len(plan.columns)
|
|
72
|
+
results = [missing] * source.count
|
|
73
|
+
rows = enumerate(source.rows)
|
|
74
|
+
cache = options.cache
|
|
75
|
+
failures: list[RowFailure] = []
|
|
76
|
+
client = options.client
|
|
77
|
+
owned = client is None
|
|
78
|
+
|
|
79
|
+
# Resolve owned configuration once, using only documented SDK constants.
|
|
80
|
+
# A supplied client captures its own defaults: isolate its cache by identity.
|
|
81
|
+
model = options.model
|
|
82
|
+
client_kwargs: dict[str, Any] = {}
|
|
83
|
+
if owned:
|
|
84
|
+
model = model or os.environ.get(constants.DEFAULT_MODEL_ENV, "").strip()
|
|
85
|
+
model = model or constants.DEFAULT_MODEL
|
|
86
|
+
base_url = os.environ.get(constants.BASE_URL_ENV, "").strip() or constants.DEFAULT_BASE_URL
|
|
87
|
+
api_key = os.environ.get(constants.API_KEY_ENV, "").strip() or None
|
|
88
|
+
client_kwargs = {"api_key": api_key, "model": model, "base_url": base_url}
|
|
89
|
+
credential_fingerprint = hashlib.sha256((api_key or "").encode()).digest()
|
|
90
|
+
scope: tuple[Any, ...] = ("owned", base_url.rstrip("/"), model, credential_fingerprint)
|
|
91
|
+
else:
|
|
92
|
+
scope = ("supplied", client_token(client), model) if cache is not None else ()
|
|
93
|
+
|
|
94
|
+
# Futures are only created for active distinct requests, not for every row.
|
|
95
|
+
# Store outcomes as values so failed owners cannot leave unobserved exceptions.
|
|
96
|
+
pending: dict[
|
|
97
|
+
tuple[Any, ...], asyncio.Future[tuple[tuple[Any, ...] | None, Exception | None]]
|
|
98
|
+
] = {}
|
|
99
|
+
completed = failed = skipped = cache_hits = 0
|
|
100
|
+
bar: Any = None
|
|
101
|
+
|
|
102
|
+
def report(*, failure: bool = False, skip: bool = False, hit: bool = False) -> None:
|
|
103
|
+
nonlocal completed, failed, skipped, cache_hits
|
|
104
|
+
completed += 1
|
|
105
|
+
failed += int(failure)
|
|
106
|
+
skipped += int(skip)
|
|
107
|
+
cache_hits += int(hit)
|
|
108
|
+
if bar is not None:
|
|
109
|
+
bar.update(1)
|
|
110
|
+
if callable(options.progress):
|
|
111
|
+
returned = options.progress(
|
|
112
|
+
Progress(completed, source.count, failed, skipped, cache_hits)
|
|
113
|
+
)
|
|
114
|
+
if inspect.isawaitable(returned):
|
|
115
|
+
if inspect.iscoroutine(returned):
|
|
116
|
+
returned.close()
|
|
117
|
+
raise TypeError("progress callback must return synchronously")
|
|
118
|
+
|
|
119
|
+
async def infer(context: Any) -> tuple[tuple[Any, ...], bool]:
|
|
120
|
+
key = None
|
|
121
|
+
generation = 0
|
|
122
|
+
if cache is not None:
|
|
123
|
+
generation = cache._generation
|
|
124
|
+
digest = hashlib.sha256(encode([context, plan.serialized]).encode()).digest()
|
|
125
|
+
key = (generation, scope, digest)
|
|
126
|
+
cached = cache._get(key)
|
|
127
|
+
if cached is not None:
|
|
128
|
+
return cached, True
|
|
129
|
+
if key in pending:
|
|
130
|
+
value, error = await asyncio.shield(pending[key])
|
|
131
|
+
if error is not None:
|
|
132
|
+
raise error
|
|
133
|
+
assert value is not None
|
|
134
|
+
return value, True
|
|
135
|
+
pending[key] = asyncio.get_running_loop().create_future()
|
|
136
|
+
try:
|
|
137
|
+
assert client is not None
|
|
138
|
+
response = await client.system_one(state=context, questions=plan.questions, model=model)
|
|
139
|
+
value = flatten(response, plan)
|
|
140
|
+
if cache is not None and key is not None:
|
|
141
|
+
cache._put(key, value, generation)
|
|
142
|
+
pending[key].set_result((value, None))
|
|
143
|
+
return value, False
|
|
144
|
+
except Exception as error:
|
|
145
|
+
if key is not None:
|
|
146
|
+
pending[key].set_result((None, error))
|
|
147
|
+
raise
|
|
148
|
+
finally:
|
|
149
|
+
if key is not None:
|
|
150
|
+
future = pending.pop(key)
|
|
151
|
+
if not future.done():
|
|
152
|
+
future.cancel()
|
|
153
|
+
|
|
154
|
+
async def worker() -> None:
|
|
155
|
+
nonlocal client
|
|
156
|
+
for position, row in rows:
|
|
157
|
+
try:
|
|
158
|
+
context = construct_context(row, state, source)
|
|
159
|
+
has_null = contains_null(context)
|
|
160
|
+
if has_null and options.nulls == "raise":
|
|
161
|
+
raise ValueError("Row context contains null values")
|
|
162
|
+
except Exception as error:
|
|
163
|
+
raise EvaluationError(position, error, phase="context construction") from error
|
|
164
|
+
if has_null and options.nulls == "skip":
|
|
165
|
+
report(skip=True)
|
|
166
|
+
else:
|
|
167
|
+
# Client configuration errors are never converted to missing probabilities.
|
|
168
|
+
# No await occurs here, so exactly one owned client is created.
|
|
169
|
+
if client is None:
|
|
170
|
+
client = _create_client(**client_kwargs)
|
|
171
|
+
try:
|
|
172
|
+
results[position], hit = await infer(context)
|
|
173
|
+
except Exception as error:
|
|
174
|
+
if options.errors == "raise":
|
|
175
|
+
raise EvaluationError(position, error) from error
|
|
176
|
+
failures.append(RowFailure(position, error))
|
|
177
|
+
report(failure=True)
|
|
178
|
+
else:
|
|
179
|
+
report(hit=hit)
|
|
180
|
+
# Cached and skipped rows otherwise never yield, starving cancellation/UI.
|
|
181
|
+
await asyncio.sleep(0)
|
|
182
|
+
|
|
183
|
+
tasks: list[asyncio.Task[None]] = []
|
|
184
|
+
try:
|
|
185
|
+
if options.progress is True:
|
|
186
|
+
from tqdm.auto import tqdm
|
|
187
|
+
|
|
188
|
+
bar = tqdm(total=source.count, desc="Evaluating", unit="row")
|
|
189
|
+
tasks = [
|
|
190
|
+
asyncio.create_task(worker()) for _ in range(min(source.count, options.max_concurrency))
|
|
191
|
+
]
|
|
192
|
+
await asyncio.gather(*tasks)
|
|
193
|
+
finally:
|
|
194
|
+
for task in tasks:
|
|
195
|
+
if not task.done():
|
|
196
|
+
task.cancel()
|
|
197
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
198
|
+
try:
|
|
199
|
+
if owned and client is not None:
|
|
200
|
+
await client.aclose()
|
|
201
|
+
finally:
|
|
202
|
+
if bar is not None:
|
|
203
|
+
bar.close()
|
|
204
|
+
if failures:
|
|
205
|
+
warnings.warn(EvaluationWarning(failures), stacklevel=3)
|
|
206
|
+
return results
|
jevframe/_schema.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Frozen request definitions and deterministic native result columns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from typesafe_sdk import (
|
|
12
|
+
Choice,
|
|
13
|
+
ChoiceAnswer,
|
|
14
|
+
Noul,
|
|
15
|
+
NoulAnswer,
|
|
16
|
+
Score,
|
|
17
|
+
ScoreAnswer,
|
|
18
|
+
SystemOneResponse,
|
|
19
|
+
TypeSafeError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from ._types import Question
|
|
23
|
+
|
|
24
|
+
# Allow floating-point/API rounding, without repairing or renormalizing values.
|
|
25
|
+
PROBABILITY_TOLERANCE = 1e-6
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def encode(value: Any) -> str:
|
|
29
|
+
return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Column:
|
|
34
|
+
name: str
|
|
35
|
+
kind: Literal["float", "int", "str"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class QuestionPlan:
|
|
40
|
+
questions: dict[str, Question]
|
|
41
|
+
columns: tuple[Column, ...]
|
|
42
|
+
serialized: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pack_rows(values: list[tuple[Any, ...]], plan: QuestionPlan) -> list[dict[str, Any] | None]:
|
|
46
|
+
"""Pack validated scalar fields, keeping failed/skipped rows wholly missing.
|
|
47
|
+
|
|
48
|
+
Build fresh dictionaries so callers cannot mutate other rows or cached values.
|
|
49
|
+
Field names and order exactly match the expanded column schema.
|
|
50
|
+
"""
|
|
51
|
+
names = [column.name for column in plan.columns]
|
|
52
|
+
return [
|
|
53
|
+
None if all(value is None for value in row) else dict(zip(names, row, strict=True))
|
|
54
|
+
for row in values
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def make_plan(questions: Mapping[str, Question], *, prefix: bool) -> QuestionPlan:
|
|
59
|
+
if not isinstance(questions, Mapping) or not questions:
|
|
60
|
+
raise ValueError("questions must be a nonempty mapping of names to SDK question objects")
|
|
61
|
+
snapshots: dict[str, Question] = {}
|
|
62
|
+
columns: list[Column] = []
|
|
63
|
+
for name, question in questions.items():
|
|
64
|
+
if not isinstance(name, str) or not name:
|
|
65
|
+
raise ValueError("Question names must be nonempty strings")
|
|
66
|
+
if not isinstance(question, (Noul, Choice, Score)):
|
|
67
|
+
raise TypeError("questions values must be official SDK Noul, Choice, or Score objects")
|
|
68
|
+
cls = (
|
|
69
|
+
Noul
|
|
70
|
+
if isinstance(question, Noul)
|
|
71
|
+
else Choice
|
|
72
|
+
if isinstance(question, Choice)
|
|
73
|
+
else Score
|
|
74
|
+
)
|
|
75
|
+
# These SDK models are mutable; validation of a dumped copy prevents drift.
|
|
76
|
+
snapshot = cls.model_validate(question.model_dump(mode="json"))
|
|
77
|
+
snapshots[name] = snapshot
|
|
78
|
+
fields: list[tuple[str, Literal["float", "int", "str"]]]
|
|
79
|
+
if isinstance(snapshot, Noul):
|
|
80
|
+
fields = [("probability", "float")]
|
|
81
|
+
elif isinstance(snapshot, Choice):
|
|
82
|
+
if not 1 <= len(snapshot.criteria) <= 255:
|
|
83
|
+
raise ValueError("Choice requires 1 to 255 options")
|
|
84
|
+
fields = [("label", "str"), ("confidence", "float")]
|
|
85
|
+
fields += [(f"p__{label}", "float") for label in snapshot.criteria]
|
|
86
|
+
else:
|
|
87
|
+
if not 2 <= len(snapshot.criteria) <= 10:
|
|
88
|
+
raise ValueError("Score requires 2 to 10 ordered levels")
|
|
89
|
+
fields = [
|
|
90
|
+
("level", "int"),
|
|
91
|
+
("label", "str"),
|
|
92
|
+
("score", "float"),
|
|
93
|
+
("confidence", "float"),
|
|
94
|
+
]
|
|
95
|
+
fields += [(f"p__{i}", "float") for i in range(len(snapshot.criteria))]
|
|
96
|
+
columns.extend(
|
|
97
|
+
Column(f"{name}__{field}" if prefix else field, kind) for field, kind in fields
|
|
98
|
+
)
|
|
99
|
+
names = [column.name for column in columns]
|
|
100
|
+
if len(set(names)) != len(names):
|
|
101
|
+
raise ValueError("Generated result column names collide; rename questions or choice labels")
|
|
102
|
+
serialized = encode({name: q.model_dump(mode="json") for name, q in snapshots.items()})
|
|
103
|
+
return QuestionPlan(snapshots, tuple(columns), serialized)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def choice_question(instructions: str, choices: Sequence[str] | Mapping[str, Any]) -> Choice:
|
|
107
|
+
if isinstance(choices, Mapping):
|
|
108
|
+
criteria = dict(choices)
|
|
109
|
+
elif isinstance(choices, Sequence) and not isinstance(choices, (str, bytes)):
|
|
110
|
+
if any(not isinstance(label, str) for label in choices):
|
|
111
|
+
raise TypeError("Choice labels must be strings")
|
|
112
|
+
if len(set(choices)) != len(choices):
|
|
113
|
+
raise ValueError("Choice labels must be unique")
|
|
114
|
+
criteria = dict.fromkeys(choices)
|
|
115
|
+
else:
|
|
116
|
+
raise TypeError("choices must be a sequence of labels or a mapping of descriptions")
|
|
117
|
+
return Choice(instructions=instructions, criteria=criteria)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def probability(value: Any) -> float:
|
|
121
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
122
|
+
raise ValueError("Expected a numeric probability")
|
|
123
|
+
number = float(value)
|
|
124
|
+
if not math.isfinite(number) or not 0 <= number <= 1:
|
|
125
|
+
raise ValueError("Probability must be finite and between zero and one")
|
|
126
|
+
return number
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def distribution(values: Mapping[Any, float], keys: Sequence[Any]) -> list[float]:
|
|
130
|
+
if set(values) != set(keys):
|
|
131
|
+
raise ValueError("Probability distribution keys differ from the requested criteria")
|
|
132
|
+
result = [probability(values[key]) for key in keys]
|
|
133
|
+
if not math.isclose(math.fsum(result), 1, rel_tol=0, abs_tol=PROBABILITY_TOLERANCE):
|
|
134
|
+
raise ValueError("Probability distribution must sum to one")
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def flatten(response: SystemOneResponse, plan: QuestionPlan) -> tuple[Any, ...]:
|
|
139
|
+
if not isinstance(response, SystemOneResponse):
|
|
140
|
+
raise TypeError("Expected an official SDK SystemOneResponse")
|
|
141
|
+
# The SDK omits unknown answer types. Check raw names too, so an unexpected
|
|
142
|
+
# future answer cannot disappear before our exact question-set validation.
|
|
143
|
+
try:
|
|
144
|
+
raw_names = response.raw_http_response.json().get("answers", {})
|
|
145
|
+
except TypeSafeError:
|
|
146
|
+
raw_names = response.answers # SDK models constructed directly, e.g. in tests.
|
|
147
|
+
if not isinstance(raw_names, dict) or set(raw_names) != set(plan.questions):
|
|
148
|
+
raise ValueError("Response question names differ from the request")
|
|
149
|
+
if set(response.answers) != set(plan.questions):
|
|
150
|
+
raise ValueError("Response question names differ from the request")
|
|
151
|
+
result: list[Any] = []
|
|
152
|
+
for name, question in plan.questions.items():
|
|
153
|
+
answer = response.answers[name]
|
|
154
|
+
if isinstance(question, Noul):
|
|
155
|
+
if not isinstance(answer, NoulAnswer):
|
|
156
|
+
raise ValueError("Expected a Noul answer")
|
|
157
|
+
result.append(probability(answer.noul))
|
|
158
|
+
elif isinstance(question, Choice):
|
|
159
|
+
if not isinstance(answer, ChoiceAnswer):
|
|
160
|
+
raise ValueError("Expected a Choice answer")
|
|
161
|
+
probabilities = distribution(answer.probabilities, list(question.criteria))
|
|
162
|
+
if answer.choice not in question.criteria:
|
|
163
|
+
raise ValueError("Selected choice is outside the requested criteria")
|
|
164
|
+
result.extend([answer.choice, probability(answer.confidence), *probabilities])
|
|
165
|
+
else:
|
|
166
|
+
if not isinstance(answer, ScoreAnswer):
|
|
167
|
+
raise ValueError("Expected a Score answer")
|
|
168
|
+
levels = list(question.criteria)
|
|
169
|
+
probabilities = distribution(answer.probabilities, list(range(len(levels))))
|
|
170
|
+
if answer.legend != dict(enumerate(levels)):
|
|
171
|
+
raise ValueError("Score legend differs from the requested levels")
|
|
172
|
+
score = answer.score
|
|
173
|
+
expected = math.fsum(i * p for i, p in enumerate(probabilities))
|
|
174
|
+
if (
|
|
175
|
+
isinstance(score, bool)
|
|
176
|
+
or not isinstance(score, (int, float))
|
|
177
|
+
or not math.isfinite(score)
|
|
178
|
+
or not 0 <= score <= len(levels) - 1
|
|
179
|
+
or not math.isclose(
|
|
180
|
+
score, expected, rel_tol=0, abs_tol=PROBABILITY_TOLERANCE * len(levels)
|
|
181
|
+
)
|
|
182
|
+
):
|
|
183
|
+
raise ValueError("Score is not the expected zero-based level")
|
|
184
|
+
level = max(range(len(levels)), key=probabilities.__getitem__)
|
|
185
|
+
label = levels[level] if isinstance(levels[level], str) else encode(levels[level])
|
|
186
|
+
result.extend(
|
|
187
|
+
[level, label, float(score), probability(answer.confidence), *probabilities]
|
|
188
|
+
)
|
|
189
|
+
return tuple(result)
|
jevframe/_types.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Public controls and diagnostics shared by both dataframe integrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Literal, TypeAlias
|
|
8
|
+
|
|
9
|
+
from typesafe_sdk import Choice, JSONContent, Noul, Score
|
|
10
|
+
|
|
11
|
+
Question: TypeAlias = Noul | Choice | Score
|
|
12
|
+
State: TypeAlias = str | Sequence[str] | Callable[[Mapping[str, Any]], JSONContent]
|
|
13
|
+
Errors: TypeAlias = Literal["raise", "coerce"]
|
|
14
|
+
Nulls: TypeAlias = Literal["include", "skip", "raise"]
|
|
15
|
+
Output: TypeAlias = Literal["columns", "struct"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Progress:
|
|
20
|
+
"""A completion snapshot. Counts refer to rows, never request attempts.
|
|
21
|
+
|
|
22
|
+
``cache_hits`` includes rows sharing another row's in-flight request.
|
|
23
|
+
Successful rows equal ``completed - failed - skipped``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
completed: int
|
|
27
|
+
total: int
|
|
28
|
+
failed: int = 0
|
|
29
|
+
skipped: int = 0
|
|
30
|
+
cache_hits: int = 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
ProgressCallback: TypeAlias = Callable[[Progress], None]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class RowFailure:
|
|
38
|
+
"""A failed inference at a zero-based position in the evaluated dataframe."""
|
|
39
|
+
|
|
40
|
+
position: int
|
|
41
|
+
cause: Exception
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class EvaluationError(RuntimeError):
|
|
45
|
+
"""A row failed inference, validation, or context construction.
|
|
46
|
+
|
|
47
|
+
``position`` is authoritative even with duplicate indexes. The underlying
|
|
48
|
+
exception is available as ``cause`` and through exception chaining.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, position: int, cause: Exception, *, phase: str = "inference"):
|
|
52
|
+
self.position = position
|
|
53
|
+
self.cause = cause
|
|
54
|
+
self.phase = phase
|
|
55
|
+
# Avoid copying SDK response bodies or user context into our diagnostics.
|
|
56
|
+
super().__init__(f"Row {position}: {phase} failed ({type(cause).__name__})")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class EvaluationWarning(UserWarning):
|
|
60
|
+
"""Coerced row failures. Inspect ``failures`` for positions and causes."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, failures: Sequence[RowFailure]):
|
|
63
|
+
self.failures = tuple(sorted(failures, key=lambda failure: failure.position))
|
|
64
|
+
positions = ", ".join(str(f.position) for f in self.failures[:10])
|
|
65
|
+
if len(self.failures) > 10:
|
|
66
|
+
positions += ", …"
|
|
67
|
+
super().__init__(
|
|
68
|
+
f"Inference failed for {len(self.failures)} row(s) at positions {positions}; "
|
|
69
|
+
"all results for these rows are missing. Inspect .failures for causes."
|
|
70
|
+
)
|
jevframe/pandas.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Import this module to register ``pandas.DataFrame.jev``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
except ImportError as error:
|
|
11
|
+
raise ImportError("Install jevframe[pandas] to use the pandas integration") from error
|
|
12
|
+
|
|
13
|
+
from ._accessor import BaseAccessor
|
|
14
|
+
from ._context import RowSource, selected_columns
|
|
15
|
+
from ._schema import QuestionPlan, pack_rows
|
|
16
|
+
from ._types import Output, State
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _scalar(value: Any) -> Any:
|
|
20
|
+
if value is pd.NA or value is pd.NaT:
|
|
21
|
+
return None
|
|
22
|
+
if isinstance(value, (np.datetime64, np.timedelta64)):
|
|
23
|
+
if np.isnat(value):
|
|
24
|
+
return None
|
|
25
|
+
if isinstance(value, np.datetime64):
|
|
26
|
+
return str(value)
|
|
27
|
+
return pd.Timedelta(value).isoformat()
|
|
28
|
+
if isinstance(value, pd.Timedelta):
|
|
29
|
+
return value.isoformat()
|
|
30
|
+
if isinstance(value, np.generic):
|
|
31
|
+
return value.item()
|
|
32
|
+
if isinstance(value, np.ndarray):
|
|
33
|
+
return list(value) if value.ndim else _scalar(value[()])
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pd.api.extensions.register_dataframe_accessor("jev")
|
|
38
|
+
class JevAccessor(BaseAccessor[pd.DataFrame, pd.Series]):
|
|
39
|
+
"""Async semantic operations preserving pandas row positions and indexes."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, frame: pd.DataFrame):
|
|
42
|
+
self._data = frame
|
|
43
|
+
|
|
44
|
+
def _source(self, state: State) -> RowSource:
|
|
45
|
+
columns = selected_columns(list(self._data.columns), state)
|
|
46
|
+
data = self._data.loc[:, columns]
|
|
47
|
+
rows = (
|
|
48
|
+
dict(zip(columns, row, strict=True)) for row in data.itertuples(index=False, name=None)
|
|
49
|
+
)
|
|
50
|
+
if not columns:
|
|
51
|
+
# itertuples(index=False) yields no rows when there are no columns.
|
|
52
|
+
rows = ({} for _ in range(len(data)))
|
|
53
|
+
return RowSource(rows, len(data), _scalar, self._data.index.copy(deep=True))
|
|
54
|
+
|
|
55
|
+
def _frame(
|
|
56
|
+
self, values: list[tuple[Any, ...]], plan: QuestionPlan, source: RowSource, output: Output
|
|
57
|
+
) -> pd.DataFrame:
|
|
58
|
+
types = {"float": "float64", "int": "Int64", "str": "string"}
|
|
59
|
+
if output == "struct":
|
|
60
|
+
return pd.DataFrame(
|
|
61
|
+
{"result": pd.array(pack_rows(values, plan), dtype="object")}, index=source.index
|
|
62
|
+
)
|
|
63
|
+
# Arrays, not index-keyed dicts/Series: duplicate index labels must not align.
|
|
64
|
+
arrays = {
|
|
65
|
+
column.name: pd.array([row[i] for row in values], dtype=types[column.kind])
|
|
66
|
+
for i, column in enumerate(plan.columns)
|
|
67
|
+
}
|
|
68
|
+
return pd.DataFrame(arrays, index=source.index)
|
|
69
|
+
|
|
70
|
+
def _series(self, values: list[tuple[Any, ...]], source: RowSource) -> pd.Series:
|
|
71
|
+
return pd.Series(
|
|
72
|
+
[row[0] for row in values], index=source.index, dtype="float64", name="probability"
|
|
73
|
+
)
|
jevframe/polars.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Import this module to register eager ``polars.DataFrame.jev``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import polars as pl
|
|
9
|
+
except ImportError as error:
|
|
10
|
+
raise ImportError("Install jevframe[polars] to use the Polars integration") from error
|
|
11
|
+
|
|
12
|
+
from ._accessor import BaseAccessor
|
|
13
|
+
from ._context import RowSource, selected_columns
|
|
14
|
+
from ._schema import QuestionPlan, pack_rows
|
|
15
|
+
from ._types import Output, State
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _duration(value: int, unit: str) -> str:
|
|
19
|
+
nanos = value * {"ns": 1, "us": 1_000, "ms": 1_000_000}[unit]
|
|
20
|
+
sign = "-" if nanos < 0 else ""
|
|
21
|
+
seconds, fractional = divmod(abs(nanos), 1_000_000_000)
|
|
22
|
+
return f"{sign}PT{seconds}.{fractional:09d}S"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _lossless_temporal(expr: pl.Expr, dtype: pl.DataType) -> pl.Expr:
|
|
26
|
+
# iter_rows converts temporal values to Python, which truncates nanoseconds.
|
|
27
|
+
# Convert before extraction, including temporal values nested in containers.
|
|
28
|
+
if isinstance(dtype, pl.Datetime):
|
|
29
|
+
fmt = "%Y-%m-%dT%H:%M:%S%.f" + ("%:z" if dtype.time_zone else "")
|
|
30
|
+
return expr.dt.to_string(fmt)
|
|
31
|
+
if dtype == pl.Time:
|
|
32
|
+
return expr.dt.to_string("%H:%M:%S%.f")
|
|
33
|
+
if isinstance(dtype, pl.Duration):
|
|
34
|
+
return expr.cast(pl.Int64).map_elements(
|
|
35
|
+
lambda value: _duration(value, dtype.time_unit), return_dtype=pl.String
|
|
36
|
+
)
|
|
37
|
+
if isinstance(dtype, pl.List):
|
|
38
|
+
return expr.list.eval(_lossless_temporal(pl.element(), dtype.inner))
|
|
39
|
+
if isinstance(dtype, pl.Array):
|
|
40
|
+
return expr.arr.to_list().list.eval(_lossless_temporal(pl.element(), dtype.inner))
|
|
41
|
+
if isinstance(dtype, pl.Struct):
|
|
42
|
+
if not dtype.fields:
|
|
43
|
+
return expr
|
|
44
|
+
fields = [
|
|
45
|
+
_lossless_temporal(expr.struct.field(field.name), field.dtype).alias(field.name)
|
|
46
|
+
for field in dtype.fields
|
|
47
|
+
]
|
|
48
|
+
return pl.when(expr.is_null()).then(None).otherwise(pl.struct(fields))
|
|
49
|
+
return expr
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pl.api.register_dataframe_namespace("jev")
|
|
53
|
+
class JevNamespace(BaseAccessor[pl.DataFrame, pl.Series]):
|
|
54
|
+
"""Async semantic operations for eager Polars; outputs stay native."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, frame: pl.DataFrame):
|
|
57
|
+
self._data = frame
|
|
58
|
+
|
|
59
|
+
def _source(self, state: State) -> RowSource:
|
|
60
|
+
columns = selected_columns(self._data.columns, state)
|
|
61
|
+
data = self._data.select(
|
|
62
|
+
_lossless_temporal(pl.col(name), self._data.schema[name]).alias(name)
|
|
63
|
+
for name in columns
|
|
64
|
+
)
|
|
65
|
+
rows = (dict(zip(columns, row, strict=True)) for row in data.iter_rows())
|
|
66
|
+
return RowSource(rows, data.height)
|
|
67
|
+
|
|
68
|
+
def _frame(
|
|
69
|
+
self, values: list[tuple[Any, ...]], plan: QuestionPlan, source: RowSource, output: Output
|
|
70
|
+
) -> pl.DataFrame:
|
|
71
|
+
types = {"float": pl.Float64, "int": pl.Int64, "str": pl.String}
|
|
72
|
+
if output == "struct":
|
|
73
|
+
dtype = pl.Struct({column.name: types[column.kind] for column in plan.columns})
|
|
74
|
+
return pl.DataFrame(pl.Series("result", pack_rows(values, plan), dtype=dtype))
|
|
75
|
+
return pl.DataFrame(
|
|
76
|
+
{
|
|
77
|
+
column.name: pl.Series(
|
|
78
|
+
column.name, [row[i] for row in values], dtype=types[column.kind]
|
|
79
|
+
)
|
|
80
|
+
for i, column in enumerate(plan.columns)
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _series(self, values: list[tuple[Any, ...]], source: RowSource) -> pl.Series:
|
|
85
|
+
return pl.Series("probability", [row[0] for row in values], dtype=pl.Float64)
|
jevframe/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: jevframe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed semantic decisions for pandas and Polars with TypeSafe Jev
|
|
5
|
+
Project-URL: Repository, https://github.com/ktaletsk/jevframe
|
|
6
|
+
Project-URL: Issues, https://github.com/ktaletsk/jevframe/issues
|
|
7
|
+
Author: Konstantin Taletskiy
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: tqdm<5,>=4.66
|
|
18
|
+
Requires-Dist: typesafe-sdk<0.8,>=0.7
|
|
19
|
+
Provides-Extra: examples
|
|
20
|
+
Requires-Dist: altair<7,>=5; extra == 'examples'
|
|
21
|
+
Requires-Dist: marimo<1,>=0.19; extra == 'examples'
|
|
22
|
+
Requires-Dist: pandas<4,>=2.2; extra == 'examples'
|
|
23
|
+
Provides-Extra: pandas
|
|
24
|
+
Requires-Dist: pandas<4,>=2.2; extra == 'pandas'
|
|
25
|
+
Provides-Extra: polars
|
|
26
|
+
Requires-Dist: polars<2,>=1; extra == 'polars'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# jevframe
|
|
30
|
+
|
|
31
|
+
[](https://molab.marimo.io/github/ktaletsk/jevframe/blob/main/examples/reviews.py)
|
|
32
|
+
|
|
33
|
+
Ask the same semantic questions about every row of a dataframe using
|
|
34
|
+
[TypeSafe Jev](https://docs.typesafe.ai/). Get ordinary pandas or Polars results,
|
|
35
|
+
with bounded async inference and complete probability distributions.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
uv add 'jevframe[pandas]==0.1.0' # in your uv project
|
|
41
|
+
# Or: uv add 'jevframe[polars]==0.1.0' / 'jevframe[pandas,polars]==0.1.0'
|
|
42
|
+
export TYPESAFE_API_KEY='your-key'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Python 3.10+; the official `typesafe-sdk>=0.7,<0.8` handles inference. Dataframe
|
|
46
|
+
dependencies are optional. Importing an integration registers its `.jev` accessor.
|
|
47
|
+
|
|
48
|
+
On **molab**, open the notebook's **Secrets** sidebar panel and add
|
|
49
|
+
`TYPESAFE_API_KEY` with your key as its value. molab loads it automatically and
|
|
50
|
+
persists it between sessions; `.env` secrets are excluded from forks.
|
|
51
|
+
[Molab's secrets documentation](https://marimo.io/pages/molab/storage#secrets-stay-with-your-notebook).
|
|
52
|
+
Then click **Evaluate reviews** in the demo. You do not need to put the key in a code cell.
|
|
53
|
+
|
|
54
|
+
## pandas
|
|
55
|
+
|
|
56
|
+
These examples use notebook top-level `await`. In a script, put them inside an
|
|
57
|
+
`async def main()` and call `asyncio.run(main())`.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import pandas as pd
|
|
61
|
+
import jevframe.pandas
|
|
62
|
+
from typesafe_sdk import Choice, Noul
|
|
63
|
+
|
|
64
|
+
df = pd.DataFrame(
|
|
65
|
+
{
|
|
66
|
+
"title": ["Charged twice", "Great update"],
|
|
67
|
+
"review": ["Please refund the duplicate charge today.", "Everything works well!"],
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
df["dissatisfied"] = await df.jev.noul(
|
|
72
|
+
"Is this customer dissatisfied?",
|
|
73
|
+
state=["title", "review"],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
topics = await df.jev.choice(
|
|
77
|
+
"What is the main issue?",
|
|
78
|
+
choices=["billing", "bug", "other"],
|
|
79
|
+
state="review",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
grades = await df.jev.score(
|
|
83
|
+
"How positive is this review?",
|
|
84
|
+
levels=["Negative", "Neutral", "Positive"],
|
|
85
|
+
state="review",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
results = await df.jev.evaluate(
|
|
89
|
+
state=["title", "review"],
|
|
90
|
+
questions={
|
|
91
|
+
"urgent": Noul(instructions="Does the customer need help today?"),
|
|
92
|
+
"topic": Choice(
|
|
93
|
+
instructions="What is the main issue?",
|
|
94
|
+
criteria={"billing": "Charges and refunds", "bug": "Broken behavior", "other": None},
|
|
95
|
+
),
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
| Method | Result columns |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| `noul` | Series named `probability`, the probability of yes |
|
|
103
|
+
| `choice` | `label`, `confidence`, `p__billing`, `p__bug`, … |
|
|
104
|
+
| `score` | `level`, `label`, `score`, `confidence`, `p__0`, `p__1`, … |
|
|
105
|
+
| `evaluate` | Fields prefixed by question name, e.g. `urgent__probability`, `topic__p__billing` |
|
|
106
|
+
|
|
107
|
+
`score` is the expected **zero-based** level, not a probability: with five levels it
|
|
108
|
+
ranges from 0 to 4. `level`/`label` identify the highest-probability level, with ties
|
|
109
|
+
resolved by level order. Structured SDK level descriptions appear as JSON text in
|
|
110
|
+
`label`. Confidence comes from the SDK; it is separate from the selected option's
|
|
111
|
+
probability. No result is thresholded or silently renormalized.
|
|
112
|
+
|
|
113
|
+
Question and criterion order determine column order. Row order and pandas indexes,
|
|
114
|
+
including duplicates and MultiIndexes, are preserved. The input is never mutated.
|
|
115
|
+
|
|
116
|
+
### Output layout
|
|
117
|
+
|
|
118
|
+
`choice`, `score`, and `evaluate` accept `output="columns"` (the default) or
|
|
119
|
+
`output="struct"`. Both return a DataFrame with one row per input row.
|
|
120
|
+
The struct layout has a single column named `result`: dictionaries in pandas
|
|
121
|
+
(`object` dtype), or a native `Struct` in Polars. Its fields have exactly the same
|
|
122
|
+
names, order, and values as the separate columns, including all probabilities.
|
|
123
|
+
`evaluate` keeps question prefixes inside the struct too.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
packed = await df.jev.evaluate(
|
|
127
|
+
state="review",
|
|
128
|
+
questions={
|
|
129
|
+
"dissatisfied": Noul(instructions="Is this customer dissatisfied?"),
|
|
130
|
+
"urgent": Noul(instructions="Does the customer need help today?"),
|
|
131
|
+
},
|
|
132
|
+
output="struct",
|
|
133
|
+
)
|
|
134
|
+
df["decisions"] = packed["result"]
|
|
135
|
+
# Each successful cell: {"dissatisfied__probability": 0.9, "urgent__probability": 0.8}
|
|
136
|
+
# Illustrative probabilities; actual values come from Jev.
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Failed or skipped rows have a missing `result` cell (`None` in pandas, null in
|
|
140
|
+
Polars). Empty results retain their output dtype, including the full Polars struct
|
|
141
|
+
schema. Layout affects only presentation: switching it makes no extra requests
|
|
142
|
+
when you reuse a cache. `noul` always returns its single probability Series.
|
|
143
|
+
|
|
144
|
+
## How requests and results map to rows
|
|
145
|
+
|
|
146
|
+
The engine sends **one input row per request**, with up to 16 row requests in flight
|
|
147
|
+
by default (`max_concurrency`). With `evaluate()`, every question about that row
|
|
148
|
+
shares the same context and request. Different rows are evaluated independently.
|
|
149
|
+
|
|
150
|
+
For example, 100 rows with three questions produce 100 initial requests and 100
|
|
151
|
+
result rows, with answers in separate columns or one structured column. Retries can add
|
|
152
|
+
requests; skipped or cached rows need none. This does not expand one input row
|
|
153
|
+
into multiple generated records. The demo evaluates dissatisfaction, urgency,
|
|
154
|
+
and topic together for each review.
|
|
155
|
+
|
|
156
|
+
## Row selection and context
|
|
157
|
+
|
|
158
|
+
Select rows with the dataframe library before evaluation:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
subset = await df.iloc[:10].jev.noul("Is this urgent?", state="review")
|
|
162
|
+
|
|
163
|
+
custom = await df.jev.noul(
|
|
164
|
+
"Is this urgent?",
|
|
165
|
+
state=lambda row: {"text": f"{row['title']}: {row['review']}"},
|
|
166
|
+
)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`state="review"` sends `{"review": value}`; a list sends those named columns.
|
|
170
|
+
Indexes are never sent implicitly. Selected column names must be unique strings;
|
|
171
|
+
unrelated duplicate columns are allowed. A synchronous context callable receives
|
|
172
|
+
a detached mapping of all columns and returns text, an object, or an array. It
|
|
173
|
+
runs once per row before cache lookup. Polars temporal values in these mappings
|
|
174
|
+
are ISO strings to retain nanosecond precision.
|
|
175
|
+
|
|
176
|
+
Missing scalars become JSON null; lists and objects are normalized recursively.
|
|
177
|
+
Dates/times become ISO text. Unsupported objects and infinities raise a row-aware
|
|
178
|
+
context error. Supply a callable to convert custom values. Context errors always
|
|
179
|
+
raise, even under `errors="coerce"`.
|
|
180
|
+
|
|
181
|
+
## Eager Polars
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
import polars as pl
|
|
185
|
+
import jevframe.polars
|
|
186
|
+
|
|
187
|
+
df = pl.DataFrame({"review": ["Charged twice", "Works perfectly"]})
|
|
188
|
+
probabilities = await df.jev.noul("Is this customer dissatisfied?", state="review")
|
|
189
|
+
df = df.with_columns(probabilities.alias("dissatisfied"))
|
|
190
|
+
topics = await df.filter(pl.col("dissatisfied") > 0.5).jev.choice(
|
|
191
|
+
"What is the issue?",
|
|
192
|
+
choices=["billing", "bug", "other"],
|
|
193
|
+
state="review",
|
|
194
|
+
)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The API and columns match pandas; outputs are native `pl.Series`/`pl.DataFrame`.
|
|
198
|
+
Polars uses nulls for missing results; pandas uses NaN for floating outputs and
|
|
199
|
+
native nullable string/integer columns. Empty outputs retain their schemas.
|
|
200
|
+
LazyFrame expressions are outside v0.
|
|
201
|
+
|
|
202
|
+
## Controls, errors, and caching
|
|
203
|
+
|
|
204
|
+
Every method accepts these keyword arguments:
|
|
205
|
+
|
|
206
|
+
| Option | Default | Behavior |
|
|
207
|
+
| --- | --- | --- |
|
|
208
|
+
| `client` | `None` | Reuse an official `AsyncTypeSafeClient`; caller owns its lifetime |
|
|
209
|
+
| `model` | `None` | Inherit SDK client/environment default (`jev-latest` otherwise) |
|
|
210
|
+
| `max_concurrency` | `16` | Maximum simultaneous row evaluations, including retries |
|
|
211
|
+
| `cache` | `None` | Opt in with a reusable `MemoryCache` |
|
|
212
|
+
| `progress` | `False` | `True` for tqdm, or a synchronous `Progress` callback |
|
|
213
|
+
| `errors` | `"raise"` | `"coerce"` returns missing results and an `EvaluationWarning` |
|
|
214
|
+
| `nulls` | `"include"` | `"skip"` skips rows with any null context; `"raise"` rejects them |
|
|
215
|
+
|
|
216
|
+
Null policy applies **after** constructing context, including nested nulls. A row
|
|
217
|
+
failure makes every question result for that row missing. `EvaluationError.position`
|
|
218
|
+
and `.cause`, or `EvaluationWarning.failures` (`RowFailure.position`/`.cause`), identify
|
|
219
|
+
failures without confusing duplicate index labels. Positions refer to the evaluated
|
|
220
|
+
frame, starting at zero. Inspect warnings with `warnings.catch_warnings(record=True)`.
|
|
221
|
+
Progress snapshots expose `completed`, `total`, `failed`, `skipped`, and `cache_hits`.
|
|
222
|
+
Callback exceptions and cancellation propagate, and outstanding workers are drained.
|
|
223
|
+
|
|
224
|
+
All questions for a row share one request. SDK retries handle transient failures and
|
|
225
|
+
backoff (two retries by default); configure its policy and timeouts directly:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
from jevframe import MemoryCache
|
|
229
|
+
from typesafe_sdk import AsyncTypeSafeClient, RetryPolicy
|
|
230
|
+
|
|
231
|
+
cache = MemoryCache(max_entries=10_000)
|
|
232
|
+
async with AsyncTypeSafeClient(timeout=30, retry=RetryPolicy(max_retries=3)) as client:
|
|
233
|
+
result = await df.jev.noul(
|
|
234
|
+
"Is this urgent?",
|
|
235
|
+
state="review",
|
|
236
|
+
client=client,
|
|
237
|
+
max_concurrency=8,
|
|
238
|
+
cache=cache,
|
|
239
|
+
progress=True,
|
|
240
|
+
)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The memory cache stores only successful, validated complete responses, including
|
|
244
|
+
all probabilities. Identical in-flight rows within one evaluation share a request.
|
|
245
|
+
Questions, criteria order, context, model, and client/configuration identity affect
|
|
246
|
+
cache keys; indexes do not. Supplied clients have separate cache namespaces. Default
|
|
247
|
+
clients reuse entries across calls when their environment configuration matches.
|
|
248
|
+
There is no disk cache. Model aliases can change: use a pinned model for reproducible
|
|
249
|
+
work and `cache.clear()` when fresh results are needed.
|
|
250
|
+
|
|
251
|
+
No API call runs merely by importing the library. Evaluation sends the chosen
|
|
252
|
+
context to TypeSafe. Late context errors can occur after other rows have completed;
|
|
253
|
+
completed requests cannot be undone. Credentials are read from the environment or
|
|
254
|
+
the SDK client; the library does not discover or load dotenv files.
|
|
255
|
+
|
|
256
|
+
## marimo example and development
|
|
257
|
+
|
|
258
|
+
From a checkout:
|
|
259
|
+
|
|
260
|
+
```sh
|
|
261
|
+
# For library development only: uv sync --extra pandas (or --extra polars)
|
|
262
|
+
uv sync --extra examples
|
|
263
|
+
uv run marimo edit examples/reviews.py
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Edit the three questions, choose an output layout, and click **Evaluate reviews**
|
|
267
|
+
to see the results and probability histograms. The eight synthetic reviews need eight initial
|
|
268
|
+
requests, each containing all three questions; repeated evaluations reuse the cache.
|
|
269
|
+
The example makes no requests until the button is clicked and a key is configured.
|
|
270
|
+
|
|
271
|
+
The badge opens the GitHub notebook preview. Fork it into your molab workspace to
|
|
272
|
+
add your own secret and run it on a server. The notebook includes inline dependency
|
|
273
|
+
metadata that installs `jevframe[pandas]==0.1.0` from PyPI. The badge requires the
|
|
274
|
+
notebook to be available on this repository's `main` branch.
|
|
275
|
+
|
|
276
|
+
For development checks, install all extras:
|
|
277
|
+
|
|
278
|
+
```sh
|
|
279
|
+
uv sync --all-extras
|
|
280
|
+
uv run pytest
|
|
281
|
+
uv run ruff check .
|
|
282
|
+
uv run ruff format --check .
|
|
283
|
+
uv run marimo check --strict examples/reviews.py
|
|
284
|
+
uv build
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Tests use the official SDK with mocked HTTP transport; no API key or live calls
|
|
288
|
+
are required. v0 focuses on row-wise decisions, with no chat interface, automatic
|
|
289
|
+
analysis, generated code, custom dtypes, Excel integration, or provider framework.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
jevframe/__init__.py,sha256=X3DuHbP91raH8AbGZbFvqtllQYkDJ7u5OD6LFDINA4c,344
|
|
2
|
+
jevframe/_accessor.py,sha256=36ekzb-jTdlQLXODUwyqT4nt1L5-Dnzw3_QZdOEAM0s,4897
|
|
3
|
+
jevframe/_cache.py,sha256=8j2pCU7TK92pMUKPkT1dpkwjIEBoa672u1NRMEASKnI,2312
|
|
4
|
+
jevframe/_context.py,sha256=dVl9aYV4Ezn6rJcSPWz0iqVT3RtcKY00hVq-iOTc0nM,4064
|
|
5
|
+
jevframe/_engine.py,sha256=Wp4kQbzxXr_Qre_57zYDjmlGxfTPl6Ac2piXjbc9HcA,8347
|
|
6
|
+
jevframe/_schema.py,sha256=xNofkvl717Nhu_w9SPgX0hgTCGAwsMgnTwNv1LgX7pc,8186
|
|
7
|
+
jevframe/_types.py,sha256=RzyLEtb3LaKfGJnsjPvY8vlmAItzHnW3kbOVnOQWPAU,2396
|
|
8
|
+
jevframe/pandas.py,sha256=45_GFtL_IejrYqlQTJjDjEOD2nbV080Py6oJjWoaKDc,2762
|
|
9
|
+
jevframe/polars.py,sha256=o9ZqAkxUhOdS7MssfSvA2ZpSkGA59gDilJXUD31PVAA,3445
|
|
10
|
+
jevframe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
jevframe-0.1.0.dist-info/METADATA,sha256=mt87cl3blS_ZrLjAKBXotMEcPPBGLRhWpDljwyuoCkU,11931
|
|
12
|
+
jevframe-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
13
|
+
jevframe-0.1.0.dist-info/licenses/LICENSE,sha256=hxDP0vRWF0rfwJfmoycmbVnE92XOZpn0kNQ7kY9lnYo,1077
|
|
14
|
+
jevframe-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Konstantin Taletskiy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|