vortexpy 4.1.7__tar.gz → 4.2.1__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 (113) hide show
  1. {vortexpy-4.1.7 → vortexpy-4.2.1}/PKG-INFO +1 -1
  2. {vortexpy-4.1.7 → vortexpy-4.2.1}/pyproject.toml +1 -1
  3. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/Jsonable.py +4 -2
  4. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/SerialiseUtil.py +6 -5
  5. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/SerialiseUtil_test.py +18 -1
  6. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/TupleRestful.py +85 -0
  7. vortexpy-4.1.7/vortex/TupleRestfulTest.py → vortexpy-4.2.1/vortex/TupleRestful_test.py +54 -0
  8. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/__init__.py +1 -1
  9. vortexpy-4.2.1/vortex/twisted_runtime_metrics.py +297 -0
  10. vortexpy-4.2.1/vortex/twisted_runtime_metrics_test.py +134 -0
  11. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortexpy.egg-info/PKG-INFO +1 -1
  12. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortexpy.egg-info/SOURCES.txt +3 -1
  13. {vortexpy-4.1.7 → vortexpy-4.2.1}/README.md +0 -0
  14. {vortexpy-4.1.7 → vortexpy-4.2.1}/setup.cfg +0 -0
  15. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/DataWrapTuple.py +0 -0
  16. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/DeferUtil.py +0 -0
  17. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/Payload.py +0 -0
  18. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadEndpoint.py +0 -0
  19. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadEndpointTest.py +0 -0
  20. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadEnvelope.py +0 -0
  21. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadFilterKeys.py +0 -0
  22. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadIO.py +0 -0
  23. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadPriority.py +0 -0
  24. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadResponse.py +0 -0
  25. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadResponseTest.py +0 -0
  26. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadResponse_test.py +0 -0
  27. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadTest.py +0 -0
  28. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PerformTestActionTuple.py +0 -0
  29. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/TestTuple.py +0 -0
  30. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/Tuple.py +0 -0
  31. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/TupleAction.py +0 -0
  32. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/TupleDataForTest.py +0 -0
  33. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/TuplePerfTest.py +0 -0
  34. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/TuplePolymorphicTupleTypeFieldArgTest.py +0 -0
  35. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/TupleSelector.py +0 -0
  36. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/TupleTest.py +0 -0
  37. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexABC.py +0 -0
  38. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexAccessControlContextABC.py +0 -0
  39. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexAuthenticatorABC.py +0 -0
  40. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexClientHttp.py +0 -0
  41. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexClientHttpTest.py +0 -0
  42. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexClientTcp.py +0 -0
  43. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexClientWebsocketFactory.py +0 -0
  44. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexConnectionABC.py +0 -0
  45. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexEndpointPermission.py +0 -0
  46. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexFactory.py +0 -0
  47. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexPayloadProtocol.py +0 -0
  48. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexPermission.py +0 -0
  49. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexPermissionError.py +0 -0
  50. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexServer.py +0 -0
  51. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexServerConnection.py +0 -0
  52. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexServerHttpResource.py +0 -0
  53. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexServerTcp.py +0 -0
  54. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexServerWebsocket.py +0 -0
  55. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexSession.py +0 -0
  56. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexTcpMemoryLeakPayloadTest.py +0 -0
  57. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexTcpMemoryLeakRawTest.py +0 -0
  58. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexTest.py +0 -0
  59. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexUtil.py +0 -0
  60. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexWritePushProducer.py +0 -0
  61. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/data_loader/TupleDataLiveLockManager.py +0 -0
  62. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/data_loader/TupleDataLoader.py +0 -0
  63. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/data_loader/TupleDataLoaderDelegate.py +0 -0
  64. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/data_loader/TupleDataLoaderTupleABC.py +0 -0
  65. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/data_loader/TupleDataLoaderTuples.py +0 -0
  66. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/data_loader/__init__.py +0 -0
  67. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/event_bus/TupleChangeEventABC.py +0 -0
  68. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/event_bus/TupleChangeEventBus.py +0 -0
  69. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/event_bus/TupleChangeEventBusObserverABC.py +0 -0
  70. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/event_bus/__init__.py +0 -0
  71. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleActionProcessor.py +0 -0
  72. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleActionProcessorProxy.py +0 -0
  73. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleActionProcessorTestHandler.py +0 -0
  74. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleDataAccessGranterABC.py +0 -0
  75. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleDataActionClient.py +0 -0
  76. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleDataObservableCache.py +0 -0
  77. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleDataObservableHandler.py +0 -0
  78. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleDataObservableProxyHandler.py +0 -0
  79. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleDataObservableTestHandler.py +0 -0
  80. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleDataObserverClient.py +0 -0
  81. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleDataProviderABC.py +0 -0
  82. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/VortexJSTupleLoaderTestHandler.py +0 -0
  83. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/__init__.py +0 -0
  84. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/mypy_plugin.py +0 -0
  85. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/py.typed +0 -0
  86. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/restful/GzippedDataHttpClient.py +0 -0
  87. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/restful/GzippedDataHttpClientTest.py +0 -0
  88. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/restful/GzippedPayloadHttpClient.py +0 -0
  89. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/restful/RestfulHttpClient.py +0 -0
  90. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/restful/RestfulHttpClientTest.py +0 -0
  91. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/restful/RestfulResource.py +0 -0
  92. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/restful/RestfulResourceTest.py +0 -0
  93. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/restful/__init__.py +0 -0
  94. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/rpc/RPC.py +0 -0
  95. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/rpc/RPCTest.py +0 -0
  96. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/rpc/RPC_test.py +0 -0
  97. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/rpc/__init__.py +0 -0
  98. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/sqla_orm/OrmCrudHandler.py +0 -0
  99. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/sqla_orm/TupleProviderForDb.py +0 -0
  100. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/sqla_orm/VortexTupleJson.py +0 -0
  101. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/sqla_orm/__init__.py +0 -0
  102. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/storage/TupleStorageSqlite.py +0 -0
  103. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/storage/__init__.py +0 -0
  104. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/tuple_json_schema/__init__.py +0 -0
  105. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/tuple_json_schema/constants.py +0 -0
  106. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/tuple_json_schema/json_schema_node_tuple.py +0 -0
  107. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/tuple_json_schema/tuple_json_schema_builder.py +0 -0
  108. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/tuple_json_schema/tuple_json_schema_test.py +0 -0
  109. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/tuple_json_schema/tuple_json_schema_test_tuples.py +0 -0
  110. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortexpy.egg-info/dependency_links.txt +0 -0
  111. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortexpy.egg-info/entry_points.txt +0 -0
  112. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortexpy.egg-info/requires.txt +0 -0
  113. {vortexpy-4.1.7 → vortexpy-4.2.1}/vortexpy.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vortexpy
3
- Version: 4.1.7
3
+ Version: 4.2.1
4
4
  Summary: Flying Ion's observable, routable, data serialisation and transport code.
5
5
  Author-email: Flying Ion Pty Ltd <contact@flyingion.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vortexpy"
7
- version = "4.1.7"
7
+ version = "4.2.1"
8
8
  license = {text = "MIT"}
9
9
  authors = [{name = "Flying Ion Pty Ltd", email = "contact@flyingion.com"}]
10
10
  description = "Flying Ion's observable, routable, data serialisation and transport code."
@@ -107,9 +107,11 @@ class Jsonable(object):
107
107
  try:
108
108
  valueType = V_NULL if value is None else toRapuiType(value)
109
109
  except KeyError as e:
110
+ unsupportedType = e.args[0] if e.args else value.__class__
110
111
  raise KeyError(
111
- "%s field name is %s, type is %s", e, name, value.__class__
112
- )
112
+ "%r field name is %r, type is %r"
113
+ % (unsupportedType, name, value.__class__)
114
+ ) from e
113
115
 
114
116
  # Payloads and Tuples
115
117
  if self.__isRawJsonableField(name):
@@ -268,11 +268,12 @@ def toRapuiType(value):
268
268
  if isinstance(value, PayloadEnvelope):
269
269
  return T_RAPUI_PAYLOAD_ENVELOPE
270
270
 
271
- if isinstance(value, enum.StrEnum):
272
- return T_STR
273
-
274
- if isinstance(value, enum.IntEnum):
275
- return T_INT
271
+ if isinstance(value, enum.Enum):
272
+ if isinstance(value, str):
273
+ return T_STR
274
+ if isinstance(value, int):
275
+ return T_INT
276
+ raise KeyError(value.__class__)
276
277
 
277
278
  valueClass = value.__class__
278
279
 
@@ -42,9 +42,11 @@ class SerialiseUtilTest(unittest.TestCase):
42
42
  self.assertEqual(toRapuiType(SampleIntEnumE.Two), T_INT)
43
43
 
44
44
  def testToRapuiTypePlainEnumMemberRaises(self) -> None:
45
- with self.assertRaises(KeyError):
45
+ with self.assertRaises(KeyError) as errorInfo:
46
46
  toRapuiType(SamplePlainEnumE.Alpha)
47
47
 
48
+ self.assertEqual(errorInfo.exception.args, (SamplePlainEnumE,))
49
+
48
50
  def testToStrStrEnumMember(self) -> None:
49
51
  self.assertEqual(toStr(SampleStrEnumE.CrewDatum), "crew-datum")
50
52
 
@@ -104,3 +106,18 @@ class SerialiseUtilTest(unittest.TestCase):
104
106
  restored = TupleSelector.fromJsonStr(original.toJsonStr())
105
107
  self.assertEqual(restored.selector["priority"], 1)
106
108
  self.assertIsInstance(restored.selector["priority"], int)
109
+
110
+ def testTupleSelectorUnsupportedEnumFieldNameInError(self) -> None:
111
+ selector = TupleSelector(
112
+ "DashboardDataTimespanUpdatedTupleSelector",
113
+ dict(objectType=SamplePlainEnumE.Alpha),
114
+ )
115
+
116
+ with self.assertRaises(KeyError) as errorInfo:
117
+ selector.toJsonStr()
118
+
119
+ self.assertEqual(
120
+ errorInfo.exception.args[0],
121
+ "%r field name is %r, type is %r"
122
+ % (SamplePlainEnumE, "objectType", SamplePlainEnumE),
123
+ )
@@ -14,6 +14,7 @@ Implementation module; public interface remains on vortex.Tuple.
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import enum
17
18
  import re
18
19
  from dataclasses import dataclass, field
19
20
  from datetime import datetime
@@ -263,6 +264,73 @@ class TupleRestfulConverter:
263
264
 
264
265
  return True
265
266
 
267
+ @classmethod
268
+ def _isStrEnumType(cls, enumType: type[enum.Enum]) -> bool:
269
+ return issubclass(enumType, str) and issubclass(enumType, enum.Enum)
270
+
271
+ @classmethod
272
+ def _isIntEnumType(cls, enumType: type[enum.Enum]) -> bool:
273
+ return (
274
+ issubclass(enumType, int)
275
+ and issubclass(enumType, enum.Enum)
276
+ and not issubclass(enumType, bool)
277
+ )
278
+
279
+ @classmethod
280
+ def _enumTypeFromHint(cls, typeHint: Any) -> type[enum.Enum] | None:
281
+ if isinstance(typeHint, type) and issubclass(typeHint, enum.Enum):
282
+ if cls._isStrEnumType(typeHint) or cls._isIntEnumType(typeHint):
283
+ return typeHint
284
+ return None
285
+
286
+ for unionArg in get_args(typeHint):
287
+ if unionArg is type(None):
288
+ continue
289
+ if isinstance(unionArg, type) and issubclass(unionArg, enum.Enum):
290
+ if cls._isStrEnumType(unionArg) or cls._isIntEnumType(unionArg):
291
+ return unionArg
292
+
293
+ return None
294
+
295
+ @classmethod
296
+ def _tryCoerceEnumValue(
297
+ cls,
298
+ field_path: str,
299
+ fieldName: str,
300
+ TupleClass: Type[Tuple],
301
+ typeHint: Any,
302
+ value: Any,
303
+ ) -> tuple[Any, TupleValidationError | None]:
304
+ if value is None:
305
+ return value, None
306
+
307
+ enumType = cls._enumTypeFromHint(typeHint)
308
+ if enumType is None:
309
+ return value, None
310
+
311
+ if isinstance(value, enumType):
312
+ return value, None
313
+
314
+ try:
315
+ if cls._isStrEnumType(enumType) and isinstance(value, str):
316
+ return enumType(value), None
317
+
318
+ if (
319
+ cls._isIntEnumType(enumType)
320
+ and isinstance(value, int)
321
+ and not isinstance(value, bool)
322
+ ):
323
+ return enumType(value), None
324
+ except ValueError:
325
+ return value, cls._makeError(
326
+ field_path,
327
+ f"parsing {TupleClass}, field {fieldName}"
328
+ f" is not a valid {enumType.__name__} value, got {value!r}",
329
+ ValueError,
330
+ )
331
+
332
+ return value, None
333
+
266
334
  @classmethod
267
335
  def _nestedTupleTypeFromHint(cls, typeHint: Any) -> Any | None:
268
336
  """Return nested tuple class for a field hint, including ``X | None`` unions."""
@@ -373,6 +441,16 @@ class TupleRestfulConverter:
373
441
  else:
374
442
  for index, item in enumerate(value):
375
443
  item_path = f"{field_path}[{index}]"
444
+ item, enumError = cls._tryCoerceEnumValue(
445
+ item_path,
446
+ fieldName,
447
+ TupleClass,
448
+ listItemTypeHint,
449
+ item,
450
+ )
451
+ if enumError:
452
+ errors.append(enumError)
453
+ continue
376
454
  itemError = cls._checkType(
377
455
  TupleClass,
378
456
  item_path,
@@ -532,6 +610,13 @@ class TupleRestfulConverter:
532
610
  ):
533
611
  value = datetime.strptime(value, ISO8601)
534
612
 
613
+ value, enumError = cls._tryCoerceEnumValue(
614
+ field_path, fieldName, TupleClass, typeHint, value
615
+ )
616
+ if enumError:
617
+ errors.append(enumError)
618
+ return _ConvertResult(_UNSET, errors)
619
+
535
620
  typeError = cls._checkType(
536
621
  TupleClass, field_path, fieldName, typeHint, value, validator
537
622
  )
@@ -8,6 +8,7 @@
8
8
 
9
9
  import json
10
10
  from datetime import datetime
11
+ from enum import IntEnum, StrEnum
11
12
  from typing import Dict, Annotated
12
13
  from typing import List
13
14
  from typing import Union
@@ -44,6 +45,23 @@ class RestfulTestTuple(Tuple):
44
45
  aDictOfSubTuples: Dict[str, "RestfulSubTestTuple"] = TupleField()
45
46
 
46
47
 
48
+ class RestfulObjectTypeE(StrEnum):
49
+ EVENT = "event"
50
+
51
+
52
+ class RestfulPriorityE(IntEnum):
53
+ HIGH = 1
54
+
55
+
56
+ @addTupleType
57
+ class RestfulEnumTestTuple(Tuple):
58
+ __tupleType__ = "flyingion.vortex.RestfulEnumTestTuple"
59
+ objectType: RestfulObjectTypeE = TupleField()
60
+ optionalObjectType: RestfulObjectTypeE | None = TupleField()
61
+ priority: RestfulPriorityE = TupleField()
62
+ priorities: List[RestfulPriorityE] = TupleField()
63
+
64
+
47
65
  @addTupleType
48
66
  class RestfulOptionalDatetimeTestTuple(Tuple):
49
67
  __tupleType__ = "flyingion.vortex.RestfulOptionalDatetimeTestTuple"
@@ -166,6 +184,26 @@ class TupleRestfulTest(unittest.TestCase):
166
184
  self.assertEqual(deserialisedTuple.requiredDate, origTuple.requiredDate)
167
185
  self.assertEqual(deserialisedTuple.optionalDate, origTuple.optionalDate)
168
186
 
187
+ def testStrEnumAndIntEnumRoundTrip(self):
188
+ origTuple = RestfulEnumTestTuple()
189
+ origTuple.objectType = RestfulObjectTypeE.EVENT
190
+ origTuple.optionalObjectType = RestfulObjectTypeE.EVENT
191
+ origTuple.priority = RestfulPriorityE.HIGH
192
+ origTuple.priorities = [RestfulPriorityE.HIGH]
193
+
194
+ jsonDict = origTuple.tupleToRestfulJsonDict()
195
+ jsonDict = json.loads(json.dumps(jsonDict))
196
+ deserialisedTuple = Tuple.restfulJsonDictToTupleWithValidation(
197
+ jsonDict, RestfulEnumTestTuple
198
+ )
199
+
200
+ self.assertEqual(deserialisedTuple.objectType, RestfulObjectTypeE.EVENT)
201
+ self.assertEqual(
202
+ deserialisedTuple.optionalObjectType, RestfulObjectTypeE.EVENT
203
+ )
204
+ self.assertEqual(deserialisedTuple.priority, RestfulPriorityE.HIGH)
205
+ self.assertEqual(deserialisedTuple.priorities, [RestfulPriorityE.HIGH])
206
+
169
207
  def testOptionalDatetimeNoneRoundTrip(self):
170
208
  origTuple = RestfulOptionalDatetimeTestTuple()
171
209
  origTuple.requiredDate = datetime(
@@ -258,6 +296,22 @@ class TupleRestfulCollectingTest(unittest.TestCase):
258
296
  result.errors[0].message.startswith("At aSubTuple.subInt:")
259
297
  )
260
298
 
299
+ def testInvalidStrEnumValueCollectsError(self):
300
+ jsonDict = {
301
+ "objectType": "not-a-valid-object-type",
302
+ "optionalObjectType": None,
303
+ "priority": 1,
304
+ "priorities": [],
305
+ }
306
+
307
+ result = Tuple.restfulJsonDictToTupleWithValidationResult(
308
+ jsonDict, RestfulEnumTestTuple
309
+ )
310
+
311
+ self.assertEqual(len(result.errors), 1)
312
+ self.assertEqual(result.errors[0].field_path, "objectType")
313
+ self.assertEqual(result.errors[0].exc_type, ValueError)
314
+
261
315
  def testStrictWrapperRaisesFirstCollectingError(self):
262
316
  jsonDict = _validRestfulJsonDict()
263
317
  jsonDict["aInt"] = "not-an-int"
@@ -6,4 +6,4 @@
6
6
  # Website : https://flyingion.com
7
7
  # Support : contact@flyingion.com
8
8
 
9
- __version__ = '4.1.7'
9
+ __version__ = '4.2.1'
@@ -0,0 +1,297 @@
1
+ # Copyright (c) 2016-2026 Flying Ion Pty Ltd
2
+ # See LICENSE for the full license text.
3
+
4
+ """Low-overhead Twisted reactor and thread-pool runtime metrics."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import os
10
+ import sys
11
+ import threading
12
+ import time
13
+ import traceback
14
+ from collections.abc import Callable
15
+ from typing import TYPE_CHECKING, Any, TypeVar, cast
16
+
17
+ from twisted.internet.defer import Deferred
18
+
19
+ from vortex.Tuple import Tuple, TupleField, addTupleType
20
+
21
+ if TYPE_CHECKING:
22
+ from twisted.internet.task import LoopingCall
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ REACTOR_LAG_TICK_SECONDS = 0.05
27
+ STACK_CAPTURE_THRESHOLD_MS = 50.0
28
+
29
+ _TWISTED_RUNTIME_METRICS_TUPLE_PREFIX = "vortex.twisted_runtime_metrics."
30
+
31
+ _F = TypeVar("_F", bound=Callable[..., Any])
32
+
33
+ # TupleField defaults are typed as TupleField at class body; runtime values match annotations.
34
+ # pyright: reportAssignmentType=false
35
+
36
+
37
+ @addTupleType
38
+ class TwistedRuntimeMetricsSnapshot(Tuple):
39
+ __tupleType__ = (
40
+ _TWISTED_RUNTIME_METRICS_TUPLE_PREFIX + "TwistedRuntimeMetricsSnapshot"
41
+ )
42
+
43
+ threadPoolBusy: int = TupleField()
44
+ threadPoolQueued: int = TupleField()
45
+ threadPoolMax: int = TupleField()
46
+ threadedCallCount: int = TupleField()
47
+ longestThreadedMethodName: str | None = TupleField(None)
48
+ longestThreadedMethodMs: float = TupleField()
49
+ longestThreadedMethodFrame: str | None = TupleField(None)
50
+ longestReactorBlockMs: float = TupleField()
51
+ longestReactorBlockFrame: str | None = TupleField(None)
52
+ loopingCallExceptionCount: int = TupleField()
53
+ loopingCallLongestExceptionMethod: str | None = TupleField(None)
54
+ loopingCallLastExceptionMethod: str | None = TupleField(None)
55
+ loopingCallLastExceptionFrame: str | None = TupleField(None)
56
+ windowSeconds: int = TupleField()
57
+
58
+
59
+ class TwistedRuntimeMetrics:
60
+ _instance: TwistedRuntimeMetrics | None = None
61
+ _instanceLock = threading.Lock()
62
+
63
+ def __init__(self) -> None:
64
+ self._metricsLock = threading.Lock()
65
+ self._started = False
66
+ self._loop: LoopingCall | None = None
67
+ self._nextExpectedMonotonic = 0.0
68
+ self._threadedCallCount = 0
69
+ self._longestThreadedMethodName: str | None = None
70
+ self._longestThreadedMethodMs = 0.0
71
+ self._longestThreadedMethodFrame: str | None = None
72
+ self._longestReactorBlockMs = 0.0
73
+ self._longestReactorBlockFrame: str | None = None
74
+ self._loopingCallExceptionCount = 0
75
+ self._loopingCallExceptionCountByMethod: dict[str, int] = {}
76
+ self._loopingCallLongestExceptionMethod: str | None = None
77
+ self._loopingCallLastExceptionMethod: str | None = None
78
+ self._loopingCallLastExceptionFrame: str | None = None
79
+ self._windowStartedMonotonic = time.monotonic()
80
+
81
+ @classmethod
82
+ def _collector(cls) -> TwistedRuntimeMetrics:
83
+ if cls._instance is not None:
84
+ return cls._instance
85
+ with cls._instanceLock:
86
+ if cls._instance is None:
87
+ cls._instance = cls()
88
+ return cls._instance
89
+
90
+ @classmethod
91
+ def start(cls, reactorArg: Any) -> None:
92
+ cls._collector()._start(reactorArg)
93
+
94
+ @classmethod
95
+ def snapshotAndResetWindow(cls) -> TwistedRuntimeMetricsSnapshot:
96
+ return cls._collector()._snapshotAndResetWindow()
97
+
98
+ def _start(self, reactorArg: Any) -> None:
99
+ if self._started:
100
+ return
101
+ from twisted.internet.task import LoopingCall
102
+
103
+ self._windowStartedMonotonic = time.monotonic()
104
+ self._nextExpectedMonotonic = time.monotonic() + REACTOR_LAG_TICK_SECONDS
105
+ self._loop = LoopingCall(
106
+ vortexLoopingCallCatchWithMetrics(logger)(self._tickReactorLag)
107
+ )
108
+ self._loop.start(REACTOR_LAG_TICK_SECONDS, now=True)
109
+ self._started = True
110
+ logger.info(
111
+ "TwistedRuntimeMetrics started (lag tick=%ss)",
112
+ REACTOR_LAG_TICK_SECONDS,
113
+ )
114
+
115
+ def _recordThreadedCall(
116
+ self,
117
+ methodQualname: str,
118
+ durationMs: float,
119
+ methodFrame: str | None = None,
120
+ ) -> None:
121
+ with self._metricsLock:
122
+ self._threadedCallCount += 1
123
+ if durationMs <= self._longestThreadedMethodMs:
124
+ return
125
+ self._longestThreadedMethodMs = durationMs
126
+ self._longestThreadedMethodName = methodQualname
127
+ self._longestThreadedMethodFrame = methodFrame
128
+
129
+ def _recordLoopingCallException(
130
+ self,
131
+ methodQualname: str,
132
+ exceptionFrame: str | None = None,
133
+ ) -> None:
134
+ with self._metricsLock:
135
+ self._loopingCallExceptionCount += 1
136
+ methodCount = (
137
+ self._loopingCallExceptionCountByMethod.get(methodQualname, 0) + 1
138
+ )
139
+ self._loopingCallExceptionCountByMethod[methodQualname] = methodCount
140
+ self._loopingCallLastExceptionMethod = methodQualname
141
+ self._loopingCallLastExceptionFrame = exceptionFrame
142
+ longestCount = 0
143
+ if self._loopingCallLongestExceptionMethod is not None:
144
+ longestCount = self._loopingCallExceptionCountByMethod[
145
+ self._loopingCallLongestExceptionMethod
146
+ ]
147
+ if (
148
+ self._loopingCallLongestExceptionMethod is None
149
+ or methodCount > longestCount
150
+ ):
151
+ self._loopingCallLongestExceptionMethod = methodQualname
152
+
153
+ def _snapshotAndResetWindow(self) -> TwistedRuntimeMetricsSnapshot:
154
+ from twisted.internet import reactor
155
+
156
+ pool = reactor.getThreadPool() # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
157
+ threadPoolBusy = len(pool.working)
158
+ threadPoolQueued = len(pool.waiters)
159
+
160
+ with self._metricsLock:
161
+ elapsedSeconds = time.monotonic() - self._windowStartedMonotonic
162
+ windowSeconds = int(max(1, round(elapsedSeconds)))
163
+ snapshot = TwistedRuntimeMetricsSnapshot(
164
+ threadPoolBusy=threadPoolBusy,
165
+ threadPoolQueued=threadPoolQueued,
166
+ threadPoolMax=pool.max,
167
+ threadedCallCount=self._threadedCallCount,
168
+ longestThreadedMethodName=self._longestThreadedMethodName,
169
+ longestThreadedMethodMs=self._longestThreadedMethodMs,
170
+ longestThreadedMethodFrame=self._longestThreadedMethodFrame,
171
+ longestReactorBlockMs=self._longestReactorBlockMs,
172
+ longestReactorBlockFrame=self._longestReactorBlockFrame,
173
+ loopingCallExceptionCount=self._loopingCallExceptionCount,
174
+ loopingCallLongestExceptionMethod=(
175
+ self._loopingCallLongestExceptionMethod
176
+ ),
177
+ loopingCallLastExceptionMethod=(self._loopingCallLastExceptionMethod),
178
+ loopingCallLastExceptionFrame=(self._loopingCallLastExceptionFrame),
179
+ windowSeconds=windowSeconds,
180
+ )
181
+ self._resetWindowCountersLocked()
182
+ return snapshot
183
+
184
+ def _tickReactorLag(self) -> None:
185
+ now = time.monotonic()
186
+ lagMs = (now - self._nextExpectedMonotonic) * 1000.0
187
+ self._nextExpectedMonotonic = now + REACTOR_LAG_TICK_SECONDS
188
+ if lagMs <= 0:
189
+ return
190
+ with self._metricsLock:
191
+ if lagMs <= self._longestReactorBlockMs:
192
+ return
193
+ self._longestReactorBlockMs = lagMs
194
+ if lagMs >= STACK_CAPTURE_THRESHOLD_MS:
195
+ self._longestReactorBlockFrame = self._captureMainThreadFrame()
196
+
197
+ def _resetWindowCountersLocked(self) -> None:
198
+ self._windowStartedMonotonic = time.monotonic()
199
+ self._threadedCallCount = 0
200
+ self._longestThreadedMethodName = None
201
+ self._longestThreadedMethodMs = 0.0
202
+ self._longestThreadedMethodFrame = None
203
+ self._longestReactorBlockMs = 0.0
204
+ self._longestReactorBlockFrame = None
205
+ self._loopingCallExceptionCount = 0
206
+ self._loopingCallExceptionCountByMethod = {}
207
+ self._loopingCallLongestExceptionMethod = None
208
+ self._loopingCallLastExceptionMethod = None
209
+ self._loopingCallLastExceptionFrame = None
210
+
211
+ @staticmethod
212
+ def _utilSkipInternalMetricsFrame(filename: str) -> bool:
213
+ basename = os.path.basename(filename)
214
+ return basename in ("twisted_runtime_metrics.py", "DeferUtil.py")
215
+
216
+ @staticmethod
217
+ def _captureMainThreadFrame() -> str | None:
218
+ mainThreadIdent = threading.main_thread().ident
219
+ if mainThreadIdent is None:
220
+ return None
221
+ frame = sys._current_frames().get(mainThreadIdent)
222
+ if frame is None:
223
+ return None
224
+ for summary in reversed(traceback.extract_stack(frame)):
225
+ if TwistedRuntimeMetrics._utilSkipInternalMetricsFrame(summary.filename):
226
+ continue
227
+ return f"{summary.filename}:{summary.name}:{summary.lineno}"
228
+ return None
229
+
230
+ @staticmethod
231
+ def _captureWorkerThreadFrame() -> str | None:
232
+ for summary in reversed(traceback.extract_stack()):
233
+ if TwistedRuntimeMetrics._utilSkipInternalMetricsFrame(summary.filename):
234
+ continue
235
+ return f"{summary.filename}:{summary.name}:{summary.lineno}"
236
+ return None
237
+
238
+ @staticmethod
239
+ def _captureExceptionFrame() -> str | None:
240
+ _excType, _excValue, excTraceback = sys.exc_info()
241
+ if excTraceback is None:
242
+ return None
243
+ for summary in reversed(traceback.extract_tb(excTraceback)):
244
+ if TwistedRuntimeMetrics._utilSkipInternalMetricsFrame(summary.filename):
245
+ continue
246
+ return f"{summary.filename}:{summary.name}:{summary.lineno}"
247
+ return None
248
+
249
+
250
+ def vortexLoopingCallCatchWithMetrics(
251
+ loggerArg: logging.Logger,
252
+ ) -> Callable[[_F], _F]:
253
+ """LoopingCall safety wrapper: log, count, do not re-raise."""
254
+
255
+ def wrapper(funcToWrap: _F) -> _F:
256
+ methodQualname = funcToWrap.__qualname__
257
+
258
+ def func(*args: Any, **kwargs: Any) -> Any:
259
+ try:
260
+ result = funcToWrap(*args, **kwargs)
261
+ except Exception:
262
+ exceptionFrame = TwistedRuntimeMetrics._captureExceptionFrame()
263
+ loggerArg.exception(
264
+ "LoopingCall method %s raised; continuing loop",
265
+ methodQualname,
266
+ )
267
+ TwistedRuntimeMetrics._collector()._recordLoopingCallException(
268
+ methodQualname,
269
+ exceptionFrame,
270
+ )
271
+ return None
272
+
273
+ if isinstance(result, Deferred):
274
+ _ = result.addErrback(
275
+ _logicLoopingCallDeferredFailure,
276
+ loggerArg,
277
+ methodQualname,
278
+ )
279
+ return result
280
+
281
+ return cast(_F, func)
282
+
283
+ return wrapper
284
+
285
+
286
+ def _logicLoopingCallDeferredFailure(
287
+ failure: Any,
288
+ loggerArg: logging.Logger,
289
+ methodQualname: str,
290
+ ) -> None:
291
+ from vortex.DeferUtil import vortexLogFailure
292
+
293
+ _ = vortexLogFailure(failure, loggerArg, consumeError=True)
294
+ TwistedRuntimeMetrics._collector()._recordLoopingCallException(
295
+ methodQualname,
296
+ None,
297
+ )
@@ -0,0 +1,134 @@
1
+ # Copyright (c) 2016-2026 Flying Ion Pty Ltd
2
+ # See LICENSE for the full license text.
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ from collections.abc import Generator
8
+ from typing import Any
9
+
10
+ import pytest
11
+ from twisted.internet.defer import Deferred, fail
12
+
13
+ from vortex.twisted_runtime_metrics import (
14
+ TwistedRuntimeMetrics,
15
+ vortexLoopingCallCatchWithMetrics,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @vortexLoopingCallCatchWithMetrics(logger)
22
+ def failingTick() -> None:
23
+ raise RuntimeError("tick failed")
24
+
25
+
26
+ @vortexLoopingCallCatchWithMetrics(logger)
27
+ def failingDeferredTick() -> Deferred[Any]:
28
+ return fail(RuntimeError("deferred tick failed"))
29
+
30
+
31
+ def utilResetTwistedRuntimeMetricsSingletonForTests() -> None:
32
+ with TwistedRuntimeMetrics._instanceLock:
33
+ TwistedRuntimeMetrics._instance = None
34
+
35
+
36
+ @pytest.fixture(autouse=True)
37
+ def resetMetricsSingleton() -> Generator[None, None, None]:
38
+ utilResetTwistedRuntimeMetricsSingletonForTests()
39
+ yield
40
+ utilResetTwistedRuntimeMetricsSingletonForTests()
41
+
42
+
43
+ def testRecordThreadedCallTracksCountAndLongestMethod() -> None:
44
+ collector = TwistedRuntimeMetrics._collector()
45
+ collector._recordThreadedCall("slow.Method", 100.0)
46
+ collector._recordThreadedCall("slower.Method", 250.5, "slow.py:run:10")
47
+ snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
48
+ assert snapshot.threadedCallCount == 2, "both threaded calls must count in window"
49
+ assert snapshot.longestThreadedMethodName == "slower.Method", (
50
+ "longest name must be the slowest call"
51
+ )
52
+ assert snapshot.longestThreadedMethodMs == 250.5, (
53
+ "longest duration must match slowest call"
54
+ )
55
+ assert snapshot.longestThreadedMethodFrame == "slow.py:run:10", (
56
+ "slow-call frame must be preserved"
57
+ )
58
+ assert snapshot.windowSeconds >= 1, "snapshot must report elapsed window seconds"
59
+
60
+
61
+ def testSnapshotResetsWindowCounters() -> None:
62
+ TwistedRuntimeMetrics._collector()._recordThreadedCall("one.Method", 50.0)
63
+ firstSnapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
64
+ secondSnapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
65
+ assert firstSnapshot.threadedCallCount == 1, "first snapshot includes recorded call"
66
+ assert firstSnapshot.longestThreadedMethodMs == 50.0, (
67
+ "first snapshot longest ms must match recorded call"
68
+ )
69
+ assert secondSnapshot.threadedCallCount == 0, "window counters reset after snapshot"
70
+ assert secondSnapshot.longestThreadedMethodMs == 0.0, (
71
+ "longest ms resets to zero after snapshot"
72
+ )
73
+
74
+
75
+ def testLoopingCallCatchWithMetricsRecordsSyncException() -> None:
76
+ failingTick()
77
+ snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
78
+ assert snapshot.loopingCallExceptionCount == 1, (
79
+ "sync tick exception must increment count"
80
+ )
81
+ assert snapshot.loopingCallLongestExceptionMethod == "failingTick", (
82
+ "longest exception method must be failing tick"
83
+ )
84
+ assert snapshot.loopingCallLastExceptionMethod == "failingTick", (
85
+ "last exception method must be failing tick"
86
+ )
87
+ assert snapshot.loopingCallLastExceptionFrame is not None, (
88
+ "sync exception must capture a frame"
89
+ )
90
+
91
+
92
+ def testLoopingCallLongestExceptionMethodUsesHighestCount() -> None:
93
+ collector = TwistedRuntimeMetrics._collector()
94
+ collector._recordLoopingCallException("noisyTick", "a.py:1")
95
+ collector._recordLoopingCallException("noisyTick", "a.py:2")
96
+ collector._recordLoopingCallException("quietTick", "b.py:1")
97
+ snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
98
+ assert snapshot.loopingCallExceptionCount == 3, "three exceptions must be counted"
99
+ assert snapshot.loopingCallLongestExceptionMethod == "noisyTick", (
100
+ "longest method is highest per-method exception count"
101
+ )
102
+ assert snapshot.loopingCallLastExceptionMethod == "quietTick", (
103
+ "last method is most recent exception"
104
+ )
105
+
106
+
107
+ def testTickReactorLagRecordsLongestBlock() -> None:
108
+ collector = TwistedRuntimeMetrics._collector()
109
+ collector._nextExpectedMonotonic = 0.0
110
+ collector._tickReactorLag()
111
+ assert collector._longestReactorBlockMs > 0.0, (
112
+ "late lag tick must record longestReactorBlockMs"
113
+ )
114
+ snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
115
+ assert snapshot.longestReactorBlockMs > 0.0, (
116
+ "snapshot must include recorded reactor block ms"
117
+ )
118
+
119
+
120
+ def testLoopingCallCatchWithMetricsRecordsDeferredFailure() -> None:
121
+ _ = failingDeferredTick()
122
+ snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
123
+ assert snapshot.loopingCallExceptionCount == 1, (
124
+ "deferred tick failure must increment exception count"
125
+ )
126
+ assert snapshot.loopingCallLongestExceptionMethod == "failingDeferredTick", (
127
+ "deferred failure must set longest exception method"
128
+ )
129
+ assert snapshot.loopingCallLastExceptionMethod == "failingDeferredTick", (
130
+ "deferred failure must set last exception method"
131
+ )
132
+ assert snapshot.loopingCallLastExceptionFrame is None, (
133
+ "deferred failures do not capture sync exception frames"
134
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vortexpy
3
- Version: 4.1.7
3
+ Version: 4.2.1
4
4
  Summary: Flying Ion's observable, routable, data serialisation and transport code.
5
5
  Author-email: Flying Ion Pty Ltd <contact@flyingion.com>
6
6
  License: MIT
@@ -24,7 +24,7 @@ vortex/TupleDataForTest.py
24
24
  vortex/TuplePerfTest.py
25
25
  vortex/TuplePolymorphicTupleTypeFieldArgTest.py
26
26
  vortex/TupleRestful.py
27
- vortex/TupleRestfulTest.py
27
+ vortex/TupleRestful_test.py
28
28
  vortex/TupleSelector.py
29
29
  vortex/TupleTest.py
30
30
  vortex/VortexABC.py
@@ -54,6 +54,8 @@ vortex/VortexWritePushProducer.py
54
54
  vortex/__init__.py
55
55
  vortex/mypy_plugin.py
56
56
  vortex/py.typed
57
+ vortex/twisted_runtime_metrics.py
58
+ vortex/twisted_runtime_metrics_test.py
57
59
  vortex/data_loader/TupleDataLiveLockManager.py
58
60
  vortex/data_loader/TupleDataLoader.py
59
61
  vortex/data_loader/TupleDataLoaderDelegate.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes