vortexpy 4.3.0__tar.gz → 4.3.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 (133) hide show
  1. {vortexpy-4.3.0 → vortexpy-4.3.3}/PKG-INFO +3 -1
  2. {vortexpy-4.3.0 → vortexpy-4.3.3}/README.md +2 -0
  3. {vortexpy-4.3.0 → vortexpy-4.3.3}/pyproject.toml +11 -1
  4. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/DeferUtil.py +24 -7
  5. vortexpy-4.3.3/vortex/DeferUtil_test.py +86 -0
  6. vortexpy-4.3.3/vortex/MtlsAuthorizedClientRegistry.py +150 -0
  7. vortexpy-4.3.3/vortex/MtlsAuthorizedClientRegistry_test.py +75 -0
  8. vortexpy-4.3.3/vortex/MtlsServerSslOptions.py +176 -0
  9. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadResponse.py +8 -1
  10. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/SerialiseUtil.py +34 -6
  11. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/SerialiseUtil_test.py +1 -1
  12. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TupleBson.py +20 -58
  13. vortexpy-4.3.3/vortex/TupleBson_test.py +197 -0
  14. vortexpy-4.3.3/vortex/TupleRestful_test.py +174 -0
  15. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexClientTcp.py +19 -4
  16. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexClientWebsocketFactory.py +29 -5
  17. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexFactory.py +55 -12
  18. vortexpy-4.3.3/vortex/VortexFactory_test.py +114 -0
  19. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexServerConnection.py +1 -1
  20. vortexpy-4.3.3/vortex/VortexServerConnection_test.py +35 -0
  21. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexSession.py +3 -9
  22. vortexpy-4.3.3/vortex/VortexSession_test.py +23 -0
  23. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/__init__.py +1 -1
  24. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/data_loader/TupleDataLiveLockManager.py +5 -1
  25. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleActionProcessorProxy.py +48 -9
  26. vortexpy-4.3.3/vortex/handler/TupleActionProcessor_shutdown_test.py +20 -0
  27. vortexpy-4.3.3/vortex/handler/TupleActionPushOffline.py +180 -0
  28. vortexpy-4.3.3/vortex/handler/TupleActionPushOffline_test.py +133 -0
  29. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleDataObservableCache.py +5 -3
  30. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleDataObservableHandler.py +38 -7
  31. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleDataObservableProxyHandler.py +9 -5
  32. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleDataObserverClient.py +12 -2
  33. vortexpy-4.3.3/vortex/payload_many_tuples_benchmark_test.py +71 -0
  34. vortexpy-4.3.3/vortex/restful/restful_http_client_test.py +73 -0
  35. vortexpy-4.3.3/vortex/restful/restful_resource_test.py +76 -0
  36. vortexpy-4.3.3/vortex/storage/TupleActionStorageSqlite.py +156 -0
  37. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/tuple_json_schema/tuple_json_schema_test.py +1 -1
  38. vortexpy-4.3.3/vortex/tuple_test_fixtures.py +176 -0
  39. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortexpy.egg-info/PKG-INFO +3 -1
  40. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortexpy.egg-info/SOURCES.txt +15 -2
  41. vortexpy-4.3.0/vortex/TupleBson_test.py +0 -353
  42. vortexpy-4.3.0/vortex/TupleRestful_test.py +0 -329
  43. vortexpy-4.3.0/vortex/restful/RestfulHttpClientTest.py +0 -68
  44. vortexpy-4.3.0/vortex/restful/RestfulResourceTest.py +0 -108
  45. {vortexpy-4.3.0 → vortexpy-4.3.3}/setup.cfg +0 -0
  46. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/DataWrapTuple.py +0 -0
  47. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/Jsonable.py +0 -0
  48. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/Payload.py +0 -0
  49. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadEndpoint.py +0 -0
  50. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadEndpointTest.py +0 -0
  51. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadEnvelope.py +0 -0
  52. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadFilterKeys.py +0 -0
  53. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadIO.py +0 -0
  54. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadPriority.py +0 -0
  55. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadResponseTest.py +0 -0
  56. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadResponse_test.py +0 -0
  57. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadTest.py +0 -0
  58. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PerformTestActionTuple.py +0 -0
  59. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TestTuple.py +0 -0
  60. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/Tuple.py +0 -0
  61. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TupleAction.py +0 -0
  62. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TupleDataForTest.py +0 -0
  63. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TupleDocumentConverter.py +0 -0
  64. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TuplePerfTest.py +0 -0
  65. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TuplePolymorphicTupleTypeFieldArgTest.py +0 -0
  66. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TupleRestful.py +0 -0
  67. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TupleSelector.py +0 -0
  68. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TupleTest.py +0 -0
  69. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexABC.py +0 -0
  70. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexAccessControlContextABC.py +0 -0
  71. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexAuthenticatorABC.py +0 -0
  72. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexClientHttp.py +0 -0
  73. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexClientHttpTest.py +0 -0
  74. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexConnectionABC.py +0 -0
  75. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexEndpointPermission.py +0 -0
  76. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexPayloadProtocol.py +0 -0
  77. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexPermission.py +0 -0
  78. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexPermissionError.py +0 -0
  79. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexServer.py +0 -0
  80. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexServerHttpResource.py +0 -0
  81. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexServerTcp.py +0 -0
  82. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexServerWebsocket.py +0 -0
  83. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexTcpMemoryLeakPayloadTest.py +0 -0
  84. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexTcpMemoryLeakRawTest.py +0 -0
  85. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexTest.py +0 -0
  86. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexUtil.py +0 -0
  87. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexWritePushProducer.py +0 -0
  88. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/data_loader/TupleDataLoader.py +0 -0
  89. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/data_loader/TupleDataLoaderDelegate.py +0 -0
  90. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/data_loader/TupleDataLoaderTupleABC.py +0 -0
  91. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/data_loader/TupleDataLoaderTuples.py +0 -0
  92. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/data_loader/__init__.py +0 -0
  93. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/event_bus/TupleChangeEventABC.py +0 -0
  94. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/event_bus/TupleChangeEventBus.py +0 -0
  95. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/event_bus/TupleChangeEventBusObserverABC.py +0 -0
  96. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/event_bus/__init__.py +0 -0
  97. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleActionProcessor.py +0 -0
  98. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleActionProcessorTestHandler.py +0 -0
  99. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleDataAccessGranterABC.py +0 -0
  100. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleDataActionClient.py +0 -0
  101. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleDataObservableTestHandler.py +0 -0
  102. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleDataProviderABC.py +0 -0
  103. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/VortexJSTupleLoaderTestHandler.py +0 -0
  104. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/__init__.py +0 -0
  105. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/mypy_plugin.py +0 -0
  106. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/py.typed +0 -0
  107. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/restful/GzippedDataHttpClient.py +0 -0
  108. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/restful/GzippedDataHttpClientTest.py +0 -0
  109. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/restful/GzippedPayloadHttpClient.py +0 -0
  110. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/restful/RestfulHttpClient.py +0 -0
  111. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/restful/RestfulResource.py +0 -0
  112. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/restful/__init__.py +0 -0
  113. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/rpc/RPC.py +0 -0
  114. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/rpc/RPCTest.py +0 -0
  115. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/rpc/RPC_test.py +0 -0
  116. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/rpc/__init__.py +0 -0
  117. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/sqla_orm/OrmCrudHandler.py +0 -0
  118. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/sqla_orm/TupleProviderForDb.py +0 -0
  119. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/sqla_orm/VortexTupleJson.py +0 -0
  120. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/sqla_orm/__init__.py +0 -0
  121. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/storage/TupleStorageSqlite.py +0 -0
  122. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/storage/__init__.py +0 -0
  123. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/tuple_json_schema/__init__.py +0 -0
  124. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/tuple_json_schema/constants.py +0 -0
  125. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/tuple_json_schema/json_schema_node_tuple.py +0 -0
  126. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/tuple_json_schema/tuple_json_schema_builder.py +0 -0
  127. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/tuple_json_schema/tuple_json_schema_test_tuples.py +0 -0
  128. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/twisted_runtime_metrics.py +0 -0
  129. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/twisted_runtime_metrics_test.py +0 -0
  130. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortexpy.egg-info/dependency_links.txt +0 -0
  131. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortexpy.egg-info/entry_points.txt +0 -0
  132. {vortexpy-4.3.0 → vortexpy-4.3.3}/vortexpy.egg-info/requires.txt +0 -0
  133. {vortexpy-4.3.0 → vortexpy-4.3.3}/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.3.0
3
+ Version: 4.3.3
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
@@ -34,6 +34,8 @@ Flying Ion's observable, routable, data serialisation and transport code.
34
34
  See [README_PERMISSIONS.md](README_PERMISSIONS.md) for the two-level permission
35
35
  system (endpoint guards + access control context) introduced in v5.0.
36
36
 
37
+ **Tuple and Payload tutorials:** [docs/README.md](docs/README.md) (simple → full features, encode/decode, threaded decode).
38
+
37
39
  Why PY at the end? To be consistent with VortexJS, which is the Browser side package.
38
40
  See https://gitlab.com/flyingion/vortexjs
39
41
 
@@ -5,6 +5,8 @@ Flying Ion's observable, routable, data serialisation and transport code.
5
5
  See [README_PERMISSIONS.md](README_PERMISSIONS.md) for the two-level permission
6
6
  system (endpoint guards + access control context) introduced in v5.0.
7
7
 
8
+ **Tuple and Payload tutorials:** [docs/README.md](docs/README.md) (simple → full features, encode/decode, threaded decode).
9
+
8
10
  Why PY at the end? To be consistent with VortexJS, which is the Browser side package.
9
11
  See https://gitlab.com/flyingion/vortexjs
10
12
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vortexpy"
7
- version = "4.3.0"
7
+ version = "4.3.3"
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."
@@ -53,3 +53,13 @@ namespaces = false
53
53
 
54
54
  [project.entry-points."mypy.plugins"]
55
55
  vortex_defer = "vortex.mypy_plugin:plugin"
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["vortex"]
59
+ python_files = ["*_test.py"]
60
+ python_functions = ["test*"]
61
+ norecursedirs = ["_trial_temp", "example", "old_fork"]
62
+ markers = [
63
+ "trial: legacy Twisted Trial *Test.py modules (not collected; run with trial CLI)",
64
+ "slow: large payload or timing benchmarks",
65
+ ]
@@ -8,6 +8,7 @@
8
8
 
9
9
  import inspect
10
10
  import logging
11
+ import time
11
12
  from collections.abc import Callable
12
13
  from typing import ParamSpec, TypeVar
13
14
 
@@ -209,7 +210,27 @@ def deferToThreadWrapWithLogger(
209
210
  "Deferring to a thread can only be done from the main thread"
210
211
  )
211
212
 
212
- d: Deferred[_R] = deferToThread(funcToWrap, *args, **kwargs)
213
+ def timedFuncToRun(*runArgs: _P.args, **runKwargs: _P.kwargs) -> _R:
214
+ startMonotonic = time.monotonic()
215
+ try:
216
+ return funcToWrap(*runArgs, **runKwargs)
217
+ finally:
218
+ durationMs = (time.monotonic() - startMonotonic) * 1000.0
219
+ from vortex.twisted_runtime_metrics import (
220
+ STACK_CAPTURE_THRESHOLD_MS,
221
+ TwistedRuntimeMetrics,
222
+ )
223
+
224
+ methodFrame = None
225
+ if durationMs >= STACK_CAPTURE_THRESHOLD_MS:
226
+ methodFrame = TwistedRuntimeMetrics._captureWorkerThreadFrame()
227
+ TwistedRuntimeMetrics._collector()._recordThreadedCall(
228
+ funcToWrap.__qualname__,
229
+ durationMs,
230
+ methodFrame,
231
+ )
232
+
233
+ d: Deferred[_R] = deferToThread(timedFuncToRun, *args, **kwargs)
213
234
  d.addCallback(checkForInlineCallbackYield)
214
235
  d.addErrback(vortexLogFailure, loggerArg, consumeError=consumeError)
215
236
  return d
@@ -225,13 +246,9 @@ def isMainThread():
225
246
 
226
247
  def noMainThread():
227
248
  if isMainThread():
228
- raise Exception(
229
- "Blocking operations shouldn't occur in the reactors thread."
230
- )
249
+ raise Exception("Blocking operations shouldn't occur in the reactors thread.")
231
250
 
232
251
 
233
252
  def yesMainThread():
234
253
  if not isMainThread():
235
- raise Exception(
236
- "Async operations must occur in the reactors main thread."
237
- )
254
+ raise Exception("Async operations must occur in the reactors main thread.")
@@ -0,0 +1,86 @@
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
+ import time
8
+ from collections.abc import Generator
9
+
10
+ import pytest
11
+ from pytest_twisted import ensureDeferred # type: ignore[import-untyped]
12
+
13
+ from vortex.DeferUtil import deferToThreadWrapWithLogger
14
+ from vortex.twisted_runtime_metrics import TwistedRuntimeMetrics
15
+ from vortex.twisted_runtime_metrics_test import (
16
+ utilResetTwistedRuntimeMetricsSingletonForTests,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @deferToThreadWrapWithLogger(logger)
23
+ def _deferUtilTestBlockingWork() -> str:
24
+ time.sleep(0.01)
25
+ return "done"
26
+
27
+
28
+ @deferToThreadWrapWithLogger(logger)
29
+ def _deferUtilTestSlowBlockingWork() -> str:
30
+ time.sleep(0.06)
31
+ return "slow-done"
32
+
33
+
34
+ @pytest.fixture(autouse=True)
35
+ def resetMetricsSingleton() -> Generator[None, None, None]:
36
+ utilResetTwistedRuntimeMetricsSingletonForTests()
37
+ yield
38
+ utilResetTwistedRuntimeMetricsSingletonForTests()
39
+
40
+
41
+ @pytest.mark.twisted
42
+ @ensureDeferred
43
+ async def testDeferToThreadWrapWithLoggerRecordsThreadedCallTiming() -> None:
44
+ result = await _deferUtilTestBlockingWork()
45
+ assert result == "done", "deferred thread call must return worker result"
46
+
47
+ snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
48
+ assert snapshot.threadedCallCount == 1, (
49
+ "defer hook must record one threaded call per invocation"
50
+ )
51
+ assert snapshot.longestThreadedMethodName == "_deferUtilTestBlockingWork", (
52
+ "defer hook must record wrapped function qualname"
53
+ )
54
+ assert snapshot.longestThreadedMethodMs >= 10.0, (
55
+ "defer hook must record at least sleep duration in ms"
56
+ )
57
+ assert snapshot.longestThreadedMethodFrame is None, (
58
+ "fast defer hook must not capture worker thread frame"
59
+ )
60
+
61
+
62
+ @pytest.mark.twisted
63
+ @ensureDeferred
64
+ async def testDeferToThreadWrapWithLoggerCapturesSlowCallFrame() -> None:
65
+ result = await _deferUtilTestSlowBlockingWork()
66
+ assert result == "slow-done", "deferred slow thread call must return worker result"
67
+
68
+ snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
69
+ assert snapshot.threadedCallCount == 1, (
70
+ "slow defer hook must record one threaded call"
71
+ )
72
+ assert snapshot.longestThreadedMethodName == "_deferUtilTestSlowBlockingWork", (
73
+ "slow defer hook must record wrapped function qualname"
74
+ )
75
+ assert snapshot.longestThreadedMethodMs >= 50.0, (
76
+ "slow defer hook must exceed stack capture threshold in ms"
77
+ )
78
+ assert snapshot.longestThreadedMethodFrame is not None, (
79
+ "slow defer hook must capture worker thread frame"
80
+ )
81
+ assert "DeferUtil.py" not in snapshot.longestThreadedMethodFrame, (
82
+ "captured frame must skip defer hook internals"
83
+ )
84
+ assert "twisted_runtime_metrics.py" not in snapshot.longestThreadedMethodFrame, (
85
+ "captured frame must skip metrics collector internals"
86
+ )
@@ -0,0 +1,150 @@
1
+ # Copyright (c) 2016-2026 Flying Ion Pty Ltd
2
+ #
3
+ # This software is open source; the MIT license applies.
4
+ # See LICENSE for the full license text.
5
+ #
6
+ # Website : https://flyingion.com
7
+ # Support : contact@flyingion.com
8
+
9
+ """Mutable authorized-client certificate set for mTLS server verification."""
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import logging
15
+ import threading
16
+ from typing import NamedTuple
17
+
18
+ from OpenSSL import crypto
19
+ from txhttputil.util.PemUtil import parsePemBundleForTrustedPeers
20
+
21
+ logger = logging.getLogger(name=__name__)
22
+
23
+
24
+ class AuthorizedClientIdentity(NamedTuple):
25
+ """Metadata for one authorized mTLS client certificate."""
26
+
27
+ fingerprint: str
28
+ publicKeyAsn1: bytes
29
+ instanceId: str
30
+ ownerUsername: str
31
+ commonName: str
32
+
33
+
34
+ class MtlsAuthorizedClientRegistry:
35
+ """Runtime-mutable allowlist of client certificate public keys.
36
+
37
+ Static PEM bundles can be loaded at startup; ``addAuthorizedClientCertificate``
38
+ and ``revokeAuthorizedClientCertificate`` mutate the set without restarting
39
+ the TLS listener when used with ``buildMtlsServerCertificateOptions``.
40
+ """
41
+
42
+ def __init__(self) -> None:
43
+ self._lock = threading.RLock()
44
+ self._byPublicKey: dict[bytes, AuthorizedClientIdentity] = {}
45
+ self._byFingerprint: dict[str, AuthorizedClientIdentity] = {}
46
+
47
+ def loadTrustedPeerBundle(self, bundle_file_path: str) -> int:
48
+ """Load client certificates from a PEM bundle into the registry.
49
+
50
+ :param bundle_file_path: Path to a PEM file with one or more client certs.
51
+ :return: Number of certificates added.
52
+ """
53
+ trusted_peer_certificates = parsePemBundleForTrustedPeers(bundle_file_path)
54
+ added = 0
55
+ for trusted_peer in trusted_peer_certificates:
56
+ cert_pem = trusted_peer.as_bytes()
57
+ fingerprint = self.addAuthorizedClientCertificate(
58
+ cert_pem,
59
+ instanceId="",
60
+ ownerUsername="",
61
+ )
62
+ if fingerprint:
63
+ added += 1
64
+ return added
65
+
66
+ def addAuthorizedClientCertificate(
67
+ self,
68
+ certificate_pem: bytes | str,
69
+ *,
70
+ instanceId: str = "",
71
+ ownerUsername: str = "",
72
+ ) -> str:
73
+ """Authorize a client certificate for future mTLS handshakes.
74
+
75
+ :param certificate_pem: PEM-encoded X.509 client certificate.
76
+ :param instanceId: Codie instance id (CN when issued by hub CA).
77
+ :param ownerUsername: OAuth username or operator id for audit logs.
78
+ :return: SHA-256 fingerprint prefix (hex) for the certificate.
79
+ """
80
+ if isinstance(certificate_pem, str):
81
+ certificate_pem = certificate_pem.encode("utf-8")
82
+
83
+ x509_cert = crypto.load_certificate(crypto.FILETYPE_PEM, certificate_pem)
84
+ public_key_asn1 = crypto.dump_publickey(
85
+ crypto.FILETYPE_ASN1, x509_cert.get_pubkey()
86
+ )
87
+ fingerprint = hashlib.sha256(
88
+ crypto.dump_certificate(crypto.FILETYPE_ASN1, x509_cert)
89
+ ).hexdigest()[:16]
90
+ subject = x509_cert.get_subject()
91
+ common_name = (
92
+ subject.CN.decode("utf-8")
93
+ if isinstance(subject.CN, bytes)
94
+ else str(subject.CN or "")
95
+ )
96
+ resolved_instance_id = instanceId or common_name
97
+ identity = AuthorizedClientIdentity(
98
+ fingerprint=fingerprint,
99
+ publicKeyAsn1=public_key_asn1,
100
+ instanceId=resolved_instance_id,
101
+ ownerUsername=ownerUsername,
102
+ commonName=common_name,
103
+ )
104
+ with self._lock:
105
+ self._byPublicKey[public_key_asn1] = identity
106
+ self._byFingerprint[fingerprint] = identity
107
+ logger.info(
108
+ "Authorized mTLS client cert fingerprint=%s instanceId=%s owner=%s cn=%s",
109
+ fingerprint,
110
+ resolved_instance_id,
111
+ ownerUsername or "(unknown)",
112
+ common_name,
113
+ )
114
+ return fingerprint
115
+
116
+ def revokeAuthorizedClientCertificate(self, fingerprint: str) -> bool:
117
+ """Remove a client certificate from the authorized set.
118
+
119
+ :param fingerprint: Value returned from ``addAuthorizedClientCertificate``.
120
+ :return: ``True`` when a certificate was removed.
121
+ """
122
+ with self._lock:
123
+ identity = self._byFingerprint.pop(fingerprint, None)
124
+ if identity is None:
125
+ return False
126
+ self._byPublicKey.pop(identity.publicKeyAsn1, None)
127
+ logger.info(
128
+ "Revoked mTLS client cert fingerprint=%s instanceId=%s owner=%s",
129
+ identity.fingerprint,
130
+ identity.instanceId,
131
+ identity.ownerUsername or "(unknown)",
132
+ )
133
+ return True
134
+
135
+ def isPublicKeyAuthorized(self, public_key_asn1: bytes) -> bool:
136
+ """Return whether a TLS peer public key is currently authorized."""
137
+ with self._lock:
138
+ return public_key_asn1 in self._byPublicKey
139
+
140
+ def getIdentityForPublicKey(
141
+ self, public_key_asn1: bytes
142
+ ) -> AuthorizedClientIdentity | None:
143
+ """Return audit metadata for an authorized peer public key."""
144
+ with self._lock:
145
+ return self._byPublicKey.get(public_key_asn1)
146
+
147
+ def listAuthorizedIdentities(self) -> list[AuthorizedClientIdentity]:
148
+ """Return a snapshot of all authorized client identities."""
149
+ with self._lock:
150
+ return list(self._byFingerprint.values())
@@ -0,0 +1,75 @@
1
+ # Copyright (c) 2016-2026 Flying Ion Pty Ltd
2
+ #
3
+ # This software is open source; the MIT license applies.
4
+ # See LICENSE for the full license text.
5
+ #
6
+ # Website : https://flyingion.com
7
+ # Support : contact@flyingion.com
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ import pytest
14
+ from cryptography import x509
15
+ from cryptography.hazmat.primitives import hashes, serialization
16
+ from cryptography.hazmat.primitives.asymmetric import rsa
17
+ from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
18
+ from datetime import datetime, timedelta, timezone
19
+ from OpenSSL import crypto
20
+
21
+ from vortex.MtlsAuthorizedClientRegistry import MtlsAuthorizedClientRegistry
22
+
23
+
24
+ def _makeClientCertPem(common_name: str) -> bytes:
25
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
26
+ subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
27
+ now = datetime.now(timezone.utc)
28
+ cert = (
29
+ x509.CertificateBuilder()
30
+ .subject_name(subject)
31
+ .issuer_name(subject)
32
+ .public_key(private_key.public_key())
33
+ .serial_number(x509.random_serial_number())
34
+ .not_valid_before(now)
35
+ .not_valid_after(now + timedelta(days=30))
36
+ .add_extension(
37
+ x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]),
38
+ critical=False,
39
+ )
40
+ .sign(private_key, hashes.SHA256())
41
+ )
42
+ return cert.public_bytes(serialization.Encoding.PEM)
43
+
44
+
45
+ def test_add_and_revoke_client_certificate() -> None:
46
+ registry = MtlsAuthorizedClientRegistry()
47
+ cert_pem = _makeClientCertPem("local-mac")
48
+ fingerprint = registry.addAuthorizedClientCertificate(
49
+ cert_pem,
50
+ instanceId="local-mac",
51
+ ownerUsername="operator",
52
+ )
53
+ assert fingerprint
54
+ x509_cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert_pem)
55
+ public_key_asn1 = crypto.dump_publickey(
56
+ crypto.FILETYPE_ASN1, x509_cert.get_pubkey()
57
+ )
58
+ assert registry.isPublicKeyAuthorized(public_key_asn1)
59
+ identity = registry.getIdentityForPublicKey(public_key_asn1)
60
+ assert identity is not None
61
+ assert identity.instanceId == "local-mac"
62
+ assert identity.ownerUsername == "operator"
63
+ assert registry.revokeAuthorizedClientCertificate(fingerprint)
64
+ assert not registry.isPublicKeyAuthorized(public_key_asn1)
65
+
66
+
67
+ def test_load_trusted_peer_bundle(tmp_path) -> None:
68
+ cert_pem = _makeClientCertPem("cloud-instance")
69
+ bundle_path = os.path.join(str(tmp_path), "trusted-peers.pem")
70
+ with open(bundle_path, "wb") as handle:
71
+ handle.write(cert_pem)
72
+ registry = MtlsAuthorizedClientRegistry()
73
+ added = registry.loadTrustedPeerBundle(bundle_path)
74
+ assert added == 1
75
+ assert len(registry.listAuthorizedIdentities()) == 1
@@ -0,0 +1,176 @@
1
+ # Copyright (c) 2016-2026 Flying Ion Pty Ltd
2
+ #
3
+ # This software is open source; the MIT license applies.
4
+ # See LICENSE for the full license text.
5
+ #
6
+ # Website : https://flyingion.com
7
+ # Support : contact@flyingion.com
8
+
9
+ """Build Twisted server SSL options with a dynamic mTLS client allowlist."""
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import Optional
15
+
16
+ from OpenSSL import SSL, crypto
17
+ from twisted.internet._sslverify import Certificate, KeyPair, TLSVersion
18
+ from twisted.internet.ssl import CertificateOptions
19
+ from twisted.python.randbytes import secureRandom
20
+ from txhttputil.util.PemUtil import PrivateKeyWithFullChain
21
+
22
+ from vortex.MtlsAuthorizedClientRegistry import MtlsAuthorizedClientRegistry
23
+
24
+ logger = logging.getLogger(name=__name__)
25
+
26
+
27
+ class _MtlsServerCertificateOptions(CertificateOptions):
28
+ """Server ``CertificateOptions`` with registry-backed client verification."""
29
+
30
+ def __init__(
31
+ self,
32
+ *,
33
+ authorized_client_registry: MtlsAuthorizedClientRegistry,
34
+ **kwargs,
35
+ ) -> None:
36
+ self._authorizedClientRegistry = authorized_client_registry
37
+ super().__init__(**kwargs)
38
+
39
+ def _makeContext(self):
40
+ ctx = self._contextFactory(self.method)
41
+ ctx.set_options(self._options)
42
+ ctx.set_mode(self._mode)
43
+
44
+ if self.certificate is not None and self.privateKey is not None:
45
+ ctx.use_certificate(self.certificate)
46
+ ctx.use_privatekey(self.privateKey)
47
+ for extra_cert in self.extraCertChain:
48
+ ctx.add_extra_chain_cert(extra_cert)
49
+ ctx.check_privatekey()
50
+
51
+ verify_flags = SSL.VERIFY_NONE
52
+ if self.verify:
53
+ verify_flags = SSL.VERIFY_PEER
54
+ if self.requireCertificate:
55
+ verify_flags |= SSL.VERIFY_FAIL_IF_NO_PEER_CERT
56
+ if self.verifyOnce:
57
+ verify_flags |= SSL.VERIFY_CLIENT_ONCE
58
+ self.trustRoot._addCACertsToContext(ctx)
59
+
60
+ registry = self._authorizedClientRegistry
61
+
62
+ def verifyCallback(
63
+ conn: SSL.Connection,
64
+ cert: crypto.X509,
65
+ errno: int,
66
+ depth: int,
67
+ preverify_ok: int,
68
+ ) -> int:
69
+ fail = 0
70
+
71
+ if depth != 0:
72
+ return preverify_ok
73
+
74
+ if preverify_ok == fail:
75
+ return preverify_ok
76
+
77
+ session_peer_public_key_bytes = crypto.dump_publickey(
78
+ crypto.FILETYPE_ASN1, cert.get_pubkey()
79
+ )
80
+ if not registry.isPublicKeyAuthorized(session_peer_public_key_bytes):
81
+ subject = cert.get_subject()
82
+ logger.error(
83
+ "mTLS peer verify failed: certificate not authorized subject=%s",
84
+ subject,
85
+ )
86
+ return fail
87
+
88
+ identity = registry.getIdentityForPublicKey(
89
+ session_peer_public_key_bytes
90
+ )
91
+ if identity is not None:
92
+ logger.info(
93
+ "mTLS peer verified instanceId=%s owner=%s fingerprint=%s",
94
+ identity.instanceId or "(unknown)",
95
+ identity.ownerUsername or "(unknown)",
96
+ identity.fingerprint,
97
+ )
98
+ return preverify_ok
99
+
100
+ ctx.set_verify(verify_flags, verifyCallback)
101
+ if self.verifyDepth is not None:
102
+ ctx.set_verify_depth(self.verifyDepth)
103
+
104
+ from binascii import hexlify
105
+
106
+ session_id_context = hexlify(secureRandom(7))
107
+ ctx.set_session_id(session_id_context)
108
+
109
+ if self.enableSessions:
110
+ ctx.set_session_cache_mode(SSL.SESS_CACHE_SERVER)
111
+ else:
112
+ ctx.set_session_cache_mode(SSL.SESS_CACHE_OFF)
113
+
114
+ if self.dhParameters:
115
+ ctx.load_tmp_dh(self.dhParameters._dhFile.path)
116
+ ctx.set_cipher_list(self._cipherString.encode("ascii"))
117
+
118
+ self._ecChooser.configureECDHCurve(ctx)
119
+
120
+ if self._acceptableProtocols:
121
+ from twisted.internet._sslverify import _setAcceptableProtocols
122
+
123
+ _setAcceptableProtocols(ctx, self._acceptableProtocols)
124
+
125
+ return ctx
126
+
127
+
128
+ def buildMtlsServerCertificateOptions(
129
+ private_key_with_full_chain: PrivateKeyWithFullChain,
130
+ trust_root,
131
+ authorized_client_registry: MtlsAuthorizedClientRegistry,
132
+ *,
133
+ acceptable_protocols: Optional[list[bytes]] = None,
134
+ dh_parameters=None,
135
+ ) -> CertificateOptions:
136
+ """Build server TLS options with dynamic authorized-client verification.
137
+
138
+ Existing callers that pass a static trusted-peer PEM bundle should load
139
+ that bundle into ``authorized_client_registry`` first; runtime ``add`` /
140
+ ``revoke`` on the registry updates verification without rebuilding options.
141
+
142
+ :param private_key_with_full_chain: Server key + certificate chain PEM bank.
143
+ :param trust_root: Twisted trust root for client certificate chain validation.
144
+ :param authorized_client_registry: Mutable authorized client public keys.
145
+ :return: Twisted ``CertificateOptions`` for ``reactor.listenSSL``.
146
+ """
147
+ if acceptable_protocols is None:
148
+ acceptable_protocols = [b"http/1.1"]
149
+
150
+ twisted_loaded_chain = PrivateKeyWithFullChain(
151
+ privateKey=KeyPair.load(
152
+ private_key_with_full_chain.privateKey.as_bytes(), crypto.FILETYPE_PEM
153
+ ).original,
154
+ certificate=Certificate.loadPEM(
155
+ private_key_with_full_chain.certificate.as_bytes()
156
+ ).original,
157
+ intermediateCAs=[
158
+ Certificate.loadPEM(intermediate_ca.as_bytes()).original
159
+ for intermediate_ca in private_key_with_full_chain.intermediateCAs
160
+ ],
161
+ rootCA=Certificate.loadPEM(
162
+ private_key_with_full_chain.rootCA.as_bytes()
163
+ ).original,
164
+ )
165
+
166
+ return _MtlsServerCertificateOptions(
167
+ authorized_client_registry=authorized_client_registry,
168
+ privateKey=twisted_loaded_chain.privateKey,
169
+ certificate=twisted_loaded_chain.certificate,
170
+ extraCertChain=twisted_loaded_chain.intermediateCAs,
171
+ trustRoot=trust_root,
172
+ raiseMinimumTo=TLSVersion.TLSv1_2,
173
+ lowerMaximumSecurityTo=TLSVersion.TLSv1_3,
174
+ acceptableProtocols=acceptable_protocols,
175
+ dhParameters=dh_parameters,
176
+ )
@@ -151,7 +151,14 @@ class PayloadResponse(Deferred):
151
151
  self._status = self.TIMED_OUT
152
152
  return failure
153
153
 
154
- def _process(self, payloadEnvelope: PayloadEnvelope, vortexName, **kwargs):
154
+ def _process(
155
+ self,
156
+ payloadEnvelope: PayloadEnvelope,
157
+ vortexUuid: str,
158
+ vortexName: str,
159
+ sendResponse=None,
160
+ vortexSession=None,
161
+ ):
155
162
  if self._endpoint:
156
163
  self._endpoint.shutdown()
157
164
  self._endpoint = None
@@ -11,7 +11,7 @@ import enum
11
11
  import logging
12
12
  from base64 import b64encode, b64decode
13
13
  from collections import defaultdict
14
- from datetime import datetime, date
14
+ from datetime import UTC, datetime, date
15
15
 
16
16
  import pytz
17
17
  from sqlalchemy.orm.collections import InstrumentedList
@@ -124,6 +124,35 @@ def deserialiseWkb(wkbB64Data: str) -> WKBElement:
124
124
  return from_shape(shapely.wkb.loads(b64decode(wkbB64Data)))
125
125
 
126
126
 
127
+ def requireAwareDatetime(value: datetime) -> datetime:
128
+ """Require a timezone-aware datetime for BSON serialisation."""
129
+ if value.tzinfo is None:
130
+ raise TypeError(
131
+ "datetime must be timezone-aware for BSON serialisation,"
132
+ f" got naive {value!r}"
133
+ )
134
+ return value
135
+
136
+
137
+ def datetimeToBson(value: datetime) -> int:
138
+ """Serialise a datetime to UTC epoch milliseconds for BSON storage."""
139
+ aware = requireAwareDatetime(value)
140
+ return int(aware.timestamp() * 1000)
141
+
142
+
143
+ def datetimeFromBson(value: object) -> datetime:
144
+ """Deserialise a BSON datetime field from UTC epoch milliseconds."""
145
+ if isinstance(value, datetime):
146
+ return requireAwareDatetime(value)
147
+ if isinstance(value, int) and not isinstance(value, bool):
148
+ return datetime.fromtimestamp(value / 1000, tz=UTC)
149
+ if isinstance(value, float):
150
+ return datetime.fromtimestamp(value / 1000, tz=UTC)
151
+ raise TypeError(
152
+ f"Expected BSON datetime or epoch milliseconds, got {type(value).__name__}"
153
+ )
154
+
155
+
127
156
  TYPE_MAP_RAPUI_TO_PY = {
128
157
  T_FLOAT: float,
129
158
  T_STR: str,
@@ -220,13 +249,12 @@ def fromStr(val: str, typeName: str):
220
249
  )
221
250
 
222
251
  if len(val) <= len("2017-06-06 09:40:37.097068"):
223
- logger.warning(
224
- "Parsing date time with no timezone: %s Setting TZ to UTC",
225
- val,
252
+ raise ValueError(
253
+ f"datetime string must include timezone information, got {val!r}"
226
254
  )
227
- return datetime.strptime(val, ISO8601_NOTZ).replace(tzinfo=pytz.UTC)
228
255
 
229
- return datetime.strptime(val, ISO8601)
256
+ parsed = datetime.strptime(val, ISO8601)
257
+ return requireAwareDatetime(parsed)
230
258
 
231
259
  if typeName == T_BOOL:
232
260
  return val == V_TRUE
@@ -9,7 +9,7 @@
9
9
  import json
10
10
  from enum import Enum, IntEnum, StrEnum
11
11
 
12
- from twisted.trial import unittest
12
+ import unittest
13
13
 
14
14
  from vortex.SerialiseUtil import T_INT, T_STR, fromStr, toRapuiType, toStr
15
15
  from vortex.TupleSelector import TupleSelector