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.
- {vortexpy-4.3.0 → vortexpy-4.3.3}/PKG-INFO +3 -1
- {vortexpy-4.3.0 → vortexpy-4.3.3}/README.md +2 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/pyproject.toml +11 -1
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/DeferUtil.py +24 -7
- vortexpy-4.3.3/vortex/DeferUtil_test.py +86 -0
- vortexpy-4.3.3/vortex/MtlsAuthorizedClientRegistry.py +150 -0
- vortexpy-4.3.3/vortex/MtlsAuthorizedClientRegistry_test.py +75 -0
- vortexpy-4.3.3/vortex/MtlsServerSslOptions.py +176 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadResponse.py +8 -1
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/SerialiseUtil.py +34 -6
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/SerialiseUtil_test.py +1 -1
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TupleBson.py +20 -58
- vortexpy-4.3.3/vortex/TupleBson_test.py +197 -0
- vortexpy-4.3.3/vortex/TupleRestful_test.py +174 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexClientTcp.py +19 -4
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexClientWebsocketFactory.py +29 -5
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexFactory.py +55 -12
- vortexpy-4.3.3/vortex/VortexFactory_test.py +114 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexServerConnection.py +1 -1
- vortexpy-4.3.3/vortex/VortexServerConnection_test.py +35 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexSession.py +3 -9
- vortexpy-4.3.3/vortex/VortexSession_test.py +23 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/__init__.py +1 -1
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/data_loader/TupleDataLiveLockManager.py +5 -1
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleActionProcessorProxy.py +48 -9
- vortexpy-4.3.3/vortex/handler/TupleActionProcessor_shutdown_test.py +20 -0
- vortexpy-4.3.3/vortex/handler/TupleActionPushOffline.py +180 -0
- vortexpy-4.3.3/vortex/handler/TupleActionPushOffline_test.py +133 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleDataObservableCache.py +5 -3
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleDataObservableHandler.py +38 -7
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleDataObservableProxyHandler.py +9 -5
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleDataObserverClient.py +12 -2
- vortexpy-4.3.3/vortex/payload_many_tuples_benchmark_test.py +71 -0
- vortexpy-4.3.3/vortex/restful/restful_http_client_test.py +73 -0
- vortexpy-4.3.3/vortex/restful/restful_resource_test.py +76 -0
- vortexpy-4.3.3/vortex/storage/TupleActionStorageSqlite.py +156 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/tuple_json_schema/tuple_json_schema_test.py +1 -1
- vortexpy-4.3.3/vortex/tuple_test_fixtures.py +176 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortexpy.egg-info/PKG-INFO +3 -1
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortexpy.egg-info/SOURCES.txt +15 -2
- vortexpy-4.3.0/vortex/TupleBson_test.py +0 -353
- vortexpy-4.3.0/vortex/TupleRestful_test.py +0 -329
- vortexpy-4.3.0/vortex/restful/RestfulHttpClientTest.py +0 -68
- vortexpy-4.3.0/vortex/restful/RestfulResourceTest.py +0 -108
- {vortexpy-4.3.0 → vortexpy-4.3.3}/setup.cfg +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/DataWrapTuple.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/Jsonable.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/Payload.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadEndpoint.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadEndpointTest.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadEnvelope.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadFilterKeys.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadIO.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadPriority.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadResponseTest.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadResponse_test.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PayloadTest.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/PerformTestActionTuple.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TestTuple.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/Tuple.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TupleAction.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TupleDataForTest.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TupleDocumentConverter.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TuplePerfTest.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TuplePolymorphicTupleTypeFieldArgTest.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TupleRestful.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TupleSelector.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/TupleTest.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexABC.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexAccessControlContextABC.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexAuthenticatorABC.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexClientHttp.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexClientHttpTest.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexConnectionABC.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexEndpointPermission.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexPayloadProtocol.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexPermission.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexPermissionError.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexServer.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexServerHttpResource.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexServerTcp.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexServerWebsocket.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexTcpMemoryLeakPayloadTest.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexTcpMemoryLeakRawTest.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexTest.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexUtil.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/VortexWritePushProducer.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/data_loader/TupleDataLoader.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/data_loader/TupleDataLoaderDelegate.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/data_loader/TupleDataLoaderTupleABC.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/data_loader/TupleDataLoaderTuples.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/data_loader/__init__.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/event_bus/TupleChangeEventABC.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/event_bus/TupleChangeEventBus.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/event_bus/TupleChangeEventBusObserverABC.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/event_bus/__init__.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleActionProcessor.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleActionProcessorTestHandler.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleDataAccessGranterABC.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleDataActionClient.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleDataObservableTestHandler.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/TupleDataProviderABC.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/VortexJSTupleLoaderTestHandler.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/handler/__init__.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/mypy_plugin.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/py.typed +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/restful/GzippedDataHttpClient.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/restful/GzippedDataHttpClientTest.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/restful/GzippedPayloadHttpClient.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/restful/RestfulHttpClient.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/restful/RestfulResource.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/restful/__init__.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/rpc/RPC.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/rpc/RPCTest.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/rpc/RPC_test.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/rpc/__init__.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/sqla_orm/OrmCrudHandler.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/sqla_orm/TupleProviderForDb.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/sqla_orm/VortexTupleJson.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/sqla_orm/__init__.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/storage/TupleStorageSqlite.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/storage/__init__.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/tuple_json_schema/__init__.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/tuple_json_schema/constants.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/tuple_json_schema/json_schema_node_tuple.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/tuple_json_schema/tuple_json_schema_builder.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/tuple_json_schema/tuple_json_schema_test_tuples.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/twisted_runtime_metrics.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortex/twisted_runtime_metrics_test.py +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortexpy.egg-info/dependency_links.txt +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortexpy.egg-info/entry_points.txt +0 -0
- {vortexpy-4.3.0 → vortexpy-4.3.3}/vortexpy.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
224
|
-
"
|
|
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
|
-
|
|
256
|
+
parsed = datetime.strptime(val, ISO8601)
|
|
257
|
+
return requireAwareDatetime(parsed)
|
|
230
258
|
|
|
231
259
|
if typeName == T_BOOL:
|
|
232
260
|
return val == V_TRUE
|