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.
- {vortexpy-4.1.7 → vortexpy-4.2.1}/PKG-INFO +1 -1
- {vortexpy-4.1.7 → vortexpy-4.2.1}/pyproject.toml +1 -1
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/Jsonable.py +4 -2
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/SerialiseUtil.py +6 -5
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/SerialiseUtil_test.py +18 -1
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/TupleRestful.py +85 -0
- vortexpy-4.1.7/vortex/TupleRestfulTest.py → vortexpy-4.2.1/vortex/TupleRestful_test.py +54 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/__init__.py +1 -1
- vortexpy-4.2.1/vortex/twisted_runtime_metrics.py +297 -0
- vortexpy-4.2.1/vortex/twisted_runtime_metrics_test.py +134 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortexpy.egg-info/PKG-INFO +1 -1
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortexpy.egg-info/SOURCES.txt +3 -1
- {vortexpy-4.1.7 → vortexpy-4.2.1}/README.md +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/setup.cfg +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/DataWrapTuple.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/DeferUtil.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/Payload.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadEndpoint.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadEndpointTest.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadEnvelope.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadFilterKeys.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadIO.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadPriority.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadResponse.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadResponseTest.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadResponse_test.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PayloadTest.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/PerformTestActionTuple.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/TestTuple.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/Tuple.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/TupleAction.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/TupleDataForTest.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/TuplePerfTest.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/TuplePolymorphicTupleTypeFieldArgTest.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/TupleSelector.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/TupleTest.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexABC.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexAccessControlContextABC.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexAuthenticatorABC.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexClientHttp.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexClientHttpTest.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexClientTcp.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexClientWebsocketFactory.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexConnectionABC.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexEndpointPermission.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexFactory.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexPayloadProtocol.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexPermission.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexPermissionError.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexServer.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexServerConnection.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexServerHttpResource.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexServerTcp.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexServerWebsocket.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexSession.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexTcpMemoryLeakPayloadTest.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexTcpMemoryLeakRawTest.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexTest.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexUtil.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/VortexWritePushProducer.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/data_loader/TupleDataLiveLockManager.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/data_loader/TupleDataLoader.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/data_loader/TupleDataLoaderDelegate.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/data_loader/TupleDataLoaderTupleABC.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/data_loader/TupleDataLoaderTuples.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/data_loader/__init__.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/event_bus/TupleChangeEventABC.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/event_bus/TupleChangeEventBus.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/event_bus/TupleChangeEventBusObserverABC.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/event_bus/__init__.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleActionProcessor.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleActionProcessorProxy.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleActionProcessorTestHandler.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleDataAccessGranterABC.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleDataActionClient.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleDataObservableCache.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleDataObservableHandler.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleDataObservableProxyHandler.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleDataObservableTestHandler.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleDataObserverClient.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/TupleDataProviderABC.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/VortexJSTupleLoaderTestHandler.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/handler/__init__.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/mypy_plugin.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/py.typed +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/restful/GzippedDataHttpClient.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/restful/GzippedDataHttpClientTest.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/restful/GzippedPayloadHttpClient.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/restful/RestfulHttpClient.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/restful/RestfulHttpClientTest.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/restful/RestfulResource.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/restful/RestfulResourceTest.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/restful/__init__.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/rpc/RPC.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/rpc/RPCTest.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/rpc/RPC_test.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/rpc/__init__.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/sqla_orm/OrmCrudHandler.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/sqla_orm/TupleProviderForDb.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/sqla_orm/VortexTupleJson.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/sqla_orm/__init__.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/storage/TupleStorageSqlite.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/storage/__init__.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/tuple_json_schema/__init__.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/tuple_json_schema/constants.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/tuple_json_schema/json_schema_node_tuple.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/tuple_json_schema/tuple_json_schema_builder.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/tuple_json_schema/tuple_json_schema_test.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortex/tuple_json_schema/tuple_json_schema_test_tuples.py +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortexpy.egg-info/dependency_links.txt +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortexpy.egg-info/entry_points.txt +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortexpy.egg-info/requires.txt +0 -0
- {vortexpy-4.1.7 → vortexpy-4.2.1}/vortexpy.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vortexpy"
|
|
7
|
-
version = "4.1
|
|
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
|
-
"%
|
|
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.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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"
|
|
@@ -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
|
+
)
|
|
@@ -24,7 +24,7 @@ vortex/TupleDataForTest.py
|
|
|
24
24
|
vortex/TuplePerfTest.py
|
|
25
25
|
vortex/TuplePolymorphicTupleTypeFieldArgTest.py
|
|
26
26
|
vortex/TupleRestful.py
|
|
27
|
-
vortex/
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|