jevimage 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.
- jevimage/__init__.py +119 -0
- jevimage/answers.py +125 -0
- jevimage/batcher.py +212 -0
- jevimage/cli.py +548 -0
- jevimage/client.py +611 -0
- jevimage/core.py +298 -0
- jevimage/encoders.py +342 -0
- jevimage/images.py +213 -0
- jevimage/local.py +188 -0
- jevimage/server.py +442 -0
- jevimage/store.py +228 -0
- jevimage/training.py +226 -0
- jevimage-0.1.0.dist-info/METADATA +196 -0
- jevimage-0.1.0.dist-info/RECORD +18 -0
- jevimage-0.1.0.dist-info/WHEEL +5 -0
- jevimage-0.1.0.dist-info/entry_points.txt +2 -0
- jevimage-0.1.0.dist-info/licenses/LICENSE +21 -0
- jevimage-0.1.0.dist-info/top_level.txt +1 -0
jevimage/__init__.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""jevimage - a probability-first image classifier you can self-host.
|
|
2
|
+
|
|
3
|
+
One image encode, many typed questions read against it. Nothing is generated: every
|
|
4
|
+
answer is a calibrated probability distribution, so you get a number you can threshold
|
|
5
|
+
instead of a sentence you have to parse.
|
|
6
|
+
|
|
7
|
+
There are two ways to use this, and both are first class.
|
|
8
|
+
|
|
9
|
+
RUN IT YOURSELF - the models execute in your process, nothing leaves the machine:
|
|
10
|
+
|
|
11
|
+
pip install 'jevimage[local]'
|
|
12
|
+
|
|
13
|
+
import jevimage
|
|
14
|
+
jev = jevimage.load() # siglip2-base-224 by default
|
|
15
|
+
a = jev.ask("photo.jpg", {
|
|
16
|
+
"scene": {"type": "choice",
|
|
17
|
+
"criteria": {"indoor": "an indoor scene",
|
|
18
|
+
"outdoor": "an outdoor scene"}},
|
|
19
|
+
})
|
|
20
|
+
a["scene"].choice # 'outdoor'
|
|
21
|
+
a["scene"].probabilities # {'indoor': 0.07, 'outdoor': 0.93}
|
|
22
|
+
a["scene"].confidence # 0.86
|
|
23
|
+
|
|
24
|
+
USE A SERVER - someone else runs the encoder; you need no torch, no weights, no GPU:
|
|
25
|
+
|
|
26
|
+
pip install jevimage # a few hundred KB, stdlib + pillow
|
|
27
|
+
|
|
28
|
+
jev = jevimage.connect("https://jev.example.com", api_key="...")
|
|
29
|
+
a = jev.ask("photo.jpg", {...}) # the same call, the same Answer objects
|
|
30
|
+
|
|
31
|
+
`connect()` is a drop-in for `load()`: same method names, same return shapes. Write
|
|
32
|
+
against one and switch later by changing that line.
|
|
33
|
+
|
|
34
|
+
HOST IT FOR OTHERS:
|
|
35
|
+
|
|
36
|
+
pip install 'jevimage[serve]'
|
|
37
|
+
jev serve --host 0.0.0.0 --port 8000
|
|
38
|
+
|
|
39
|
+
Zero-shot not good enough? Train a head on your own labelled images. The encoder stays
|
|
40
|
+
frozen, so this takes about a second and costs nothing at query time - and it works the
|
|
41
|
+
same locally or through a server:
|
|
42
|
+
|
|
43
|
+
head = jev.train("street", "dataset/") # dataset/bus/*.jpg, dataset/bike/*.jpg
|
|
44
|
+
head.accuracy # held-out, k-fold
|
|
45
|
+
jev.ask("photo.jpg", {"q": {"type": "head", "head": "street"}})
|
|
46
|
+
"""
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
__version__ = "0.1.0"
|
|
50
|
+
|
|
51
|
+
# Only the light things are imported eagerly. torch and transformers live behind
|
|
52
|
+
# __getattr__ below, so `import jevimage` costs milliseconds and an API-only install
|
|
53
|
+
# never needs them on disk at all.
|
|
54
|
+
from .answers import Answer, confidence, normalise_questions
|
|
55
|
+
from .images import IMAGE_SUFFIXES, Head, as_examples, read_folder, to_image
|
|
56
|
+
|
|
57
|
+
__all__ = ["load", "connect", "register", "available", "Jev", "Head", "Answer",
|
|
58
|
+
"Encoder", "JevError", "to_image", "read_folder", "as_examples",
|
|
59
|
+
"confidence", "IMAGE_SUFFIXES", "DEFAULT", "__version__"]
|
|
60
|
+
|
|
61
|
+
_LAZY = {
|
|
62
|
+
"load": ("local", "load"),
|
|
63
|
+
"Jev": ("local", "Jev"),
|
|
64
|
+
"Encoder": ("encoders", "Encoder"),
|
|
65
|
+
"register": ("encoders", "register"),
|
|
66
|
+
"available": ("encoders", "available"),
|
|
67
|
+
"DEFAULT": ("encoders", "DEFAULT"),
|
|
68
|
+
"JevError": ("client", "JevError"),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def __getattr__(name):
|
|
73
|
+
"""Resolve the rest of the API on first use.
|
|
74
|
+
|
|
75
|
+
`load` and `Jev` need torch; the encoder-registry names and `JevError` do not - they
|
|
76
|
+
are here to keep `import jevimage` down to milliseconds. Importing the heavy half
|
|
77
|
+
lazily is what lets `pip install jevimage` be a small HTTP client, while
|
|
78
|
+
`pip install 'jevimage[local]'` is the full framework under the identical name.
|
|
79
|
+
"""
|
|
80
|
+
target = _LAZY.get(name)
|
|
81
|
+
if target is None:
|
|
82
|
+
# submodules too: error messages and the docs say jevimage.encoders.unit(),
|
|
83
|
+
# and a plain `import jevimage` should be enough to reach one.
|
|
84
|
+
import importlib.util
|
|
85
|
+
if importlib.util.find_spec(f".{name}", __name__) is None:
|
|
86
|
+
raise AttributeError(f"module 'jevimage' has no attribute {name!r}")
|
|
87
|
+
target = (name, None)
|
|
88
|
+
module, attr = target
|
|
89
|
+
try:
|
|
90
|
+
import importlib
|
|
91
|
+
mod = importlib.import_module(f".{module}", __name__)
|
|
92
|
+
return mod if attr is None else getattr(mod, attr)
|
|
93
|
+
except ImportError as e:
|
|
94
|
+
raise ImportError(
|
|
95
|
+
f"jevimage.{name} runs the encoder in this process, which needs torch and "
|
|
96
|
+
f"transformers: pip install 'jevimage[local]'\n"
|
|
97
|
+
f"To use a server instead - no torch required - call "
|
|
98
|
+
f"jevimage.connect(url).\n(underlying import error: {e})") from e
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def __dir__():
|
|
102
|
+
return sorted(__all__)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def connect(url: str, *, api_key: str | None = None, timeout: float = 60.0,
|
|
106
|
+
max_edge: int | None = 384):
|
|
107
|
+
"""Talk to a `jev serve` instance. Same methods as `load()`, over HTTP.
|
|
108
|
+
|
|
109
|
+
Needs no torch and downloads no weights, so this is the whole dependency footprint
|
|
110
|
+
of an API-only install.
|
|
111
|
+
|
|
112
|
+
`max_edge` downscales an image before uploading it, because no encoder reads more
|
|
113
|
+
than 384px and on a fresh connection the upload is most of the latency. Pass None
|
|
114
|
+
to send originals.
|
|
115
|
+
The returned object keeps one connection open; close it with `.close()`, or use it
|
|
116
|
+
as a context manager.
|
|
117
|
+
"""
|
|
118
|
+
from .client import Remote
|
|
119
|
+
return Remote(url, api_key=api_key, timeout=timeout, max_edge=max_edge)
|
jevimage/answers.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The shapes a request and its answers take, with no dependency on torch.
|
|
2
|
+
|
|
3
|
+
This module is deliberately pure Python. An API-only install - someone who just calls a
|
|
4
|
+
`jev serve` instance over HTTP - gets the same Answer objects as someone running the
|
|
5
|
+
models locally, without installing a deep-learning stack to do it.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import numbers
|
|
11
|
+
|
|
12
|
+
__all__ = ["Answer", "check_template", "confidence", "normalise_questions",
|
|
13
|
+
"positive_temperature", "TEMPERATURE_RANGE"]
|
|
14
|
+
|
|
15
|
+
# The usable range. Probabilities are reported to six decimals, so below the floor every
|
|
16
|
+
# distribution is one-hot and above the ceiling every one is uniform to the last digit
|
|
17
|
+
# printed - and past about 1e38 the fp32 division underflows to zero, which made the
|
|
18
|
+
# ANSWER flip to whichever option was listed first. Both ends are the same failure inf
|
|
19
|
+
# is refused for: a well-formed answer carrying no signal.
|
|
20
|
+
TEMPERATURE_RANGE = (1e-6, 1e6)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def positive_temperature(temperature):
|
|
24
|
+
"""A negative temperature inverts every softmax - the least similar option wins, and
|
|
25
|
+
the answer still reports high confidence - so it must never reach one.
|
|
26
|
+
|
|
27
|
+
Here rather than in `core`, so the torch-free client can refuse a bad temperature
|
|
28
|
+
with the same three sentences a local `Jev` gives instead of its own wording.
|
|
29
|
+
"""
|
|
30
|
+
# numbers.Real, not (int, float): a numpy scalar is a number, and refusing
|
|
31
|
+
# np.float32(0.5) with "must be a number greater than 0, got 0.5" states a falsehood
|
|
32
|
+
# about the value it prints - while np.float64 sailed through for subclassing float.
|
|
33
|
+
if isinstance(temperature, bool) or not isinstance(temperature, numbers.Real):
|
|
34
|
+
raise ValueError(f"temperature must be a number greater than 0, "
|
|
35
|
+
f"got {temperature!r}")
|
|
36
|
+
if not temperature > 0:
|
|
37
|
+
raise ValueError(f"temperature must be greater than 0, got {temperature!r}")
|
|
38
|
+
# inf is "greater than 0" and divides every logit to zero, so every option comes
|
|
39
|
+
# back equally likely and confidence 0 - a well-formed answer carrying no signal,
|
|
40
|
+
# which is worse than an error because nothing downstream can tell.
|
|
41
|
+
if not math.isfinite(temperature):
|
|
42
|
+
raise ValueError(f"temperature must be finite, got {temperature!r}; an infinite "
|
|
43
|
+
"temperature makes every option equally likely")
|
|
44
|
+
lo, hi = TEMPERATURE_RANGE
|
|
45
|
+
if not lo <= temperature <= hi:
|
|
46
|
+
raise ValueError(f"temperature must be between {lo:g} and {hi:g}, got "
|
|
47
|
+
f"{temperature!r}; outside that range every option comes back "
|
|
48
|
+
"equally likely, or exactly one does, whatever the image says")
|
|
49
|
+
return float(temperature)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_template(tpl, field: str = "prompt"):
|
|
53
|
+
"""A caption template must substitute exactly once.
|
|
54
|
+
|
|
55
|
+
Testing for a literal '{}' is not enough: 'a photo of {x}', '{} and {}' and '{ {}'
|
|
56
|
+
all contain a brace, all passed, and all then blew up inside str.format with a raw
|
|
57
|
+
stdlib error naming neither the field nor the question. A trial substitution answers
|
|
58
|
+
the only question that matters - can this template turn one option into one caption.
|
|
59
|
+
"""
|
|
60
|
+
probe = "\x00probe\x00"
|
|
61
|
+
try:
|
|
62
|
+
if isinstance(tpl, str) and tpl.format(probe).count(probe) == 1:
|
|
63
|
+
return tpl
|
|
64
|
+
except (IndexError, KeyError, ValueError):
|
|
65
|
+
pass
|
|
66
|
+
# without exactly one placeholder every option gets the same caption, so the answer
|
|
67
|
+
# is a uniform distribution over identical strings and `choice` is whichever came first
|
|
68
|
+
raise ValueError(f"{field} {tpl!r} has no '{{}}' placeholder, so every option "
|
|
69
|
+
"would get the same caption; write e.g. 'a photo of {}'")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Answer(dict):
|
|
73
|
+
"""One question's answer. A plain dict, so it serialises to JSON unchanged, with
|
|
74
|
+
attribute access because `a.choice` reads better than `a["choice"]`."""
|
|
75
|
+
|
|
76
|
+
def __getattr__(self, k):
|
|
77
|
+
try:
|
|
78
|
+
return self[k]
|
|
79
|
+
except KeyError:
|
|
80
|
+
raise AttributeError(
|
|
81
|
+
f"{self.get('type', 'answer')!r} answers have no {k!r}; "
|
|
82
|
+
f"this one has: {', '.join(self)}") from None
|
|
83
|
+
|
|
84
|
+
def __repr__(self):
|
|
85
|
+
if "choice" in self:
|
|
86
|
+
return (f"<{self['type']} {self['choice']!r} "
|
|
87
|
+
f"p={max(self['probabilities'].values()):.3f}>")
|
|
88
|
+
if "noul" in self:
|
|
89
|
+
return f"<noul {self['noul']:.3f}>"
|
|
90
|
+
if "score" in self:
|
|
91
|
+
return f"<score {self['score']:.2f}/{len(self['legend']) - 1}>"
|
|
92
|
+
return f"<{self.get('type', 'answer')}>"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def confidence(p) -> float:
|
|
96
|
+
"""How peaked a distribution is, rescaled so uniform is 0 and certain is 1.
|
|
97
|
+
|
|
98
|
+
Raw max-probability is not comparable across questions: 0.5 is lukewarm over two
|
|
99
|
+
options and emphatic over fifty. (max - 1/K) / (1 - 1/K) removes that dependence,
|
|
100
|
+
so one threshold works for every question you ask.
|
|
101
|
+
"""
|
|
102
|
+
k = len(p)
|
|
103
|
+
return (max(p) - 1 / k) / (1 - 1 / k) if k > 1 else 1.0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def normalise_questions(questions) -> dict:
|
|
107
|
+
"""A dict keyed by id, or a list that gets q1, q2, ... . Both are common at call
|
|
108
|
+
sites; neither should need a wrapper."""
|
|
109
|
+
if isinstance(questions, dict):
|
|
110
|
+
return questions
|
|
111
|
+
if isinstance(questions, (list, tuple)):
|
|
112
|
+
out = {}
|
|
113
|
+
for i, q in enumerate(questions):
|
|
114
|
+
if not isinstance(q, dict):
|
|
115
|
+
# same sentence core.plan gives for the dict form, so a malformed
|
|
116
|
+
# question reads the same whichever shape it arrived in - and an
|
|
117
|
+
# AttributeError below would escape as a 500 rather than a 422
|
|
118
|
+
raise ValueError(f"a question must be an object, got {type(q).__name__}")
|
|
119
|
+
qid = q.get("id") or f"q{i + 1}"
|
|
120
|
+
if qid in out:
|
|
121
|
+
# the dict would keep only the last: two questions in, one answer out
|
|
122
|
+
raise ValueError(f"two questions share the id {qid!r}; ids must be unique")
|
|
123
|
+
out[qid] = q
|
|
124
|
+
return out
|
|
125
|
+
raise TypeError("questions must be a {id: question} object or a list of questions")
|
jevimage/batcher.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Dynamic batching for the image encoder.
|
|
2
|
+
|
|
3
|
+
One image through the encoder costs 9.7ms; eight together cost 20.6ms - 2.6ms each.
|
|
4
|
+
The GPU is idle between the small matmuls of a single image, so the marginal image in a
|
|
5
|
+
batch is nearly free. Measured on the A10G:
|
|
6
|
+
|
|
7
|
+
batch 1 9.68 ms/img 103 img/s
|
|
8
|
+
batch 8 2.57 ms/img 388 img/s 3.8x
|
|
9
|
+
batch 16 2.05 ms/img 487 img/s 4.7x
|
|
10
|
+
|
|
11
|
+
Requests arriving within a few milliseconds of each other are therefore worth holding
|
|
12
|
+
briefly so they can ride one forward pass. The wait is bounded and small: under load the
|
|
13
|
+
batch fills before the timer, and when traffic is thin a request waits at most
|
|
14
|
+
`max_wait` and still beats the un-batched path on throughput while losing only that.
|
|
15
|
+
|
|
16
|
+
Thread-based on purpose - the server's handlers are sync and uvicorn runs them in its
|
|
17
|
+
threadpool, so this batches whatever that pool is holding without making anything async.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
__all__ = ["Batcher"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Batcher:
|
|
28
|
+
"""Collect concurrent calls into one call of `fn(list) -> list`."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, fn, max_batch: int = 16, max_wait: float = 0.006,
|
|
31
|
+
name: str = "batch"):
|
|
32
|
+
self.fn = fn
|
|
33
|
+
self.max_batch = max_batch
|
|
34
|
+
self.max_wait = max_wait
|
|
35
|
+
self.name = name
|
|
36
|
+
self._q = [] # [(item, slot)]
|
|
37
|
+
self._ema = 1.0 # recent batch size; drives the adaptive wait
|
|
38
|
+
self._lock = threading.Lock()
|
|
39
|
+
self._wake = threading.Condition(self._lock)
|
|
40
|
+
self._worker = None
|
|
41
|
+
self.stats = {"calls": 0, "batches": 0, "items": 0, "max_seen": 0}
|
|
42
|
+
|
|
43
|
+
def _ensure_worker(self):
|
|
44
|
+
# started lazily so importing this module never spawns a thread, and so a
|
|
45
|
+
# forked uvicorn worker gets its own rather than inheriting a dead one
|
|
46
|
+
if self._worker is None or not self._worker.is_alive():
|
|
47
|
+
self._worker = threading.Thread(target=self._run, daemon=True,
|
|
48
|
+
name=f"batcher-{self.name}")
|
|
49
|
+
self._worker.start()
|
|
50
|
+
|
|
51
|
+
def submit(self, item):
|
|
52
|
+
"""Queue one item and block until its result is ready. Raises what fn raised."""
|
|
53
|
+
slot = {"event": threading.Event(), "out": None, "err": None}
|
|
54
|
+
self.stats["calls"] += 1
|
|
55
|
+
with self._wake:
|
|
56
|
+
self._q.append((item, slot))
|
|
57
|
+
self._ensure_worker()
|
|
58
|
+
self._wake.notify()
|
|
59
|
+
slot["event"].wait()
|
|
60
|
+
if slot["err"] is not None:
|
|
61
|
+
raise slot["err"]
|
|
62
|
+
return slot["out"]
|
|
63
|
+
|
|
64
|
+
def _drain(self):
|
|
65
|
+
"""Wait for work, then let a batch accumulate for at most max_wait.
|
|
66
|
+
|
|
67
|
+
The wait is ADAPTIVE. Holding a request for 6ms only pays if someone else
|
|
68
|
+
arrives inside that window; on an idle service nobody does, and every request
|
|
69
|
+
pays the delay for nothing - measured as p50 13ms -> 20ms at concurrency 1.
|
|
70
|
+
So the recent batch size decides: while traffic is thin the batch goes straight
|
|
71
|
+
through, and the wait switches on only once batches are actually forming.
|
|
72
|
+
"""
|
|
73
|
+
with self._wake:
|
|
74
|
+
while not self._q:
|
|
75
|
+
self._wake.wait(0.5)
|
|
76
|
+
if not self._q:
|
|
77
|
+
return []
|
|
78
|
+
wait = self.max_wait if self._ema >= 1.5 else 0.0
|
|
79
|
+
if wait:
|
|
80
|
+
deadline = time.monotonic() + wait
|
|
81
|
+
while len(self._q) < self.max_batch:
|
|
82
|
+
left = deadline - time.monotonic()
|
|
83
|
+
if left <= 0:
|
|
84
|
+
break
|
|
85
|
+
self._wake.wait(left)
|
|
86
|
+
batch, self._q = self._q[:self.max_batch], self._q[self.max_batch:]
|
|
87
|
+
# a queue that refilled while we worked is itself evidence of load, so count
|
|
88
|
+
# the backlog too - otherwise the EMA can never climb out of "thin"
|
|
89
|
+
self._ema = 0.7 * self._ema + 0.3 * (len(batch) + min(len(self._q), 4))
|
|
90
|
+
return batch
|
|
91
|
+
|
|
92
|
+
def _run(self):
|
|
93
|
+
while True:
|
|
94
|
+
batch = self._drain()
|
|
95
|
+
if not batch:
|
|
96
|
+
continue
|
|
97
|
+
items = [b[0] for b in batch]
|
|
98
|
+
try:
|
|
99
|
+
out = self.fn(items)
|
|
100
|
+
if len(out) != len(items):
|
|
101
|
+
raise RuntimeError(f"batched fn returned {len(out)} for {len(items)}")
|
|
102
|
+
for (_, slot), o in zip(batch, out):
|
|
103
|
+
slot["out"] = o
|
|
104
|
+
except BaseException as e: # one bad batch must not kill the worker
|
|
105
|
+
# Nor may it fail its neighbours. Batching is an internal throughput
|
|
106
|
+
# trick, so one caller's undecodable image must not answer seven other
|
|
107
|
+
# callers with an error about a file they never sent. Re-run the batch
|
|
108
|
+
# one item at a time and route each result - or each failure - to the
|
|
109
|
+
# caller it belongs to. Only on the error path, so it costs nothing
|
|
110
|
+
# when nothing is wrong.
|
|
111
|
+
for item, slot in batch:
|
|
112
|
+
if len(batch) == 1:
|
|
113
|
+
slot["err"] = e
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
out = self.fn([item])
|
|
117
|
+
if len(out) != 1:
|
|
118
|
+
raise RuntimeError(f"batched fn returned {len(out)} for 1")
|
|
119
|
+
slot["out"] = out[0]
|
|
120
|
+
except BaseException as e2:
|
|
121
|
+
slot["err"] = e2
|
|
122
|
+
finally:
|
|
123
|
+
self.stats["batches"] += 1
|
|
124
|
+
self.stats["items"] += len(batch)
|
|
125
|
+
self.stats["max_seen"] = max(self.stats["max_seen"], len(batch))
|
|
126
|
+
for _, slot in batch:
|
|
127
|
+
slot["event"].set()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def demo() -> None:
|
|
131
|
+
"""python3 batcher.py - proves batching, ordering, errors and the wait bound."""
|
|
132
|
+
seen = []
|
|
133
|
+
|
|
134
|
+
def fn(xs):
|
|
135
|
+
seen.append(len(xs))
|
|
136
|
+
time.sleep(0.005)
|
|
137
|
+
return [x * 2 for x in xs]
|
|
138
|
+
|
|
139
|
+
b = Batcher(fn, max_batch=8, max_wait=0.02)
|
|
140
|
+
|
|
141
|
+
# concurrent callers share a pass, and each gets ITS OWN answer back
|
|
142
|
+
out = {}
|
|
143
|
+
def call(i):
|
|
144
|
+
out[i] = b.submit(i)
|
|
145
|
+
ts = [threading.Thread(target=call, args=(i,)) for i in range(8)]
|
|
146
|
+
for t in ts:
|
|
147
|
+
t.start()
|
|
148
|
+
for t in ts:
|
|
149
|
+
t.join()
|
|
150
|
+
assert out == {i: i * 2 for i in range(8)}, out
|
|
151
|
+
assert max(seen) > 1, f"nothing batched: {seen}"
|
|
152
|
+
|
|
153
|
+
# a lone caller on a quiet batcher pays no waiting penalty at all
|
|
154
|
+
quiet = Batcher(lambda xs: xs, max_batch=8, max_wait=0.050)
|
|
155
|
+
quiet.submit(1) # prime the worker
|
|
156
|
+
t0 = time.perf_counter()
|
|
157
|
+
assert quiet.submit(21) == 21
|
|
158
|
+
solo = time.perf_counter() - t0
|
|
159
|
+
assert solo < 0.010, f"idle solo call waited {solo*1000:.0f}ms, should not wait"
|
|
160
|
+
|
|
161
|
+
# an exception reaches every caller in the batch, and the worker survives
|
|
162
|
+
def boom(xs):
|
|
163
|
+
raise ValueError("upstream failed")
|
|
164
|
+
b2 = Batcher(boom, max_batch=4, max_wait=0.01)
|
|
165
|
+
errs = []
|
|
166
|
+
def call2(i):
|
|
167
|
+
try:
|
|
168
|
+
b2.submit(i)
|
|
169
|
+
except ValueError as e:
|
|
170
|
+
errs.append(str(e))
|
|
171
|
+
ts = [threading.Thread(target=call2, args=(i,)) for i in range(4)]
|
|
172
|
+
for t in ts:
|
|
173
|
+
t.start()
|
|
174
|
+
for t in ts:
|
|
175
|
+
t.join()
|
|
176
|
+
assert len(errs) == 4 and all("upstream failed" in e for e in errs), errs
|
|
177
|
+
|
|
178
|
+
# ONE bad item must not fail the callers who rode the same pass. This is the whole
|
|
179
|
+
# point: on a server, one truncated upload used to error every concurrent request.
|
|
180
|
+
def picky(xs):
|
|
181
|
+
if any(x == "bad" for x in xs):
|
|
182
|
+
raise ValueError("cannot decode 'bad'")
|
|
183
|
+
return [x * 2 for x in xs]
|
|
184
|
+
b4 = Batcher(picky, max_batch=8, max_wait=0.02)
|
|
185
|
+
got = {}
|
|
186
|
+
def call4(i, x):
|
|
187
|
+
try:
|
|
188
|
+
got[i] = b4.submit(x)
|
|
189
|
+
except ValueError as e:
|
|
190
|
+
got[i] = f"ERR {e}"
|
|
191
|
+
ts = [threading.Thread(target=call4, args=(i, i)) for i in range(7)]
|
|
192
|
+
ts.append(threading.Thread(target=call4, args=(7, "bad")))
|
|
193
|
+
for t in ts:
|
|
194
|
+
t.start()
|
|
195
|
+
for t in ts:
|
|
196
|
+
t.join()
|
|
197
|
+
assert got[7] == "ERR cannot decode 'bad'", got[7]
|
|
198
|
+
assert all(got[i] == i * 2 for i in range(7)), got
|
|
199
|
+
|
|
200
|
+
# a wrong-length return is an error, not silent misrouting of answers
|
|
201
|
+
b3 = Batcher(lambda xs: xs[:-1], max_batch=4, max_wait=0.01) # always short by one
|
|
202
|
+
try:
|
|
203
|
+
b3.submit(1)
|
|
204
|
+
raise AssertionError("short return was not caught")
|
|
205
|
+
except RuntimeError:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
print(f"batcher selftest ok - batches {seen}, solo wait {solo*1000:.0f}ms")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
demo()
|