flopscope-server 0.4.1__tar.gz → 0.4.3__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.
Files changed (23) hide show
  1. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/PKG-INFO +2 -2
  2. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/pyproject.toml +2 -2
  3. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/src/flopscope_server/__init__.py +1 -1
  4. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/src/flopscope_server/_protocol.py +4 -0
  5. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/src/flopscope_server/_request_handler.py +90 -9
  6. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/src/flopscope_server/_session.py +26 -0
  7. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/.gitignore +0 -0
  8. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/README.md +0 -0
  9. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/src/flopscope_server/__main__.py +0 -0
  10. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/src/flopscope_server/_array_store.py +0 -0
  11. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/src/flopscope_server/_comms_tracker.py +0 -0
  12. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/src/flopscope_server/_server.py +0 -0
  13. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/tests/test_array_store.py +0 -0
  14. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/tests/test_bugfixes_round2.py +0 -0
  15. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/tests/test_bugfixes_round3.py +0 -0
  16. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/tests/test_comms_tracker.py +0 -0
  17. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/tests/test_new_types.py +0 -0
  18. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/tests/test_protocol.py +0 -0
  19. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/tests/test_request_handler.py +0 -0
  20. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/tests/test_server.py +0 -0
  21. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/tests/test_session.py +0 -0
  22. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/tests/test_version_handshake.py +0 -0
  23. {flopscope_server-0.4.1 → flopscope_server-0.4.3}/uv.lock +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flopscope-server
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Backend server for flopscope client-server architecture
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
- Requires-Dist: flopscope==0.4.1
7
+ Requires-Dist: flopscope==0.4.3
8
8
  Requires-Dist: msgpack>=1.0.0
9
9
  Requires-Dist: numpy<2.5.0,>=2.0.0
10
10
  Requires-Dist: pyzmq>=26.0.0
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "flopscope-server"
7
- version = "0.4.1"
7
+ version = "0.4.3"
8
8
  description = "Backend server for flopscope client-server architecture"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "MIT"
12
12
  dependencies = [
13
- "flopscope==0.4.1",
13
+ "flopscope==0.4.3",
14
14
  "numpy>=2.0.0,<2.5.0",
15
15
  "pyzmq>=26.0.0",
16
16
  "msgpack>=1.0.0",
@@ -1,3 +1,3 @@
1
1
  """Flopscope backend server — executes numpy operations on behalf of remote clients."""
2
2
 
3
- __version__ = "0.4.1"
3
+ __version__ = "0.4.3"
@@ -130,6 +130,10 @@ def validate_request(msg: dict) -> None:
130
130
  If the op name is not in :data:`WHITELIST`.
131
131
  """
132
132
  op = msg.get("op", "")
133
+ # Generator.<method> ops are dispatched to a server-side RNG handle and are
134
+ # method-whitelisted in the request handler (_ALLOWED_GEN_METHODS).
135
+ if op.startswith("Generator."):
136
+ return
133
137
  if op not in WHITELIST:
134
138
  raise InvalidRequestError(f"unknown op: {op!r}")
135
139
 
@@ -14,6 +14,27 @@ from flopscope_server._session import Session
14
14
 
15
15
  _HANDLE_RE = re.compile(r"^a\d+$")
16
16
 
17
+ # Generator methods the server will dispatch to a server-side numpy Generator
18
+ # resolved from a ``{"__gen__": handle}`` argument. Array-returning samplers
19
+ # only (``shuffle`` mutates in place and is intentionally excluded).
20
+ _ALLOWED_GEN_METHODS = frozenset(
21
+ {
22
+ "uniform",
23
+ "standard_normal",
24
+ "normal",
25
+ "integers",
26
+ "random",
27
+ "standard_exponential",
28
+ "exponential",
29
+ "poisson",
30
+ "binomial",
31
+ "beta",
32
+ "gamma",
33
+ "choice",
34
+ "permutation",
35
+ }
36
+ )
37
+
17
38
 
18
39
  def _make_serializable(obj):
19
40
  """Convert a nested structure to be msgpack-safe (no numpy types)."""
@@ -28,6 +49,28 @@ def _make_serializable(obj):
28
49
  return obj
29
50
 
30
51
 
52
+ _MSGPACK_SCALARS = (type(None), bool, int, float, str, bytes)
53
+
54
+
55
+ def _is_msgpack_native(obj) -> bool:
56
+ """True if *obj* is composed only of msgpack-encodable Python types.
57
+
58
+ ``_make_serializable`` flattens numpy types but passes unknown objects
59
+ through unchanged; this distinguishes a genuinely-encodable result from one
60
+ that would only fail later inside ``msgpack.packb``.
61
+ """
62
+ if isinstance(obj, _MSGPACK_SCALARS):
63
+ return True
64
+ if isinstance(obj, (list, tuple)):
65
+ return all(_is_msgpack_native(item) for item in obj)
66
+ if isinstance(obj, dict):
67
+ return all(
68
+ isinstance(k, (str, bytes, int)) and _is_msgpack_native(v)
69
+ for k, v in obj.items()
70
+ )
71
+ return False
72
+
73
+
31
74
  #: Maximum allowed array size in bytes (configurable via environment variable).
32
75
  MAX_ARRAY_BYTES = int(os.environ.get("FLOPSCOPE_MAX_ARRAY_BYTES", 100 * 1024 * 1024))
33
76
 
@@ -92,6 +135,12 @@ class RequestHandler:
92
135
  "error_type": "UnsupportedFunctionError",
93
136
  "message": str(e),
94
137
  }
138
+ except flops.UnsupportedReturnType as e:
139
+ return {
140
+ "status": "error",
141
+ "error_type": "UnsupportedReturnType",
142
+ "message": str(e),
143
+ }
95
144
  except (ValueError, TypeError) as e:
96
145
  return {
97
146
  "status": "error",
@@ -221,6 +270,24 @@ class RequestHandler:
221
270
  result = arr.astype(dtype)
222
271
  return self._pack_result(result)
223
272
 
273
+ # Generator method calls: op is "Generator.<method>" with the remote
274
+ # generator handle as the first arg. Resolve it and call the method
275
+ # server-side, so the RNG state lives + advances on the server (the
276
+ # stream stays deterministic per seed and FLOP-counted).
277
+ if op.startswith("Generator."):
278
+ method = op[len("Generator.") :]
279
+ if method not in _ALLOWED_GEN_METHODS:
280
+ return {
281
+ "status": "error",
282
+ "error_type": "UnsupportedFunctionError",
283
+ "message": f"Generator.{method} is not supported by the flopscope server",
284
+ }
285
+ gen = self._resolve_arg(raw_args[0])
286
+ rest = [self._resolve_arg(a) for a in raw_args[1:]]
287
+ resolved_kwargs = {k: self._resolve_arg(v) for k, v in kwargs.items()}
288
+ result = getattr(gen, method)(*rest, **resolved_kwargs)
289
+ return self._pack_result(result)
290
+
224
291
  func = _get_flopscope_func(op)
225
292
  resolved_args = [self._resolve_arg(a) for a in raw_args]
226
293
  resolved_kwargs = {k: self._resolve_arg(v) for k, v in kwargs.items()}
@@ -247,6 +314,13 @@ class RequestHandler:
247
314
  if isinstance(handle, bytes):
248
315
  handle = handle.decode("utf-8")
249
316
  return self._session.get_array(handle)
317
+ gen_handle = arg.get("__gen__")
318
+ if gen_handle is None:
319
+ gen_handle = arg.get(b"__gen__")
320
+ if gen_handle is not None:
321
+ if isinstance(gen_handle, bytes):
322
+ gen_handle = gen_handle.decode("utf-8")
323
+ return self._session.get_generator(gen_handle)
250
324
  # SymmetryGroup wire format
251
325
  pg_data = arg.get("__symmetry_group__") or arg.get(b"__symmetry_group__")
252
326
  if pg_data is not None:
@@ -370,16 +444,23 @@ class RequestHandler:
370
444
  "result": {"value": str(result), "dtype": "str"},
371
445
  "budget": budget,
372
446
  }
373
- # Fallback: try to make it serializable
374
- try:
375
- serializable = _make_serializable(result)
447
+ if isinstance(result, np.random.Generator):
448
+ handle = self._session.store_generator(result)
449
+ return {"status": "ok", "result": {"gen_id": handle}, "budget": budget}
450
+
451
+ # Fallback: flatten nested numpy structures to JSON-safe values. If the
452
+ # result still isn't msgpack-native, fail loudly + attributably rather
453
+ # than silently str()-degrading (which previously surfaced downstream as
454
+ # an opaque "failed to serialize response" error). The registry-driven
455
+ # conformance test (tests/test_registry_conformance.py) catches any op
456
+ # whose return type lands here.
457
+ serializable = _make_serializable(result)
458
+ if _is_msgpack_native(serializable):
376
459
  return {"status": "ok", "result": {"value": serializable}, "budget": budget}
377
- except Exception:
378
- return {
379
- "status": "ok",
380
- "result": {"value": str(result), "dtype": "str"},
381
- "budget": budget,
382
- }
460
+ raise flops.UnsupportedReturnType(
461
+ f"{type(result).__name__} is not serializable across the "
462
+ f"client/server boundary"
463
+ )
383
464
 
384
465
 
385
466
  # ---------------------------------------------------------------------------
@@ -24,6 +24,8 @@ class Session:
24
24
 
25
25
  def __init__(self, flop_budget: int, flop_multiplier: float = 1.0) -> None:
26
26
  self._store = ArrayStore()
27
+ self._generators: dict[str, Any] = {}
28
+ self._gen_counter: int = 0
27
29
  self._comms_tracker = CommsTracker()
28
30
  self._budget_ctx = flops.BudgetContext(
29
31
  flop_budget=flop_budget,
@@ -109,6 +111,29 @@ class Session:
109
111
  """
110
112
  self._store.free(handles)
111
113
 
114
+ # ------------------------------------------------------------------
115
+ # Generator operations (server-side RNG handles)
116
+ # ------------------------------------------------------------------
117
+
118
+ def store_generator(self, gen: Any) -> str:
119
+ """Store an RNG ``Generator`` and return its handle ID (``g0``, ``g1`` …)."""
120
+ handle = f"g{self._gen_counter}"
121
+ self._generators[handle] = gen
122
+ self._gen_counter += 1
123
+ return handle
124
+
125
+ def get_generator(self, handle: str) -> Any:
126
+ """Return the ``Generator`` for *handle*.
127
+
128
+ Raises
129
+ ------
130
+ KeyError
131
+ If *handle* is not a known generator handle.
132
+ """
133
+ if handle not in self._generators:
134
+ raise KeyError(f"Generator handle {handle!r} not found in store")
135
+ return self._generators[handle]
136
+
112
137
  # ------------------------------------------------------------------
113
138
  # Budget
114
139
  # ------------------------------------------------------------------
@@ -162,6 +187,7 @@ class Session:
162
187
  budget_summary = self._budget_ctx.summary(by_namespace=show_namespaces)
163
188
  comms_summary = self._comms_tracker.summary()
164
189
  self._store.clear()
190
+ self._generators.clear()
165
191
  self._is_open = False
166
192
 
167
193
  return {