predcache 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: predcache
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Persistent timestamp-keyed prediction cache and batched inference runner for ML models
5
5
  Author-email: Anton Uralskii <uralskyanton@gmail.com>
6
6
  License: MIT
@@ -28,9 +28,13 @@ Requires-Dist: numpy>=1.21
28
28
  Provides-Extra: parquet
29
29
  Requires-Dist: pandas>=1.5; extra == "parquet"
30
30
  Requires-Dist: pyarrow>=10; extra == "parquet"
31
+ Provides-Extra: otel
32
+ Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
31
33
  Provides-Extra: test
32
34
  Requires-Dist: pytest>=7; extra == "test"
33
35
  Requires-Dist: pandas>=1.5; extra == "test"
36
+ Requires-Dist: opentelemetry-api>=1.20; extra == "test"
37
+ Requires-Dist: opentelemetry-sdk>=1.20; extra == "test"
34
38
  Dynamic: license-file
35
39
 
36
40
  # predcache
@@ -104,9 +108,50 @@ out = cache.get_or_compute(
104
108
  box; register custom backends with `register_backend(name, dump, load)`.
105
109
  - **Batched inference runner** — framework-agnostic `(start, stop) -> scores`
106
110
  contract, progress callbacks, tuned default batch size (500).
111
+ - **OpenTelemetry instrumentation** — optional spans + counters for cache hits,
112
+ computed predictions, batched inference and feature assembly (see below).
107
113
  - **L-inf normalization** helper matching the source system's
108
114
  `normalize_vector_absolute`.
109
115
 
116
+ ## Observability (OpenTelemetry)
117
+
118
+ `predcache` emits spans and counters for every expensive operation. The
119
+ `opentelemetry-api` package is **optional**: without it predcache runs
120
+ identically with zero overhead; with it installed, signals flow to whatever
121
+ Tracer/Meter provider your application configured.
122
+
123
+ ```bash
124
+ pip install predcache[otel] # pull in opentelemetry-api
125
+ ```
126
+
127
+ ```python
128
+ from opentelemetry import trace
129
+ from opentelemetry.sdk.trace import TracerProvider
130
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
131
+
132
+ trace.set_tracer_provider(TracerProvider())
133
+ trace.get_tracer_provider().add_span_processor(
134
+ BatchSpanProcessor(ConsoleSpanExporter())
135
+ )
136
+
137
+ from predcache import PredictionCache
138
+
139
+ cache = PredictionCache("preds.pkl")
140
+ cache.get_or_compute(keys, compute_fn)
141
+ # -> span "predcache.get_or_compute" with predcache.requested / .computed /
142
+ # .hits / .elapsed_s attributes, + predictions.computed counter
143
+ ```
144
+
145
+ | Signal | Name | Attributes |
146
+ |---|---|---|
147
+ | span | `predcache.get_or_compute` | `predcache.requested`, `.computed`, `.hits`, `.elapsed_s` |
148
+ | span | `predcache.inference` | `predcache.rows`, `.batch_size`, `.elapsed_s` |
149
+ | span | `predcache.features` | `predcache.windows`, `.window`, `.warmup`, `.elapsed_s` |
150
+ | counter | `predcache.predictions.computed` / `.hits` | — |
151
+ | counter | `predcache.inference.batches` / `.rows` | — |
152
+
153
+ Set `PREDCACHE_OTEL=0` to disable emission entirely (hot loops, test runs).
154
+
110
155
  ## Origin
111
156
 
112
157
  This package is the generalized core of the prediction-serving layer of
@@ -69,9 +69,50 @@ out = cache.get_or_compute(
69
69
  box; register custom backends with `register_backend(name, dump, load)`.
70
70
  - **Batched inference runner** — framework-agnostic `(start, stop) -> scores`
71
71
  contract, progress callbacks, tuned default batch size (500).
72
+ - **OpenTelemetry instrumentation** — optional spans + counters for cache hits,
73
+ computed predictions, batched inference and feature assembly (see below).
72
74
  - **L-inf normalization** helper matching the source system's
73
75
  `normalize_vector_absolute`.
74
76
 
77
+ ## Observability (OpenTelemetry)
78
+
79
+ `predcache` emits spans and counters for every expensive operation. The
80
+ `opentelemetry-api` package is **optional**: without it predcache runs
81
+ identically with zero overhead; with it installed, signals flow to whatever
82
+ Tracer/Meter provider your application configured.
83
+
84
+ ```bash
85
+ pip install predcache[otel] # pull in opentelemetry-api
86
+ ```
87
+
88
+ ```python
89
+ from opentelemetry import trace
90
+ from opentelemetry.sdk.trace import TracerProvider
91
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
92
+
93
+ trace.set_tracer_provider(TracerProvider())
94
+ trace.get_tracer_provider().add_span_processor(
95
+ BatchSpanProcessor(ConsoleSpanExporter())
96
+ )
97
+
98
+ from predcache import PredictionCache
99
+
100
+ cache = PredictionCache("preds.pkl")
101
+ cache.get_or_compute(keys, compute_fn)
102
+ # -> span "predcache.get_or_compute" with predcache.requested / .computed /
103
+ # .hits / .elapsed_s attributes, + predictions.computed counter
104
+ ```
105
+
106
+ | Signal | Name | Attributes |
107
+ |---|---|---|
108
+ | span | `predcache.get_or_compute` | `predcache.requested`, `.computed`, `.hits`, `.elapsed_s` |
109
+ | span | `predcache.inference` | `predcache.rows`, `.batch_size`, `.elapsed_s` |
110
+ | span | `predcache.features` | `predcache.windows`, `.window`, `.warmup`, `.elapsed_s` |
111
+ | counter | `predcache.predictions.computed` / `.hits` | — |
112
+ | counter | `predcache.inference.batches` / `.rows` | — |
113
+
114
+ Set `PREDCACHE_OTEL=0` to disable emission entirely (hot loops, test runs).
115
+
75
116
  ## Origin
76
117
 
77
118
  This package is the generalized core of the prediction-serving layer of
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "predcache"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Persistent timestamp-keyed prediction cache and batched inference runner for ML models"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -37,7 +37,8 @@ dependencies = ["numpy>=1.21"]
37
37
 
38
38
  [project.optional-dependencies]
39
39
  parquet = ["pandas>=1.5", "pyarrow>=10"]
40
- test = ["pytest>=7", "pandas>=1.5"]
40
+ otel = ["opentelemetry-api>=1.20"]
41
+ test = ["pytest>=7", "pandas>=1.5", "opentelemetry-api>=1.20", "opentelemetry-sdk>=1.20"]
41
42
 
42
43
  [project.urls]
43
44
  Homepage = "https://github.com/Geomanti/predcache"
@@ -1,14 +1,16 @@
1
1
  """predcache: persistent prediction caching and batched inference for ML models."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
4
4
 
5
5
  from .cache import PredictionCache, CacheEntry
6
6
  from .batched import BatchedInferenceRunner, InferenceFn
7
7
  from .windowing import FeatureWindowAssembler
8
+ from .observability import otel_available
8
9
 
9
10
  __all__ = [
10
11
  "PredictionCache",
11
12
  "CacheEntry",
12
13
  "BatchedInferenceRunner",
13
14
  "FeatureWindowAssembler",
15
+ "otel_available",
14
16
  ]
@@ -14,6 +14,8 @@ from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence
14
14
 
15
15
  import numpy as np
16
16
 
17
+ from .observability import _TimedSpan, add_count
18
+
17
19
  __all__ = ["BatchedInferenceRunner", "InferenceFn"]
18
20
 
19
21
  # A scorer takes (start, stop) and returns an array-like of predictions
@@ -79,19 +81,28 @@ class BatchedInferenceRunner:
79
81
  out: List[Any] = []
80
82
  t0 = time.perf_counter()
81
83
  n_batches = 0
82
- for start, stop in self.batches():
83
- chunk = self.inference_fn(start, stop)
84
- out.extend(_row_iter(chunk))
85
- n_batches += 1
86
- if progress_cb and n_batches % self.progress_every == 0:
87
- cb = {
88
- "rows_done": stop,
89
- "rows_total": self.n_samples,
90
- "batches": n_batches,
91
- "elapsed_s": round(time.perf_counter() - t0, 3),
92
- }
93
- self.progress_log.append(cb)
94
- progress_cb(cb)
84
+ with _TimedSpan(
85
+ "predcache.inference",
86
+ {
87
+ "predcache.rows": self.n_samples,
88
+ "predcache.batch_size": self.batch_size,
89
+ },
90
+ ):
91
+ for start, stop in self.batches():
92
+ chunk = self.inference_fn(start, stop)
93
+ out.extend(_row_iter(chunk))
94
+ n_batches += 1
95
+ add_count("predcache.inference.batches", 1)
96
+ add_count("predcache.inference.rows", stop - start)
97
+ if progress_cb and n_batches % self.progress_every == 0:
98
+ cb = {
99
+ "rows_done": stop,
100
+ "rows_total": self.n_samples,
101
+ "batches": n_batches,
102
+ "elapsed_s": round(time.perf_counter() - t0, 3),
103
+ }
104
+ self.progress_log.append(cb)
105
+ progress_cb(cb)
95
106
  return out
96
107
 
97
108
 
@@ -24,6 +24,8 @@ import tempfile
24
24
  from dataclasses import dataclass, field
25
25
  from typing import Any, Callable, Dict, Hashable, Iterable, List, Optional, Protocol
26
26
 
27
+ from .observability import _TimedSpan, add_count
28
+
27
29
  __all__ = ["PredictionCache", "CacheEntry", "register_backend"]
28
30
 
29
31
 
@@ -200,13 +202,22 @@ class PredictionCache:
200
202
  dict ``{key: value}``. Computed values are merged into the cache.
201
203
  """
202
204
  missing = [k for k in keys if k not in self._entries]
203
- if missing:
204
- computed = compute_fn(missing) or {}
205
- for k, v in computed.items():
206
- self._entries[k] = v
207
- self._dirty = True
208
- if save:
209
- self.save()
205
+ with _TimedSpan(
206
+ "predcache.get_or_compute",
207
+ {
208
+ "predcache.requested": len(keys),
209
+ "predcache.computed": len(missing),
210
+ "predcache.hits": len(keys) - len(missing),
211
+ },
212
+ ):
213
+ if missing:
214
+ computed = compute_fn(missing) or {}
215
+ for k, v in computed.items():
216
+ self._entries[k] = v
217
+ self._dirty = True
218
+ add_count("predcache.predictions.computed", len(computed))
219
+ if save:
220
+ self.save()
210
221
  return {k: self._entries[k] for k in keys if k in self._entries}
211
222
 
212
223
  def drop(self, keys: List[Hashable]) -> int:
@@ -0,0 +1,118 @@
1
+ """Optional OpenTelemetry instrumentation for predcache.
2
+
3
+ Design
4
+ ------
5
+ * **Zero hard dependency** — when ``opentelemetry`` is not installed every
6
+ helper here degrades to a no-op context manager / counter sink, so the
7
+ core cache stays dependency-light.
8
+ * **Auto-enabled** — as soon as ``opentelemetry-api`` is importable, spans
9
+ and counters flow to whatever ``TracerProvider``/``MeterProvider`` the
10
+ host application configured (the OpenTelemetry API returns no-op
11
+ tracers/meters until a provider is set, so unconfigured hosts pay
12
+ near-zero cost).
13
+ * **Explicit off switch** — set ``PREDCACHE_OTEL=0`` to disable emission
14
+ entirely (useful in hot loops or test matrices without the package).
15
+
16
+ Emitted signals
17
+ ---------------
18
+ Spans (as current spans, so they nest under the caller's trace):
19
+
20
+ * ``predcache.get_or_compute`` — attributes ``predcache.requested``,
21
+ ``predcache.computed``, ``predcache.hits``
22
+ * ``predcache.inference`` — ``predcache.rows``, ``predcache.batches``,
23
+ ``predcache.batch_size``, ``predcache.elapsed_s``
24
+ * ``predcache.features`` — ``predcache.windows``, ``predcache.window``,
25
+ ``predcache.warmup``
26
+
27
+ Counters:
28
+
29
+ * ``predcache.predictions.computed`` / ``predcache.predictions.hits``
30
+ * ``predcache.inference.batches`` / ``predcache.inference.rows``
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import time
37
+ from contextlib import nullcontext
38
+ from typing import Any, Dict, Optional
39
+
40
+ __all__ = ["span", "add_count", "otel_available"]
41
+
42
+ try: # opentelemetry-api is optional
43
+ from opentelemetry import metrics, trace
44
+
45
+ _OTEL_AVAILABLE = True
46
+ except ImportError: # pragma: no cover - exercised in no-dependency installs
47
+ _OTEL_AVAILABLE = False
48
+
49
+ _TRACER = None
50
+ _METER = None
51
+ _COUNTERS: Dict[str, Any] = {}
52
+
53
+
54
+ def otel_available() -> bool:
55
+ """True when opentelemetry is importable and not disabled by env var."""
56
+ return _OTEL_AVAILABLE and os.environ.get("PREDCACHE_OTEL", "1") != "0"
57
+
58
+
59
+ def _tracer():
60
+ global _TRACER
61
+ if _TRACER is None and _OTEL_AVAILABLE:
62
+ # ProxyTracer: resolves the real provider lazily, so caching here is safe
63
+ # even when the host sets the TracerProvider after importing predcache.
64
+ _TRACER = trace.get_tracer("predcache")
65
+ return _TRACER
66
+
67
+
68
+ def _meter():
69
+ global _METER
70
+ if _METER is None and _OTEL_AVAILABLE:
71
+ _METER = metrics.get_meter("predcache")
72
+ return _METER
73
+
74
+
75
+ def span(name: str, attributes: Optional[Dict[str, Any]] = None):
76
+ """Start a span as the current span; no-op context when OTel is absent."""
77
+ tracer = _tracer() if otel_available() else None
78
+ if tracer is None:
79
+ return nullcontext()
80
+ return tracer.start_as_current_span(name, attributes=attributes or {})
81
+
82
+
83
+ def add_count(name: str, value: int = 1, attributes: Optional[Dict[str, Any]] = None) -> None:
84
+ """Increment a monotonic counter; no-op when OTel is absent."""
85
+ meter = _meter() if otel_available() else None
86
+ if meter is None:
87
+ return
88
+ counter = _COUNTERS.get(name)
89
+ if counter is None:
90
+ counter = meter.create_counter(name)
91
+ _COUNTERS[name] = counter
92
+ if attributes:
93
+ counter.add(value, attributes)
94
+ else:
95
+ counter.add(value)
96
+
97
+
98
+ class _TimedSpan:
99
+ """Span helper that also reports elapsed seconds as a span attribute."""
100
+
101
+ def __init__(self, name: str, attributes: Optional[Dict[str, Any]] = None):
102
+ self._name = name
103
+ self._attributes = dict(attributes or {})
104
+ self._cm = span(name, self._attributes)
105
+ self._t0: Optional[float] = None
106
+
107
+ def __enter__(self):
108
+ self._cm.__enter__()
109
+ self._t0 = time.perf_counter()
110
+ return self
111
+
112
+ def __exit__(self, exc_type, exc, tb):
113
+ if self._t0 is not None:
114
+ elapsed = round(time.perf_counter() - self._t0, 6)
115
+ current = trace.get_current_span() if _OTEL_AVAILABLE else None
116
+ if current is not None and current.is_recording():
117
+ current.set_attribute("predcache.elapsed_s", elapsed)
118
+ return self._cm.__exit__(exc_type, exc, tb)
@@ -12,6 +12,8 @@ from typing import Callable, List, Optional, Sequence
12
12
 
13
13
  import numpy as np
14
14
 
15
+ from .observability import _TimedSpan
16
+
15
17
  __all__ = ["normalize_absolute", "FeatureWindowAssembler"]
16
18
 
17
19
 
@@ -81,10 +83,18 @@ class FeatureWindowAssembler:
81
83
  """Stack feature rows for every window; returns (X, keys)."""
82
84
  if self.row_fn is None:
83
85
  raise ValueError("row_fn is required to build features")
84
- rows: List[np.ndarray] = []
85
- for win in self.windows(df):
86
- rows.append(np.asarray(self.row_fn(win), dtype=np.float32))
87
- if not rows:
88
- return np.zeros((0, 0), dtype=np.float32), []
89
- keys = self.keys(df, key_col) if key_col else self.keys(df)
90
- return np.stack(rows, axis=0), keys
86
+ with _TimedSpan(
87
+ "predcache.features",
88
+ {
89
+ "predcache.windows": max(0, len(df) - self.warmup),
90
+ "predcache.window": self.window,
91
+ "predcache.warmup": self.warmup,
92
+ },
93
+ ):
94
+ rows: List[np.ndarray] = []
95
+ for win in self.windows(df):
96
+ rows.append(np.asarray(self.row_fn(win), dtype=np.float32))
97
+ if not rows:
98
+ return np.zeros((0, 0), dtype=np.float32), []
99
+ keys = self.keys(df, key_col) if key_col else self.keys(df)
100
+ return np.stack(rows, axis=0), keys
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: predcache
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Persistent timestamp-keyed prediction cache and batched inference runner for ML models
5
5
  Author-email: Anton Uralskii <uralskyanton@gmail.com>
6
6
  License: MIT
@@ -28,9 +28,13 @@ Requires-Dist: numpy>=1.21
28
28
  Provides-Extra: parquet
29
29
  Requires-Dist: pandas>=1.5; extra == "parquet"
30
30
  Requires-Dist: pyarrow>=10; extra == "parquet"
31
+ Provides-Extra: otel
32
+ Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
31
33
  Provides-Extra: test
32
34
  Requires-Dist: pytest>=7; extra == "test"
33
35
  Requires-Dist: pandas>=1.5; extra == "test"
36
+ Requires-Dist: opentelemetry-api>=1.20; extra == "test"
37
+ Requires-Dist: opentelemetry-sdk>=1.20; extra == "test"
34
38
  Dynamic: license-file
35
39
 
36
40
  # predcache
@@ -104,9 +108,50 @@ out = cache.get_or_compute(
104
108
  box; register custom backends with `register_backend(name, dump, load)`.
105
109
  - **Batched inference runner** — framework-agnostic `(start, stop) -> scores`
106
110
  contract, progress callbacks, tuned default batch size (500).
111
+ - **OpenTelemetry instrumentation** — optional spans + counters for cache hits,
112
+ computed predictions, batched inference and feature assembly (see below).
107
113
  - **L-inf normalization** helper matching the source system's
108
114
  `normalize_vector_absolute`.
109
115
 
116
+ ## Observability (OpenTelemetry)
117
+
118
+ `predcache` emits spans and counters for every expensive operation. The
119
+ `opentelemetry-api` package is **optional**: without it predcache runs
120
+ identically with zero overhead; with it installed, signals flow to whatever
121
+ Tracer/Meter provider your application configured.
122
+
123
+ ```bash
124
+ pip install predcache[otel] # pull in opentelemetry-api
125
+ ```
126
+
127
+ ```python
128
+ from opentelemetry import trace
129
+ from opentelemetry.sdk.trace import TracerProvider
130
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
131
+
132
+ trace.set_tracer_provider(TracerProvider())
133
+ trace.get_tracer_provider().add_span_processor(
134
+ BatchSpanProcessor(ConsoleSpanExporter())
135
+ )
136
+
137
+ from predcache import PredictionCache
138
+
139
+ cache = PredictionCache("preds.pkl")
140
+ cache.get_or_compute(keys, compute_fn)
141
+ # -> span "predcache.get_or_compute" with predcache.requested / .computed /
142
+ # .hits / .elapsed_s attributes, + predictions.computed counter
143
+ ```
144
+
145
+ | Signal | Name | Attributes |
146
+ |---|---|---|
147
+ | span | `predcache.get_or_compute` | `predcache.requested`, `.computed`, `.hits`, `.elapsed_s` |
148
+ | span | `predcache.inference` | `predcache.rows`, `.batch_size`, `.elapsed_s` |
149
+ | span | `predcache.features` | `predcache.windows`, `.window`, `.warmup`, `.elapsed_s` |
150
+ | counter | `predcache.predictions.computed` / `.hits` | — |
151
+ | counter | `predcache.inference.batches` / `.rows` | — |
152
+
153
+ Set `PREDCACHE_OTEL=0` to disable emission entirely (hot loops, test runs).
154
+
110
155
  ## Origin
111
156
 
112
157
  This package is the generalized core of the prediction-serving layer of
@@ -4,10 +4,12 @@ pyproject.toml
4
4
  src/predcache/__init__.py
5
5
  src/predcache/batched.py
6
6
  src/predcache/cache.py
7
+ src/predcache/observability.py
7
8
  src/predcache/windowing.py
8
9
  src/predcache.egg-info/PKG-INFO
9
10
  src/predcache.egg-info/SOURCES.txt
10
11
  src/predcache.egg-info/dependency_links.txt
11
12
  src/predcache.egg-info/requires.txt
12
13
  src/predcache.egg-info/top_level.txt
14
+ tests/test_observability.py
13
15
  tests/test_predcache.py
@@ -0,0 +1,14 @@
1
+ numpy>=1.21
2
+
3
+ [otel]
4
+ opentelemetry-api>=1.20
5
+
6
+ [parquet]
7
+ pandas>=1.5
8
+ pyarrow>=10
9
+
10
+ [test]
11
+ pytest>=7
12
+ pandas>=1.5
13
+ opentelemetry-api>=1.20
14
+ opentelemetry-sdk>=1.20
@@ -0,0 +1,141 @@
1
+ """Observability tests: spans + counters with the in-memory exporter.
2
+
3
+ These run only when opentelemetry is installed (the ``otel`` test extra);
4
+ they are skipped cleanly otherwise. The no-op path is covered by forcing
5
+ ``PREDCACHE_OTEL=0``.
6
+
7
+ Note: opentelemetry's SDK forbids overriding an already-set global
8
+ Tracer/MeterProvider, so these tests share ONE module-scoped provider and
9
+ read spans/metrics cumulatively (counting only the spans each test adds).
10
+ """
11
+
12
+ import pytest
13
+
14
+ pytest.importorskip("opentelemetry", reason="observability tests need opentelemetry-api/sdk")
15
+
16
+ from opentelemetry import trace as _trace
17
+ from opentelemetry import metrics as _metrics
18
+ from opentelemetry.sdk.metrics import MeterProvider
19
+ from opentelemetry.sdk.metrics.export import InMemoryMetricReader
20
+ from opentelemetry.sdk.trace import TracerProvider
21
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor
22
+ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
23
+
24
+ import predcache
25
+ from predcache import BatchedInferenceRunner, FeatureWindowAssembler, PredictionCache
26
+
27
+
28
+ @pytest.fixture(scope="module")
29
+ def span_exporter():
30
+ """One provider for the module; returns the shared in-memory exporter."""
31
+ exporter = InMemorySpanExporter()
32
+ provider = TracerProvider()
33
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
34
+ try:
35
+ _trace.set_tracer_provider(provider)
36
+ except Exception:
37
+ # A provider was already set by another test module — use a proxy
38
+ # tracer from the existing provider but keep collecting into ours.
39
+ pass
40
+ # Re-point predcache's lazy tracer at this provider.
41
+ import predcache.observability as obs
42
+
43
+ obs._TRACER = trace = _trace.get_tracer("predcache-test")
44
+ yield exporter
45
+ obs._TRACER = None # restore lazy resolution
46
+
47
+
48
+ @pytest.fixture(scope="module")
49
+ def metric_reader():
50
+ reader = InMemoryMetricReader()
51
+ provider = MeterProvider(metric_readers=[reader])
52
+ try:
53
+ _metrics.set_meter_provider(provider)
54
+ except Exception:
55
+ pass
56
+ import predcache.observability as obs
57
+
58
+ obs._METER = _metrics.get_meter("predcache-test")
59
+ # predcache caches counters per meter; reset the cache so the new meter
60
+ # creates fresh counters instead of reusing ones from another provider.
61
+ obs._COUNTERS.clear()
62
+ yield reader
63
+ obs._METER = None
64
+ obs._COUNTERS.clear()
65
+
66
+
67
+ def _names(exporter):
68
+ return [s.name for s in exporter.get_finished_spans()]
69
+
70
+
71
+ def test_get_or_compute_emits_span_with_attributes(span_exporter, metric_reader, tmp_path):
72
+ n_before = len(_names(span_exporter))
73
+ cache = PredictionCache(str(tmp_path / "c.pkl"))
74
+ cache.get_or_compute(["a", "b"], compute_fn=lambda missing: {k: 1 for k in missing})
75
+ cache.get_or_compute(["a", "b", "c"], compute_fn=lambda missing: {k: 2 for k in missing})
76
+
77
+ spans = span_exporter.get_finished_spans()[n_before:]
78
+ names = [s.name for s in spans]
79
+ assert names.count("predcache.get_or_compute") == 2
80
+
81
+ second = spans[-1]
82
+ attrs = dict(second.attributes)
83
+ assert attrs["predcache.requested"] == 3
84
+ assert attrs["predcache.computed"] == 1
85
+ assert attrs["predcache.hits"] == 2
86
+ assert "predcache.elapsed_s" in attrs
87
+
88
+
89
+ def test_runner_emits_inference_span_and_metrics(span_exporter, metric_reader):
90
+ n_before = len(_names(span_exporter))
91
+ runner = BatchedInferenceRunner(
92
+ n_samples=10, inference_fn=lambda s, e: [[float(i)] for i in range(s, e)], batch_size=4
93
+ )
94
+ runner.run()
95
+
96
+ spans = span_exporter.get_finished_spans()[n_before:]
97
+ inf = [s for s in spans if s.name == "predcache.inference"]
98
+ assert len(inf) == 1
99
+ attrs = dict(inf[0].attributes)
100
+ assert attrs["predcache.rows"] == 10
101
+ assert attrs["predcache.batch_size"] == 4
102
+ assert "predcache.elapsed_s" in attrs
103
+
104
+
105
+ def test_features_span(span_exporter, metric_reader):
106
+ import pandas as pd
107
+
108
+ n_before = len(_names(span_exporter))
109
+ df = pd.DataFrame({"time": [f"t{i}" for i in range(6)], "close": range(6)})
110
+ asm = FeatureWindowAssembler(window=3, warmup=1, row_fn=lambda win: [float(win["close"].iloc[0])])
111
+ X, keys = asm.build(df, key_col="time")
112
+ assert len(keys) == 5
113
+
114
+ spans = span_exporter.get_finished_spans()[n_before:]
115
+ feat = [s for s in spans if s.name == "predcache.features"]
116
+ assert len(feat) == 1
117
+ attrs = dict(feat[0].attributes)
118
+ assert attrs["predcache.windows"] == 5
119
+ assert attrs["predcache.window"] == 3
120
+
121
+
122
+ def test_env_kill_switch_disables_emission(span_exporter, metric_reader, tmp_path, monkeypatch):
123
+ monkeypatch.setenv("PREDCACHE_OTEL", "0")
124
+ try:
125
+ assert predcache.otel_available() is False
126
+ n_before = len(_names(span_exporter))
127
+ cache = PredictionCache(str(tmp_path / "c2.pkl"))
128
+ cache.get_or_compute(["x"], compute_fn=lambda missing: {k: 9 for k in missing})
129
+ assert len(_names(span_exporter)) == n_before
130
+ finally:
131
+ monkeypatch.delenv("PREDCACHE_OTEL", raising=False)
132
+
133
+
134
+ def test_package_works_without_otel(monkeypatch, tmp_path):
135
+ """The no-op path: PREDCACHE_OTEL=0 must not change cache behavior."""
136
+ monkeypatch.setenv("PREDCACHE_OTEL", "0")
137
+ cache = PredictionCache(str(tmp_path / "c3.pkl"))
138
+ r1 = cache.get_or_compute(["k1"], compute_fn=lambda missing: {k: 42 for k in missing})
139
+ r2 = cache.get_or_compute(["k1"], compute_fn=lambda missing: {k: -1 for k in missing})
140
+ assert r1 == {"k1": 42}
141
+ assert r2 == {"k1": 42} # served from cache, compute_fn not called
@@ -1,9 +0,0 @@
1
- numpy>=1.21
2
-
3
- [parquet]
4
- pandas>=1.5
5
- pyarrow>=10
6
-
7
- [test]
8
- pytest>=7
9
- pandas>=1.5
File without changes
File without changes