diff --git a/.benchmarks/Linux-CPython-3.14-64bit/0001_baseline.json b/.benchmarks/Linux-CPython-3.14-64bit/0001_baseline.json new file mode 100644 index 0000000..1e36bc7 --- /dev/null +++ b/.benchmarks/Linux-CPython-3.14-64bit/0001_baseline.json @@ -0,0 +1,660 @@ +{ + "machine_info": { + "node": "NB-Andi", + "processor": "x86_64", + "machine": "x86_64", + "python_compiler": "Clang 21.1.4 ", + "python_implementation": "CPython", + "python_implementation_version": "3.14.2", + "python_version": "3.14.2", + "python_build": [ + "main", + "Dec 9 2025 19:03:28" + ], + "release": "6.6.87.2-microsoft-standard-WSL2", + "system": "Linux", + "cpu": { + "python_version": "3.14.2.final.0 (64 bit)", + "cpuinfo_version": [ + 9, + 0, + 0 + ], + "cpuinfo_version_string": "9.0.0", + "arch": "X86_64", + "bits": 64, + "count": 8, + "arch_string_raw": "x86_64", + "vendor_id_raw": "GenuineIntel", + "brand_raw": "Intel(R) Core(TM) Ultra 7 258V", + "hz_advertised_friendly": "3.3024 GHz", + "hz_actual_friendly": "3.3024 GHz", + "hz_advertised": [ + 3302400000, + 0 + ], + "hz_actual": [ + 3302400000, + 0 + ], + "stepping": 1, + "model": 189, + "family": 6, + "flags": [ + "3dnowprefetch", + "abm", + "adx", + "aes", + "apic", + "arch_capabilities", + "avx", + "avx2", + "avx_vnni", + "bmi1", + "bmi2", + "clflush", + "clflushopt", + "clwb", + "cmov", + "constant_tsc", + "cpuid", + "cx16", + "cx8", + "de", + "ept", + "ept_ad", + "erms", + "f16c", + "flush_l1d", + "fma", + "fpu", + "fsgsbase", + "fsrm", + "fxsr", + "gfni", + "ht", + "hypervisor", + "ibpb", + "ibrs", + "ibrs_enhanced", + "invpcid", + "lahf_lm", + "lm", + "mca", + "mce", + "md_clear", + "mmx", + "movbe", + "movdir64b", + "movdiri", + "msr", + "mtrr", + "nonstop_tsc", + "nopl", + "nx", + "osxsave", + "pae", + "pat", + "pcid", + "pclmulqdq", + "pdpe1gb", + "pge", + "pni", + "popcnt", + "pse", + "pse36", + "rdpid", + "rdrand", + "rdrnd", + "rdseed", + "rdtscp", + "rep_good", + "sep", + "serialize", + "sha", + "sha_ni", + "smap", + "smep", + "ss", + "ssbd", + "sse", + "sse2", + "sse4_1", + "sse4_2", + "ssse3", + "stibp", + "syscall", + "tpr_shadow", + "tsc", + "tsc_adjust", + "tsc_deadline_timer", + "tsc_known_freq", + "tsc_reliable", + "tscdeadline", + "umip", + "vaes", + "vme", + "vmx", + "vnmi", + "vpclmulqdq", + "vpid", + "waitpkg", + "x2apic", + "xgetbv1", + "xsave", + "xsavec", + "xsaveopt", + "xsaves", + "xtopology" + ], + "l3_cache_size": 12582912, + "l2_cache_size": 20971520, + "l1_data_cache_size": 393216, + "l1_instruction_cache_size": 524288, + "l2_cache_line_size": 2560, + "l2_cache_associativity": 7 + } + }, + "commit_info": { + "id": "c4eea7339cfef1de4d492cd260400a60f3e696c7", + "time": "2026-05-11T18:16:39+02:00", + "author_time": "2026-05-11T18:16:39+02:00", + "dirty": true, + "project": "microdcs", + "branch": "feature/aiomqtt-v3" + }, + "benchmarks": [ + { + "group": null, + "name": "test_state_machine_trigger_store", + "fullname": "tests/statemachine_test.py::test_state_machine_trigger_store", + "params": null, + "param": null, + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00010742899939941708, + "max": 0.000305649999063462, + "mean": 0.00013877872002922233, + "stddev": 3.6643698778287344e-05, + "rounds": 50, + "median": 0.00012556250021589221, + "iqr": 3.0915000024833716e-05, + "q1": 0.0001177829999505775, + "q3": 0.00014869799997541122, + "iqr_outliers": 3, + "stddev_outliers": 5, + "outliers": "5;3", + "ld15iqr": 0.00010742899939941708, + "hd15iqr": 0.00021985999956086744, + "ops": 7205.715687458656, + "total": 0.006938936001461116, + "iterations": 1 + } + }, + { + "group": null, + "name": "test_json_round_trip_basic", + "fullname": "tests/test_common.py::TestCloudEventSerialization::test_json_round_trip_basic", + "params": null, + "param": null, + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 2.635999408084899e-06, + "max": 0.00017367100008414127, + "mean": 3.2599162934249607e-06, + "stddev": 3.6383045048439233e-06, + "rounds": 17750, + "median": 2.8909998945891857e-06, + "iqr": 1.5700061339884996e-07, + "q1": 2.828999640769325e-06, + "q3": 2.986000254168175e-06, + "iqr_outliers": 2163, + "stddev_outliers": 429, + "outliers": "429;2163", + "ld15iqr": 2.635999408084899e-06, + "hd15iqr": 3.2219995773630217e-06, + "ops": 306756.34279841324, + "total": 0.057863514208293054, + "iterations": 1 + } + }, + { + "group": null, + "name": "test_msgpack_round_trip", + "fullname": "tests/test_common.py::TestCloudEventSerialization::test_msgpack_round_trip", + "params": null, + "param": null, + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 3.4799995773937553e-06, + "max": 0.0005018659994675545, + "mean": 4.33520777031396e-06, + "stddev": 9.370854297299746e-06, + "rounds": 10131, + "median": 3.7170011637499556e-06, + "iqr": 2.0100014808122069e-07, + "q1": 3.64600055036135e-06, + "q3": 3.847000698442571e-06, + "iqr_outliers": 1073, + "stddev_outliers": 94, + "outliers": "94;1073", + "ld15iqr": 3.4799995773937553e-06, + "hd15iqr": 4.149000233155675e-06, + "ops": 230669.4518421153, + "total": 0.04391998992105073, + "iterations": 1 + } + }, + { + "group": null, + "name": "test_unserialize_json_payload", + "fullname": "tests/test_common.py::TestCloudEventPayload::test_unserialize_json_payload", + "params": null, + "param": null, + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 5.019992386223748e-07, + "max": 0.00016171800052688923, + "mean": 6.21584984246789e-07, + "stddev": 1.4431681615179605e-06, + "rounds": 65742, + "median": 5.680012691300362e-07, + "iqr": 3.999775799456984e-08, + "q1": 5.520014383364469e-07, + "q3": 5.919991963310167e-07, + "iqr_outliers": 5652, + "stddev_outliers": 196, + "outliers": "196;5652", + "ld15iqr": 5.019992386223748e-07, + "hd15iqr": 6.519985618069768e-07, + "ops": 1608790.4716870834, + "total": 0.040864240034352406, + "iterations": 1 + } + }, + { + "group": null, + "name": "test_unserialize_msgpack_payload", + "fullname": "tests/test_common.py::TestCloudEventPayload::test_unserialize_msgpack_payload", + "params": null, + "param": null, + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 5.109995981911197e-07, + "max": 0.0008038409996515838, + "mean": 7.009004105040734e-07, + "stddev": 3.817141306376536e-06, + "rounds": 69363, + "median": 5.980000423733145e-07, + "iqr": 5.8002115110866725e-08, + "q1": 5.759993655374274e-07, + "q3": 6.340014806482941e-07, + "iqr_outliers": 6627, + "stddev_outliers": 130, + "outliers": "130;6627", + "ld15iqr": 5.109995981911197e-07, + "hd15iqr": 7.21998731023632e-07, + "ops": 1426736.2167484255, + "total": 0.04861655517379404, + "iterations": 1 + } + }, + { + "group": null, + "name": "test_serialize_json_payload", + "fullname": "tests/test_common.py::TestCloudEventPayload::test_serialize_json_payload", + "params": null, + "param": null, + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 4.4099942897446454e-07, + "max": 0.00023252599930856377, + "mean": 5.734904332350994e-07, + "stddev": 2.114551379603026e-06, + "rounds": 87605, + "median": 4.920002538710833e-07, + "iqr": 3.8002326618880033e-08, + "q1": 4.769990482600406e-07, + "q3": 5.150013748789206e-07, + "iqr_outliers": 7645, + "stddev_outliers": 252, + "outliers": "252;7645", + "ld15iqr": 4.4099942897446454e-07, + "hd15iqr": 5.729998520109802e-07, + "ops": 1743708.2504740844, + "total": 0.05024062940356089, + "iterations": 1 + } + }, + { + "group": null, + "name": "test_serialize_msgpack_payload", + "fullname": "tests/test_common.py::TestCloudEventPayload::test_serialize_msgpack_payload", + "params": null, + "param": null, + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 6.359987310133874e-07, + "max": 0.0002777560002868995, + "mean": 8.198955386452235e-07, + "stddev": 2.544198567056261e-06, + "rounds": 64974, + "median": 7.199996616691351e-07, + "iqr": 4.799949238076806e-08, + "q1": 6.990012479946017e-07, + "q3": 7.470007403753698e-07, + "iqr_outliers": 4858, + "stddev_outliers": 228, + "outliers": "228;4858", + "ld15iqr": 6.359987310133874e-07, + "hd15iqr": 8.190017979359254e-07, + "ops": 1219667.57088638, + "total": 0.053271892727934755, + "iterations": 1 + } + }, + { + "group": null, + "name": "test_json_round_trip", + "fullname": "tests/test_dataclass.py::TestDataClassMixinSerialization::test_json_round_trip", + "params": null, + "param": null, + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 3.530003596097231e-07, + "max": 0.00012029199933749624, + "mean": 4.3462094615217815e-07, + "stddev": 1.0647162788325232e-06, + "rounds": 93371, + "median": 3.969998942920938e-07, + "iqr": 3.199966158717871e-08, + "q1": 3.839995770249516e-07, + "q3": 4.159992386121303e-07, + "iqr_outliers": 6898, + "stddev_outliers": 226, + "outliers": "226;6898", + "ld15iqr": 3.530003596097231e-07, + "hd15iqr": 4.640005499823019e-07, + "ops": 2300855.512955098, + "total": 0.04058099236317503, + "iterations": 1 + } + }, + { + "group": null, + "name": "test_msgpack_round_trip", + "fullname": "tests/test_dataclass.py::TestDataClassMixinSerialization::test_msgpack_round_trip", + "params": null, + "param": null, + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 5.649999366141856e-07, + "max": 0.0005418149994511623, + "mean": 7.268816231719165e-07, + "stddev": 2.953906290964771e-06, + "rounds": 53845, + "median": 6.48000423097983e-07, + "iqr": 3.800050762947649e-08, + "q1": 6.310001481324434e-07, + "q3": 6.690006557619199e-07, + "iqr_outliers": 3345, + "stddev_outliers": 117, + "outliers": "117;3345", + "ld15iqr": 5.749989213654771e-07, + "hd15iqr": 7.269991328939795e-07, + "ops": 1375739.829047085, + "total": 0.03913894099969184, + "iterations": 1 + } + }, + { + "group": null, + "name": "test_json_roundtrip", + "fullname": "tests/test_sfc_recipe.py::TestSfcStep::test_json_roundtrip", + "params": null, + "param": null, + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 7.470007403753698e-07, + "max": 0.00017428199862479232, + "mean": 9.6703218776265e-07, + "stddev": 2.4833962810172284e-06, + "rounds": 51854, + "median": 8.300012268591672e-07, + "iqr": 5.90007402934134e-08, + "q1": 8.070001058513299e-07, + "q3": 8.660008461447433e-07, + "iqr_outliers": 5386, + "stddev_outliers": 265, + "outliers": "265;5386", + "ld15iqr": 7.470007403753698e-07, + "hd15iqr": 9.549985406920314e-07, + "ops": 1034091.7423996248, + "total": 0.05014448706424446, + "iterations": 1 + } + }, + { + "group": null, + "name": "test_msgpack_roundtrip", + "fullname": "tests/test_sfc_recipe.py::TestSfcStep::test_msgpack_roundtrip", + "params": null, + "param": null, + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 1.0000003385357559e-06, + "max": 0.000492145998578053, + "mean": 1.28985256348118e-06, + "stddev": 4.113639652333724e-06, + "rounds": 37753, + "median": 1.1249994713580236e-06, + "iqr": 7.699964044149965e-08, + "q1": 1.0929998097708449e-06, + "q3": 1.1699994502123445e-06, + "iqr_outliers": 3684, + "stddev_outliers": 142, + "outliers": "142;3684", + "ld15iqr": 1.0000003385357559e-06, + "hd15iqr": 1.2860000424552709e-06, + "ops": 775282.4069295969, + "total": 0.04869580382910499, + "iterations": 1 + } + }, + { + "group": null, + "name": "test_minimal_recipe_roundtrip", + "fullname": "tests/test_sfc_recipe.py::TestSfcRecipe::test_minimal_recipe_roundtrip", + "params": null, + "param": null, + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 5.017000148654915e-06, + "max": 0.0006805240009271074, + "mean": 6.042103428393336e-06, + "stddev": 7.834220392469775e-06, + "rounds": 19665, + "median": 5.392999810283072e-06, + "iqr": 2.442507138766814e-07, + "q1": 5.2939994930056855e-06, + "q3": 5.538250206882367e-06, + "iqr_outliers": 2526, + "stddev_outliers": 324, + "outliers": "324;2526", + "ld15iqr": 5.017000148654915e-06, + "hd15iqr": 5.904999852646142e-06, + "ops": 165505.27673868558, + "total": 0.11881796391935495, + "iterations": 1 + } + }, + { + "group": null, + "name": "test_full_recipe_json_roundtrip", + "fullname": "tests/test_sfc_recipe.py::TestSfcRecipe::test_full_recipe_json_roundtrip", + "params": null, + "param": null, + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 1.0237001333734952e-05, + "max": 0.00021858400032215286, + "mean": 1.218895395141991e-05, + "stddev": 7.17802837832817e-06, + "rounds": 16113, + "median": 1.0965999535983428e-05, + "iqr": 5.67000824958086e-07, + "q1": 1.0792000466608442e-05, + "q3": 1.1359001291566528e-05, + "iqr_outliers": 2522, + "stddev_outliers": 535, + "outliers": "535;2522", + "ld15iqr": 1.0237001333734952e-05, + "hd15iqr": 1.2210999557282776e-05, + "ops": 82041.49461763357, + "total": 0.196400615019229, + "iterations": 1 + } + }, + { + "group": null, + "name": "test_msgpack_roundtrip", + "fullname": "tests/test_sfc_recipe.py::TestSfcRecipe::test_msgpack_roundtrip", + "params": null, + "param": null, + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 5.4079991969047114e-06, + "max": 0.00047721500050101895, + "mean": 6.660186853310234e-06, + "stddev": 7.79969757648308e-06, + "rounds": 13850, + "median": 5.797999619971961e-06, + "iqr": 2.6899942895397544e-07, + "q1": 5.697000233340077e-06, + "q3": 5.9659996622940525e-06, + "iqr_outliers": 1980, + "stddev_outliers": 442, + "outliers": "442;1980", + "ld15iqr": 5.4079991969047114e-06, + "hd15iqr": 6.370999471982941e-06, + "ops": 150145.93764782767, + "total": 0.09224358791834675, + "iterations": 1 + } + } + ], + "datetime": "2026-05-11T16:59:21.258845+00:00", + "version": "5.2.3" +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7869487..a815ad6 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -40,11 +40,30 @@ "problemMatcher": [] }, { - "label": "Run App (instrumented)", + "label": "Run App (instrumented/console exporter)", "type": "shell", - "command": "${workspaceFolder}${/}.venv${/}Scripts${/}opentelemetry-instrument ${command:python.interpreterPath} -m app 2>&1", + "command": "${workspaceFolder}${/}.venv${/}bin${/}opentelemetry-instrument ${workspaceFolder}${/}.venv${/}bin${/}python -m app 2>&1", "options": { "env": { + "APP_REDIS_HOSTNAME": "localhost", + "APP_REDIS_PORT": "6379", + "APP_MQTT_HOSTNAME": "localhost", + "APP_MQTT_PORT": "1883", + "APP_MQTT_IDENTIFIER": "app", + "APP_MQTT_CONNECT_TIMEOUT": "10", + "APP_MQTT_PUBLISH_TIMEOUT": "5", + //"APP_MQTT_SAT_TOKEN_PATH": "/var/run/secrets/tokens/broker-sat", + //"APP_MQTT_TLS_CERT_PATH": "/var/run/certs/ca.crt", + "APP_PROCESSING_MESSAGE_EXPIRY_INTERVAL": "10", + "APP_PROCESSING_SHARED_SUBSCRIPTION_NAME": "appsub", + "APP_PROCESSING_TOPIC_PREFIXES": "greetings:app/greetings,machinery-jobs:app/jobs", + "APP_PROCESSING_TOPIC_WILDCARD_LEVELS": "greetings:0,machinery-jobs:3", + "APP_PROCESSING_RESPONSE_TOPICS": "greetings:app/greetings/responses,machinery-jobs:app/jobs/responses", + "APP_PROCESSING_CLOUDEVENT_SOURCE": "https://aschamberger.github.com/microdcs/app", + "APP_LOGGING_LEVEL": "DEBUG", + "APP_MSGPACK_HOSTNAME": "localhost", + "APP_MSGPACK_PORT": "8888", + "KUBECONFIG": "${userHome}${/}.kube${/}config.yaml", "APP_PROCESSING_OTEL_INSTRUMENTATION_ENABLED": "true", "OTEL_SERVICE_NAME": "microdcs.app", "OTEL_TRACES_EXPORTER": "console", // console, otlp, none @@ -64,6 +83,70 @@ "dependsOrder": "parallel", "problemMatcher": [] }, + { + "label": "Run App (instrumented/Aspire dashboard)", + "type": "shell", + "command": "${workspaceFolder}${/}.venv${/}bin${/}opentelemetry-instrument ${workspaceFolder}${/}.venv${/}bin${/}python -m app 2>&1", + "options": { + "env": { + "APP_REDIS_HOSTNAME": "localhost", + "APP_REDIS_PORT": "6379", + "APP_MQTT_HOSTNAME": "localhost", + "APP_MQTT_PORT": "1883", + "APP_MQTT_IDENTIFIER": "app", + "APP_MQTT_CONNECT_TIMEOUT": "10", + "APP_MQTT_PUBLISH_TIMEOUT": "5", + //"APP_MQTT_SAT_TOKEN_PATH": "/var/run/secrets/tokens/broker-sat", + //"APP_MQTT_TLS_CERT_PATH": "/var/run/certs/ca.crt", + "APP_PROCESSING_MESSAGE_EXPIRY_INTERVAL": "10", + "APP_PROCESSING_SHARED_SUBSCRIPTION_NAME": "appsub", + "APP_PROCESSING_TOPIC_PREFIXES": "greetings:app/greetings,machinery-jobs:app/jobs", + "APP_PROCESSING_TOPIC_WILDCARD_LEVELS": "greetings:0,machinery-jobs:3", + "APP_PROCESSING_RESPONSE_TOPICS": "greetings:app/greetings/responses,machinery-jobs:app/jobs/responses", + "APP_PROCESSING_CLOUDEVENT_SOURCE": "https://aschamberger.github.com/microdcs/app", + "APP_LOGGING_LEVEL": "DEBUG", + "APP_MSGPACK_HOSTNAME": "localhost", + "APP_MSGPACK_PORT": "8888", + "KUBECONFIG": "${userHome}${/}.kube${/}config.yaml", + "APP_PROCESSING_OTEL_INSTRUMENTATION_ENABLED": "true", + "OTEL_SERVICE_NAME": "microdcs.app", + "OTEL_TRACES_EXPORTER": "otlp", + "OTEL_METRICS_EXPORTER": "otlp", + "OTEL_LOGS_EXPORTER": "otlp", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc", + "OTEL_PYTHON_LOG_CORRELATION": "true", + "OTEL_PYTHON_LOG_LEVEL": "debug", + "OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED": "true", + "OTEL_PYTHON_DISABLED_INSTRUMENTATIONS": "system_metrics,threading", + "OTEL_SEMCONV_STABILITY_OPT_IN": "http,database,messaging" + } + }, + "group": "test", + "dependsOn": ["Start local MQTT broker", "Start local redis server", "Start Aspire dashboard"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "Start Aspire dashboard", + "type": "shell", + "command": "docker start aspire-dashboard 2>/dev/null || docker run --rm -p 18888:18888 -p 4317:18889 -p 4318:18890 -d --name aspire-dashboard mcr.microsoft.com/dotnet/aspire-dashboard:latest", + "group": "test", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": ".", + "file": 1, + "location": 2, + "message": 3 + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".", + "endsPattern": "." + } + } + }, { "label": "Start local MQTT broker", "type": "shell", diff --git a/Dockerfile b/Dockerfile index 0a60136..27d3fee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,9 @@ # Use a Python image with uv pre-installed FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder +# Add git for installing dependencies from git repositories +RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* + # Install the project into `/app` WORKDIR /app @@ -62,5 +65,5 @@ ENV PYTHONUNBUFFERED=1 ENTRYPOINT [] # Run the application -#CMD ["opentelemetry-instrument", "python3", "-m", "app", "2>&1"] -CMD ["python3", "-m", "app"] +CMD ["opentelemetry-instrument", "python3", "-m", "app"] +#CMD ["python3", "-m", "app"] diff --git a/docs/development.md b/docs/development.md index f05f399..9affda8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -52,6 +52,31 @@ Run test coverage: uv run pytest --cov=microdcs --cov-report=term-missing tests/ --ignore=tests/test_mqtt_integration.py --ignore=tests/test_msgpack_integration.py ``` +Run benchmarks and save results: + +```bash +uv run pytest --benchmark-only --benchmark-autosave +``` + +Compare against a previously saved baseline: + +```bash +# Save current results as the named baseline +uv run pytest --benchmark-only --benchmark-save=baseline + +# Later, compare against that baseline +uv run pytest --benchmark-only --benchmark-compare=baseline +``` + +Benchmarks are automatically disabled during normal test runs. They only execute when `--benchmark-only` is passed, or when running the full suite with `--benchmark-autosave` (which runs them alongside regular tests and persists results under `.benchmarks/`). + +The benchmarked tests cover the per-message hot paths: + +* **`test_common.py`** — `CloudEvent` JSON and msgpack round-trips; payload serialization/deserialization +* **`test_dataclass.py`** — `DataClassMixin` JSON and msgpack round-trips +* **`test_sfc_recipe.py`** — `SfcStep` and full `SfcRecipe` JSON and msgpack round-trips +* **`statemachine_test.py`** — ISA95 job state machine trigger dispatch + Run the example application: ```bash @@ -213,6 +238,47 @@ Generate the SFC recipe dataclasses: uv run microdcs dataclassgen dataclasses sfc_recipe.schema.json ``` +## OpenTelemetry Analysis with Aspire Dashboard + +The [Aspire dashboard](https://aspire.dev/dashboard/standalone/) can be run as a standalone container to visualise traces, metrics, and structured logs from any OpenTelemetry-enabled app — no .NET or full Aspire installation required. + +Start the dashboard container: + +```bash +docker start aspire-dashboard 2>/dev/null || docker run --rm -p 18888:18888 -p 4317:18889 -p 4318:18890 -d --name aspire-dashboard mcr.microsoft.com/dotnet/aspire-dashboard:latest +``` + +Port mapping: + +| Host port | Container port | Purpose | +|---|---|---| +| `18888` | `18888` | Dashboard UI | +| `4317` | `18889` | OTLP/gRPC endpoint | +| `4318` | `18890` | OTLP/HTTP endpoint | + +Open `http://localhost:18888` in the browser. The dashboard is secured with a login token by default — retrieve it from the container logs: + +```bash +docker logs aspire-dashboard 2>&1 | grep "login?t=" +``` + +To run the app and send telemetry to the dashboard, configure the OTLP exporter and start via `opentelemetry-instrument`: + +```bash +APP_PROCESSING_OTEL_INSTRUMENTATION_ENABLED=true \ +OTEL_SERVICE_NAME=microdcs.app \ +OTEL_TRACES_EXPORTER=otlp \ +OTEL_METRICS_EXPORTER=otlp \ +OTEL_LOGS_EXPORTER=otlp \ +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ +OTEL_EXPORTER_OTLP_PROTOCOL=grpc \ +OTEL_PYTHON_LOG_CORRELATION=true \ +OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true \ + .venv/bin/opentelemetry-instrument python -m app +``` + +Or use the VS Code task **Run App (instrumented/Aspire dashboard)** which starts MQTT, Redis, and the Aspire dashboard automatically. + ## Documentation The documentation site is built with Zensical. diff --git a/docs/operations.md b/docs/operations.md index 628b5e6..9386f7b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -48,15 +48,12 @@ structure `APP_{SECTION}_{FIELD}`. | `APP_MQTT_HOSTNAME` | `str` | `localhost` | MQTT broker hostname | | `APP_MQTT_PORT` | `int` | `1883` | MQTT broker port | | `APP_MQTT_IDENTIFIER` | `str` | `app_client` | MQTT client identifier | -| `APP_MQTT_CONNECT_TIMEOUT` | `int` | `10` | Broker connection timeout (seconds) | -| `APP_MQTT_PUBLISH_TIMEOUT` | `int` | `5` | Publish confirmation timeout (seconds) | | `APP_MQTT_SAT_TOKEN_PATH` | `Path` | `/var/run/secrets/tokens/broker-sat` | Path to SAT token for broker auth | | `APP_MQTT_TLS_CERT_PATH` | `Path` | `/var/run/certs/ca.crt` | CA certificate for TLS connections | -| `APP_MQTT_INCOMING_QUEUE_SIZE` | `int` | `0` (unbounded) | Max queued incoming messages in aiomqtt client | -| `APP_MQTT_OUTGOING_QUEUE_SIZE` | `int` | `0` (unbounded) | Max queued outgoing messages in aiomqtt client | | `APP_MQTT_MESSAGE_WORKERS` | `int` | `5` | Concurrent tasks processing incoming messages | | `APP_MQTT_DEDUPE_TTL_SECONDS` | `int` | `600` | TTL for Redis deduplication keys (seconds) | | `APP_MQTT_BINDING_OUTGOING_QUEUE_SIZE` | `int` | `5` | Per-binding outgoing queue capacity | +| `APP_MQTT_SESSION_EXPIRY_INTERVAL` | `int` | `4294967295` | MQTT v5 session expiry interval in seconds (`2³²−1` = never expire) | ### MessagePack RPC (`APP_MSGPACK_*`) @@ -255,8 +252,6 @@ Each queue has a distinct fill condition and a distinct consequence when full. | Queue | Config variable | Default | Fills when | Producer behaviour when full | |---|---|---|---|---| -| MQTT incoming | `APP_MQTT_INCOMING_QUEUE_SIZE` | `0` (unbounded) | Messages arrive faster than `message_workers` can dispatch | With default `0`: unbounded growth until OOM. With a finite value: backpressure to aiomqtt receive loop — new messages are not read from the socket until space is available | -| MQTT outgoing | `APP_MQTT_OUTGOING_QUEUE_SIZE` | `0` (unbounded) | Processors produce outgoing events faster than the MQTT handler can publish | With default `0`: unbounded growth until OOM. With a finite value: paho client blocks publish calls until space is available | | MQTT binding outgoing | `APP_MQTT_BINDING_OUTGOING_QUEUE_SIZE` | `5` | A single binding's outgoing events accumulate faster than the handler drains them | **Raises `RuntimeError`** — the producer is not blocked, the error propagates to the caller | | MessagePack binding outgoing | `APP_MSGPACK_BINDING_OUTGOING_QUEUE_SIZE` | `5` | Outgoing notification frames queue faster than connected clients consume them | **Raises `RuntimeError`** — same behaviour as MQTT binding queues | | MessagePack concurrent requests | `APP_MSGPACK_MAX_CONCURRENT_REQUESTS` | `10` | More than N simultaneous `publish` RPC calls arrive from the same client | Server stops reading from the socket — TCP-level backpressure to the client | @@ -267,12 +262,6 @@ Each queue has a distinct fill condition and a distinct consequence when full. to the cap value with a warning log. If the protocol-level setting is `0`, the cap value is used as the effective size. -!!! warning "Unbounded MQTT queues by default" - The MQTT incoming and outgoing queues default to `0` (unbounded). This means they will - never apply backpressure — instead, memory grows without bound under sustained overload. - For production deployments, set explicit finite values for `APP_MQTT_INCOMING_QUEUE_SIZE` - and `APP_MQTT_OUTGOING_QUEUE_SIZE` based on your expected burst profile. - ### Isolation The binding-level queues (`APP_MQTT_BINDING_OUTGOING_QUEUE_SIZE`, @@ -280,12 +269,6 @@ The binding-level queues (`APP_MQTT_BINDING_OUTGOING_QUEUE_SIZE`, saturated binding does not affect other bindings — a tightening controller processor that falls behind does not block a QA camera processor from publishing its results. -The shared queues (`APP_MQTT_INCOMING_QUEUE_SIZE`, `APP_MQTT_OUTGOING_QUEUE_SIZE`) -sit at the handler level and are shared across all bindings registered with that -handler. A sustained fill on either of these affects the whole handler and therefore -all processors attached to it. Sizing these queues appropriately for the expected burst -profile is the primary tuning lever for the shared transport layer. - ### Tuning Signals Before a queue fills, it will show up as latency — the time between an MQTT message diff --git a/pyproject.toml b/pyproject.toml index d245e10..cd2825d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,10 +8,13 @@ authors = [ ] requires-python = ">=3.14" dependencies = [ - "aiomqtt>=2.4.0", + "aiomqtt @ git+https://github.com/empicano/aiomqtt.git@main", "mashumaro[orjson]>=3.21", "msgpack>=1.1.2", "opentelemetry-distro>=0.60b1", + "opentelemetry-exporter-otlp-proto-grpc>=1.41.1", + "opentelemetry-exporter-otlp-proto-http>=1.41.1", + "opentelemetry-instrumentation-aiomqtt", "opentelemetry-instrumentation-redis>=0.62b0", "redis[hiredis]>=7.4.0", "transitions>=0.9.3", @@ -31,6 +34,7 @@ dev = [ "pytest>=9.0.2", "pytest-asyncio>=1.3.0", "pytest-cov>=7.0.0", + "pytest-benchmark>=5.2.3", ] [project.scripts] @@ -46,5 +50,15 @@ markers = [ "integration: integration tests requiring external services (MQTT, Redis)", ] +[tool.uv] +override-dependencies = [ + "opentelemetry-api==1.41.1", + "opentelemetry-instrumentation==0.62b1", + "opentelemetry-semantic-conventions==0.62b1", +] + +[tool.uv.sources] +opentelemetry-instrumentation-aiomqtt = { git = "https://github.com/aschamberger/opentelemetry-python-contrib", subdirectory = "instrumentation/opentelemetry-instrumentation-aiomqtt", branch = "feat/aiomqtt-instrumentation" } + [tool.ruff] format.preview = true diff --git a/src/microdcs/__init__.py b/src/microdcs/__init__.py index 4780eda..987eb2d 100644 --- a/src/microdcs/__init__.py +++ b/src/microdcs/__init__.py @@ -32,15 +32,12 @@ class MQTTConfig: hostname: str = "localhost" port: int = 1883 identifier: str = "app_client" - connect_timeout: int = 10 - publish_timeout: int = 5 sat_token_path: Path = Path("/var/run/secrets/tokens/broker-sat") tls_cert_path: Path = Path("/var/run/certs/ca.crt") - incoming_queue_size: int = 0 - outgoing_queue_size: int = 0 message_workers: int = 5 dedupe_ttl_seconds: int = 60 * 10 # 10 minutes binding_outgoing_queue_size: int = 5 + session_expiry_interval: int = 2**32 - 1 # never expire @dataclass @@ -321,10 +318,6 @@ def require_positive(value: int, field_name: str) -> None: require_port(self.mqtt.port, "mqtt.port") require_port(self.msgpack.port, "msgpack.port") - require_positive(self.mqtt.connect_timeout, "mqtt.connect_timeout") - require_positive(self.mqtt.publish_timeout, "mqtt.publish_timeout") - require_non_negative(self.mqtt.incoming_queue_size, "mqtt.incoming_queue_size") - require_non_negative(self.mqtt.outgoing_queue_size, "mqtt.outgoing_queue_size") require_positive(self.mqtt.message_workers, "mqtt.message_workers") require_non_negative(self.mqtt.dedupe_ttl_seconds, "mqtt.dedupe_ttl_seconds") require_non_negative( diff --git a/src/microdcs/mqtt.py b/src/microdcs/mqtt.py index de9b291..7ed8e23 100644 --- a/src/microdcs/mqtt.py +++ b/src/microdcs/mqtt.py @@ -1,22 +1,17 @@ import asyncio import dataclasses import enum +import functools import logging -import random -import time +import re +import ssl import uuid -from typing import Any import aiomqtt -import paho.mqtt.client +import mqtt5 import redis.asyncio as redis -from opentelemetry import metrics, trace -from opentelemetry.propagate import extract, inject +from opentelemetry import trace from opentelemetry.semconv._incubating.attributes import messaging_attributes -from opentelemetry.semconv._incubating.metrics import messaging_metrics -from opentelemetry.semconv.attributes import error_attributes, server_attributes -from paho.mqtt.packettypes import PacketTypes -from paho.mqtt.properties import Properties from microdcs import MQTTConfig, ProcessingConfig from microdcs.common import ( @@ -42,44 +37,75 @@ class QoS(enum.IntEnum): def create_mqtt_client( config: MQTTConfig, client_identifier: str | None = None, - **kwargs: Any, + *, + clean_start: bool = False, + reconnect: bool = True, ) -> aiomqtt.Client: """Create an ``aiomqtt.Client`` from a :class:`MQTTConfig`. Shared between :class:`MQTTHandler` (subscriber) and :class:`MQTTPublisher` (retained-publish writer) to avoid duplicating - connection setup. Extra *kwargs* are forwarded to the - ``aiomqtt.Client`` constructor (e.g. ``clean_start``, - ``max_queued_incoming_messages``). + connection setup. *client_identifier* overrides ``config.identifier`` when provided, which allows callers (e.g. :class:`MQTTPublisher`) to use a different MQTT client ID than the handler without changing the shared config object. + + *clean_start* and *reconnect* default to the long-lived handler settings + (persistent session, auto-reconnect). Pass ``clean_start=True, + reconnect=False`` for short-lived clients such as integration-test helpers. """ - properties = None + authentication_method: str | None = None + authentication_data: bytes | None = None if config.sat_token_path.exists(): with open(config.sat_token_path, "rb") as f: - sat_token = f.read() - properties = Properties(PacketTypes.CONNECT) - properties.AuthenticationMethod = "K8S-SAT" - properties.AuthenticationData = sat_token - tls_params = None + authentication_method = "K8S-SAT" + authentication_data = f.read() + ssl_context: ssl.SSLContext | None = None if config.tls_cert_path.exists(): - tls_params = aiomqtt.TLSParameters(ca_certs=str(config.tls_cert_path)) + ssl_context = ssl.create_default_context(cafile=str(config.tls_cert_path)) return aiomqtt.Client( - protocol=aiomqtt.ProtocolVersion.V5, hostname=config.hostname, port=config.port, identifier=client_identifier if client_identifier is not None else config.identifier, - timeout=config.connect_timeout, - properties=properties, - tls_params=tls_params, - **kwargs, + authentication_method=authentication_method, + authentication_data=authentication_data, + ssl_context=ssl_context, + session_expiry_interval=config.session_expiry_interval, + clean_start=clean_start, + reconnect=reconnect, ) +@functools.lru_cache(maxsize=256) +def _compile_topic_pattern(pattern: str) -> re.Pattern[str]: + """Compile an MQTT subscription pattern to a regex. + + Patterns are fixed at binding setup time, so the cache hit rate is + effectively 100% after warm-up. + """ + escaped = re.escape(pattern) + regex = escaped.replace(r"\+", "[^/]*").replace(r"/\#", "(|/.*)") + return re.compile(regex) + + +def _topic_matches(pattern: str, topic: str) -> bool: + """Check if an MQTT topic matches a subscription pattern. + + Supports '+' (single-level wildcard) and '#' (multi-level wildcard). + Shared subscription prefixes (``$share//``) in *pattern* are + stripped before matching, because the broker delivers messages with the + original topic (without the prefix). + """ + if pattern.startswith("$share/"): + pattern = pattern.split("/", 2)[2] + if pattern == "#": + return True + return bool(_compile_topic_pattern(pattern).fullmatch(topic)) + + class MQTTHandler(ProtocolHandler["MQTTProtocolBinding"]): def __init__( self, @@ -98,18 +124,13 @@ def __init__( redis_key_schema, ttl=self._runtime_config.dedupe_ttl_seconds, ) - self._expiration_timeout_tasks: dict[str, Any] = {} + self._expiration_timeout_tasks: dict[str, asyncio.Task[object]] = {} def _client(self) -> aiomqtt.Client: client = create_mqtt_client( self._runtime_config, client_identifier=self._runtime_config.identifier + "-proc", - clean_start=paho.mqtt.client.MQTT_CLEAN_START_FIRST_ONLY, - max_queued_incoming_messages=self._runtime_config.incoming_queue_size, - max_queued_outgoing_messages=self._runtime_config.outgoing_queue_size, ) - # FIXME: set this as a aiomqtt client property when https://github.com/empicano/aiomqtt/pull/346 is merged - client._client.manual_ack_set(True) # type: ignore # return client async def _publish_message( @@ -128,46 +149,49 @@ async def _publish_message( "Publishing message to topic %s", cloudevent.transportmetadata.get("mqtt_topic"), ) - qos: int = QoS.AT_MOST_ONCE - properties = Properties(PacketTypes.PUBLISH) + qos: mqtt5.QoS = mqtt5.QoS.AT_MOST_ONCE + message_expiry_interval: int | None = None if cloudevent.expiryinterval is not None: - properties.MessageExpiryInterval = cloudevent.expiryinterval + message_expiry_interval = int(cloudevent.expiryinterval) + content_type: str | None = None if cloudevent.datacontenttype is not None: - properties.ContentType = cloudevent.datacontenttype - if cloudevent.datacontenttype == "application/octet-stream": - properties.PayloadFormatIndicator = 0 # bytes - else: - properties.PayloadFormatIndicator = 1 # UTF-8 string + content_type = cloudevent.datacontenttype + response_topic: str | None = None if ( cloudevent.transportmetadata is not None and cloudevent.transportmetadata.get("mqtt_response_topic") is not None ): - properties.ResponseTopic = cloudevent.transportmetadata.get( - "mqtt_response_topic" - ) - qos = QoS.AT_LEAST_ONCE + response_topic = cloudevent.transportmetadata.get("mqtt_response_topic") + qos = mqtt5.QoS.AT_LEAST_ONCE _correlation_data_id = ( cloudevent.causationid if cloudevent.causationid is not None else cloudevent.id ) + correlation_data: bytes | None = None if _correlation_data_id is not None: - properties.CorrelationData = uuid.UUID(_correlation_data_id).bytes # type: ignore - # Convert dictionary to list of tuples - properties.UserProperty = list( + correlation_data = uuid.UUID(_correlation_data_id).bytes + # Convert dictionary to list of tuples for user properties + user_properties: list[tuple[str, str]] = list( cloudevent.to_dict( context={"remove_data": True, "make_str_values": True} ).items() ) await client.publish( cloudevent.transportmetadata.get("mqtt_topic", ""), - cloudevent.data, + cloudevent.data or b"", qos=qos, + packet_id=next(client.packet_ids) + if qos != mqtt5.QoS.AT_MOST_ONCE + else None, retain=cloudevent.transportmetadata.get("mqtt_retain", False) if cloudevent.transportmetadata else False, - properties=properties, - timeout=self._runtime_config.publish_timeout, + message_expiry_interval=message_expiry_interval, + content_type=content_type, + response_topic=response_topic, + correlation_data=correlation_data, + user_properties=user_properties, ) # schedule expiration handling if applicable if ( @@ -217,34 +241,32 @@ async def _is_duplicate_message(self, cloudevent: CloudEvent) -> bool: str(cloudevent.source), str(cloudevent.id) ) - def _cloudevent_from_message(self, message: aiomqtt.Message) -> CloudEvent: + def _cloudevent_from_message(self, message: mqtt5.PublishPacket) -> CloudEvent: # Construct CloudEvent from MQTT message cloudevent = CloudEvent(data=message.payload) # Populate transport metadata cloudevent.transportmetadata = { - "mqtt_message_id": message.mid, - "mqtt_topic": str(message.topic), - "mqtt_qos": QoS(message.qos), + "mqtt_message_id": message.packet_id, + "mqtt_topic": message.topic, + "mqtt_qos": QoS(int(message.qos)), "mqtt_retain": message.retain, } - # Populate from MQTT 5 properties - if message.properties and hasattr(message.properties, "PayloadFormatIndicator"): - pass - if message.properties and hasattr(message.properties, "MessageExpiryInterval"): - cloudevent.expiryinterval = message.properties.MessageExpiryInterval # type: ignore - if message.properties and hasattr(message.properties, "ContentType"): - cloudevent.datacontenttype = str(message.properties.ContentType) # type: ignore - if message.properties and hasattr(message.properties, "ResponseTopic"): + # Populate from MQTT 5 properties (now direct attributes on PublishPacket) + if message.message_expiry_interval is not None: + cloudevent.expiryinterval = message.message_expiry_interval + if message.content_type is not None: + cloudevent.datacontenttype = str(message.content_type) + if message.response_topic is not None: cloudevent.transportmetadata["mqtt_response_topic"] = str( - message.properties.ResponseTopic # type: ignore + message.response_topic ) - if message.properties and hasattr(message.properties, "CorrelationData"): + if message.correlation_data is not None: cloudevent.transportmetadata["mqtt_correlation_data"] = str( - uuid.UUID(bytes=message.properties.CorrelationData) # type: ignore + uuid.UUID(bytes=message.correlation_data) ) - if message.properties and hasattr(message.properties, "UserProperty"): + if message.user_properties is not None: # Convert list of tuples to dictionary - cloudevent.custommetadata = dict(message.properties.UserProperty) # type: ignore + cloudevent.custommetadata = dict(message.user_properties) # Populate CloudEvent attributes from user properties if present for field in dataclasses.fields(CloudEvent): if ( @@ -260,22 +282,32 @@ def _cloudevent_from_message(self, message: aiomqtt.Message) -> CloudEvent: return cloudevent async def _process_message( - self, client: aiomqtt.Client, message: aiomqtt.Message + self, client: aiomqtt.Client, message: mqtt5.PublishPacket ) -> tuple[bool, str]: + """Process a single incoming MQTT message end-to-end. + + Deserialises the raw publish packet into a :class:`CloudEvent`, performs + deduplication, cancels any pending expiration task for incoming responses, + dispatches to matching processor bindings, and ACKs QoS 1 messages. + + Returns a ``(success, subscription)`` tuple where *success* is ``False`` + for duplicate messages and *subscription* is a comma-joined string of + the binding topic patterns that matched. + """ # extract CloudEvent from MQTT message cloudevent = self._cloudevent_from_message(message) # check for duplicate message IDs due to QoS 1 (at-least-once delivery) if await self._is_duplicate_message(cloudevent): logger.info( - "Duplicate message received on topic %s with message ID %d", + "Duplicate message received on topic %s with message ID %s", message.topic, - message.mid, + message.packet_id, ) for binding in self._bindings: - if message.topic.matches(binding.response_topic): + if _topic_matches(binding.response_topic, message.topic): return False, binding.response_topic for topic in binding.topics: - if message.topic.matches(topic): + if _topic_matches(topic, message.topic): return False, topic return False, "" else: @@ -294,7 +326,7 @@ async def _process_message( # If multiple processors match the topic, all will be invoked sequentially subscription: list[str] = [] for binding in self._bindings: - if message.topic.matches(binding.response_topic): + if _topic_matches(binding.response_topic, message.topic): subscription.append(binding.response_topic) processor_response = ( await binding.processor.process_response_cloudevent(cloudevent) @@ -309,7 +341,7 @@ async def _process_message( elif processor_response is None: continue for topic in binding.topics: - if message.topic.matches(topic): + if _topic_matches(topic, message.topic): subscription.append(topic) processor_response = await binding.processor.process_cloudevent( cloudevent @@ -332,15 +364,17 @@ async def _process_message( elif processor_response is None: continue - # FIXME: use the native ack method when https://github.com/empicano/aiomqtt/pull/346 is merged - client._client.ack(message.mid, message.qos) # type: ignore # + # Acknowledge QoS 1 messages using the native puback method + if message.packet_id is not None: + await client.puback(message.packet_id) return True, ", ".join(subscription) async def _process_messages(self, client: aiomqtt.Client) -> None: logger.info("Starting MQTT message processing") - message: aiomqtt.Message - async for message in client.messages: + async for message in client.messages(): + if isinstance(message, aiomqtt.PubRelPacket): + continue # skip QoS 2 pubrel packets (not used in this handler) # Shield message processing from cancellation so that in-flight # messages are fully processed, ACKed, and expiration tasks set up. processing = asyncio.create_task(self._process_message(client, message)) @@ -358,8 +392,20 @@ async def _outgoing_message_publisher( cloudevent, intent = await binding.outgoing_queue.get() # Enrich with MQTT transport metadata from the binding binding.enrich_publish_transportmetadata(intent, cloudevent) - - await self._publish_message(client, cloudevent, binding.processor) + # Retry publish if the connection is temporarily down + while True: + try: + await self._publish_message(client, cloudevent, binding.processor) + break + except ( + aiomqtt.ConnectError, + aiomqtt.ProtocolError, + aiomqtt.NegativeAckError, + ): + logger.warning( + "Publish failed while reconnecting; waiting for connection" + ) + await client.connected() binding.outgoing_queue.task_done() async def _cancel_and_wait(self, tasks: list[asyncio.Task]) -> None: @@ -376,342 +422,167 @@ async def task(self) -> None: self._runtime_config.hostname, self._runtime_config.port, ) - backoff = 1 # seconds - max_backoff = 60 # seconds - while True: - client: aiomqtt.Client = self._client() - try: - # redis is required for message deduplication and expiration handling, - # so we check the connection before starting the MQTT client - try: - await self._redis_client.ping() # pyright: ignore[reportGeneralTypeIssues] - except redis.RedisError as e: - logger.error(f"Error connecting to Redis: {e}") - raise - async with client: - backoff = 1 # reset backoff after successful connection - for binding in self._bindings: - for topic in binding.topics: - await client.subscribe(topic) - logger.info("Subscribed to topic: %s", topic) - await client.subscribe(binding.response_topic) - logger.info( - "Subscribed to response topic: %s", - binding.response_topic, - ) - # Start worker and publisher tasks (manual management - # instead of TaskGroup for controlled graceful shutdown) - worker_tasks: list[asyncio.Task] = [] - publisher_tasks: list[asyncio.Task] = [] + # redis is required for message deduplication and expiration handling, + # so we check the connection before starting the MQTT client + try: + await self._redis_client.ping() # pyright: ignore[reportGeneralTypeIssues] + except redis.RedisError as e: + logger.error(f"Error connecting to Redis: {e}") + raise + client: aiomqtt.Client = self._client() + try: + async with client: + for binding in self._bindings: + for topic in binding.topics: + await client.subscribe(aiomqtt.TopicFilter(topic)) + logger.info("Subscribed to topic: %s", topic) + await client.subscribe(aiomqtt.TopicFilter(binding.response_topic)) logger.info( - "Starting %d message worker tasks", - self._runtime_config.message_workers, + "Subscribed to response topic: %s", + binding.response_topic, ) - for _ in range(self._runtime_config.message_workers): - worker_tasks.append( - asyncio.create_task(self._process_messages(client)) - ) - for binding in self._bindings: - publisher_tasks.append( - asyncio.create_task( - self._outgoing_message_publisher(client, binding) - ) + # Start worker and publisher tasks (manual management + # instead of TaskGroup for controlled graceful shutdown) + worker_tasks: list[asyncio.Task] = [] + publisher_tasks: list[asyncio.Task] = [] + logger.info( + "Starting %d message worker tasks", + self._runtime_config.message_workers, + ) + for _ in range(self._runtime_config.message_workers): + worker_tasks.append( + asyncio.create_task(self._process_messages(client)) + ) + for binding in self._bindings: + publisher_tasks.append( + asyncio.create_task( + self._outgoing_message_publisher(client, binding) ) - all_tasks = worker_tasks + publisher_tasks + ) + all_tasks = worker_tasks + publisher_tasks - try: - # Wait for shutdown event or an unexpected task failure - shutdown_waiter = asyncio.create_task( - self._shutdown_event.wait() - ) - done, _ = await asyncio.wait( - set(all_tasks) | {shutdown_waiter}, - return_when=asyncio.FIRST_COMPLETED, - ) + try: + # Wait for shutdown event or an unexpected task failure + shutdown_waiter = asyncio.create_task(self._shutdown_event.wait()) + done, _ = await asyncio.wait( + set(all_tasks) | {shutdown_waiter}, + return_when=asyncio.FIRST_COMPLETED, + ) - if shutdown_waiter in done: - # === Graceful shutdown sequence === - # Phase 1: Unsubscribe – stop receiving new messages - logger.info( - "Graceful shutdown: unsubscribing from MQTT topics" - ) - for binding in self._bindings: - for topic in binding.topics: - try: - await client.unsubscribe(topic) - logger.info( - "Unsubscribed from topic: %s", topic - ) - except aiomqtt.MqttError: - logger.warning( - "Failed to unsubscribe from topic: %s", - topic, - ) + if shutdown_waiter in done: + # === Graceful shutdown sequence === + # Phase 1: Unsubscribe – stop receiving new messages + logger.info("Graceful shutdown: unsubscribing from MQTT topics") + for binding in self._bindings: + for topic in binding.topics: try: - await client.unsubscribe(binding.response_topic) - logger.info( - "Unsubscribed from response topic: %s", - binding.response_topic, - ) - except aiomqtt.MqttError: + await client.unsubscribe(topic) + logger.info("Unsubscribed from topic: %s", topic) + except ( + aiomqtt.ConnectError, + aiomqtt.ProtocolError, + aiomqtt.NegativeAckError, + ): logger.warning( - "Failed to unsubscribe from response topic: %s", - binding.response_topic, + "Failed to unsubscribe from topic: %s", + topic, ) + try: + await client.unsubscribe(binding.response_topic) + logger.info( + "Unsubscribed from response topic: %s", + binding.response_topic, + ) + except ( + aiomqtt.ConnectError, + aiomqtt.ProtocolError, + aiomqtt.NegativeAckError, + ): + logger.warning( + "Failed to unsubscribe from response topic: %s", + binding.response_topic, + ) - # Phase 2: Cancel workers – shielded processing - # ensures in-flight messages finish (process, ack, - # create expiration tasks, enqueue responses) - await self._cancel_and_wait(worker_tasks) - logger.info("All message workers completed") - - # Phase 3: Drain outgoing queues – send remaining - # outgoing events that were enqueued during processing - for binding in self._bindings: - if not binding.outgoing_queue.empty(): - logger.info( - "Draining outgoing queue (%d items)", - binding.outgoing_queue.qsize(), - ) - await binding.outgoing_queue.join() - - # Phase 4: Cancel publishers – queues are drained - await self._cancel_and_wait(publisher_tasks) - logger.info("All publishers completed") - - # Phase 5: Wait for expiration timeout tasks - pending = [ - task - for task in self._expiration_timeout_tasks.values() - if not task.done() - ] - if pending: + # Phase 2: Cancel workers – shielded processing + # ensures in-flight messages finish (process, ack, + # create expiration tasks, enqueue responses) + await self._cancel_and_wait(worker_tasks) + logger.info("All message workers completed") + + # Phase 3: Drain outgoing queues – send remaining + # outgoing events that were enqueued during processing + for binding in self._bindings: + if not binding.outgoing_queue.empty(): logger.info( - "Waiting for %d expiration timeout task(s)", - len(pending), + "Draining outgoing queue (%d items)", + binding.outgoing_queue.qsize(), ) - await asyncio.gather(*pending, return_exceptions=True) - - logger.info("MQTT graceful shutdown complete") - break # exit retry loop - else: - # A worker/publisher died unexpectedly - shutdown_waiter.cancel() - await self._cancel_and_wait(all_tasks) - for task in done: - if ( - not task.cancelled() - and task.exception() is not None - ): - raise task.exception() # pyright: ignore[reportGeneralTypeIssues] + await binding.outgoing_queue.join() + + # Phase 4: Cancel publishers – queues are drained + await self._cancel_and_wait(publisher_tasks) + logger.info("All publishers completed") + + # Phase 5: Wait for expiration timeout tasks + pending = [ + task + for task in self._expiration_timeout_tasks.values() + if not task.done() + ] + if pending: + logger.info( + "Waiting for %d expiration timeout task(s)", + len(pending), + ) + await asyncio.gather(*pending, return_exceptions=True) - except asyncio.CancelledError: - # Force shutdown (grace period exceeded) – - # cancel everything including expiration tasks + logger.info("MQTT graceful shutdown complete") + else: + # A worker/publisher died unexpectedly + shutdown_waiter.cancel() await self._cancel_and_wait(all_tasks) - for task in list(self._expiration_timeout_tasks.values()): - if not task.done(): - task.cancel() - raise - - except aiomqtt.MqttError: - if self._shutdown_event.is_set(): - logger.info("MQTT connection lost during shutdown; exiting") - break - sleep_time = backoff + random.uniform(0, 0.1 * backoff) - logger.warning(f"Connection lost. Retrying in {sleep_time:.2f}s...") - try: - await asyncio.wait_for( - self._shutdown_event.wait(), timeout=sleep_time - ) - logger.info("Shutdown during reconnect backoff; exiting") - break - except asyncio.TimeoutError: - backoff = min(backoff * 2, max_backoff) + for task in done: + if not task.cancelled() and task.exception() is not None: + raise task.exception() # pyright: ignore[reportGeneralTypeIssues] + + except asyncio.CancelledError: + # Force shutdown (grace period exceeded) – + # cancel everything including expiration tasks + await self._cancel_and_wait(all_tasks) + for task in list(self._expiration_timeout_tasks.values()): + if not task.done(): + task.cancel() + raise - except asyncio.CancelledError: - logger.info("MQTT handler task cancelled; shutting down") - for task in list(self._expiration_timeout_tasks.values()): - if not task.done(): - task.cancel() - raise + except asyncio.CancelledError: + logger.info("MQTT handler task cancelled; shutting down") + for task in list(self._expiration_timeout_tasks.values()): + if not task.done(): + task.cancel() + raise logger.info("MQTT handler shutdown complete") class OTELInstrumentedMQTTHandler(MQTTHandler): - def __init__( - self, - runtime_config: MQTTConfig, - redis_connection_pool: redis.ConnectionPool, - redis_key_schema: "RedisKeySchema", - ): - super().__init__(runtime_config, redis_connection_pool, redis_key_schema) - - self._tracer: trace.Tracer = trace.get_tracer(__name__) - self._meter: metrics.Meter = metrics.get_meter(__name__) - self._consumed_messages = ( - messaging_metrics.create_messaging_client_consumed_messages(self._meter) - ) - self._process_duration = messaging_metrics.create_messaging_process_duration( - self._meter - ) - self._operation_duration = ( - messaging_metrics.create_messaging_client_operation_duration(self._meter) - ) - self._sent_messages = messaging_metrics.create_messaging_client_sent_messages( - self._meter - ) - async def _process_message( - self, client: aiomqtt.Client, message: aiomqtt.Message + self, client: aiomqtt.Client, message: mqtt5.PublishPacket ) -> tuple[bool, str]: - # extract creation context from MQTT user properties for trace linking - links: list[trace.Link] = [] - if message.properties and hasattr(message.properties, "UserProperty"): - user_props: list[tuple[str, str]] = list(message.properties.UserProperty) # type: ignore - if user_props: - creation_ctx = extract(dict(user_props)) - creation_span_ctx = trace.get_current_span( - creation_ctx - ).get_span_context() - if creation_span_ctx.is_valid: - links = [trace.Link(creation_span_ctx)] - - # span attributes (high-cardinality destination name kept on span only) - span_attrs: dict[str, Any] = { - messaging_attributes.MESSAGING_SYSTEM: "mqtt", - messaging_attributes.MESSAGING_OPERATION_TYPE: messaging_attributes.MessagingOperationTypeValues.PROCESS.value, - messaging_attributes.MESSAGING_OPERATION_NAME: "process", - messaging_attributes.MESSAGING_DESTINATION_NAME: str(message.topic), - messaging_attributes.MESSAGING_CLIENT_ID: self._runtime_config.identifier, - messaging_attributes.MESSAGING_MESSAGE_ID: str(message.mid), - server_attributes.SERVER_ADDRESS: self._runtime_config.hostname, - server_attributes.SERVER_PORT: self._runtime_config.port, - } - if isinstance(message.payload, (bytes, bytearray)): - span_attrs[messaging_attributes.MESSAGING_MESSAGE_BODY_SIZE] = len( - message.payload - ) - - # metric attributes (no high-cardinality destination name) - metric_attrs: dict[str, Any] = { - messaging_attributes.MESSAGING_SYSTEM: "mqtt", - messaging_attributes.MESSAGING_OPERATION_TYPE: messaging_attributes.MessagingOperationTypeValues.PROCESS.value, - messaging_attributes.MESSAGING_OPERATION_NAME: "process", - server_attributes.SERVER_ADDRESS: self._runtime_config.hostname, - server_attributes.SERVER_PORT: self._runtime_config.port, - } - - # count delivery before processing (tracks messages dispatched to application) - self._consumed_messages.add(1, metric_attrs) + """Extend base processing with ``MESSAGING_DESTINATION_SUBSCRIPTION_NAME``. - start = time.monotonic() - error_type: str | None = None - ok: bool = False - subscription: str = "" - - with self._tracer.start_as_current_span( - "process", - kind=trace.SpanKind.CONSUMER, - attributes=span_attrs, - links=links, - ) as span: - try: - ok, subscription = await super()._process_message(client, message) - span.set_attribute( - messaging_attributes.MESSAGING_DESTINATION_SUBSCRIPTION_NAME, - subscription, - ) - except Exception as exc: - error_type = type(exc).__qualname__ - span.record_exception(exc) - span.set_status(trace.Status(trace.StatusCode.ERROR)) - raise - finally: - duration = time.monotonic() - start - if error_type is not None: - self._process_duration.record( - duration, - metric_attrs | {error_attributes.ERROR_TYPE: error_type}, - ) - else: - self._process_duration.record(duration, metric_attrs) - - return ok, subscription - - async def _publish_message( - self, - client: aiomqtt.Client, - cloudevent: CloudEvent, - processor: CloudEventProcessor | None = None, - ) -> None: - topic = ( - cloudevent.transportmetadata.get("mqtt_topic") - if cloudevent.transportmetadata is not None - else None - ) - if topic is None: - await super()._publish_message(client, cloudevent, processor) - return - - span_attrs: dict[str, Any] = { - messaging_attributes.MESSAGING_SYSTEM: "mqtt", - messaging_attributes.MESSAGING_OPERATION_TYPE: messaging_attributes.MessagingOperationTypeValues.SEND.value, - messaging_attributes.MESSAGING_OPERATION_NAME: "publish", - messaging_attributes.MESSAGING_DESTINATION_NAME: str(topic), - messaging_attributes.MESSAGING_CLIENT_ID: self._runtime_config.identifier, - server_attributes.SERVER_ADDRESS: self._runtime_config.hostname, - server_attributes.SERVER_PORT: self._runtime_config.port, - } - if isinstance(cloudevent.data, (bytes, bytearray)): - span_attrs[messaging_attributes.MESSAGING_MESSAGE_BODY_SIZE] = len( - cloudevent.data + After ``super()._process_message()`` resolves which binding pattern + matched the incoming topic, the matched subscription string is set on + the active OpenTelemetry span. The aiomqtt auto-instrumentor cannot + provide this attribute because it wraps the low-level ``messages()`` + iterator and has no knowledge of the binding/subscription routing logic. + """ + ok, subscription = await super()._process_message(client, message) + if subscription: + trace.get_current_span().set_attribute( + messaging_attributes.MESSAGING_DESTINATION_SUBSCRIPTION_NAME, + subscription, ) - - metric_attrs: dict[str, Any] = { - messaging_attributes.MESSAGING_SYSTEM: "mqtt", - messaging_attributes.MESSAGING_OPERATION_TYPE: messaging_attributes.MessagingOperationTypeValues.SEND.value, - messaging_attributes.MESSAGING_OPERATION_NAME: "publish", - server_attributes.SERVER_ADDRESS: self._runtime_config.hostname, - server_attributes.SERVER_PORT: self._runtime_config.port, - } - - start = time.monotonic() - error_type: str | None = None - - with self._tracer.start_as_current_span( - "publish", - kind=trace.SpanKind.PRODUCER, - attributes=span_attrs, - ): - # inject current span context into custommetadata so it propagates as MQTT - # UserProperty — CloudEvent.__post_serialize__ flattens custommetadata keys - trace_headers: dict[str, str] = {} - inject(trace_headers) - if trace_headers: - if cloudevent.custommetadata is None: - cloudevent.custommetadata = {} - cloudevent.custommetadata.update(trace_headers) - - try: - await super()._publish_message(client, cloudevent, processor) - except Exception as exc: - error_type = type(exc).__qualname__ - trace.get_current_span().record_exception(exc) - trace.get_current_span().set_status( - trace.Status(trace.StatusCode.ERROR) - ) - raise - finally: - duration = time.monotonic() - start - if error_type is not None: - err_attrs = metric_attrs | {error_attributes.ERROR_TYPE: error_type} - self._operation_duration.record(duration, err_attrs) - self._sent_messages.add(1, err_attrs) - else: - self._operation_duration.record(duration, metric_attrs) - self._sent_messages.add(1, metric_attrs) + return ok, subscription class MQTTProtocolBinding(ProtocolBinding["MQTTHandler"]): @@ -886,15 +757,14 @@ async def publish_retained( ttl: Message Expiry Interval in seconds. """ assert self._client is not None, "Client not connected — use from within task()" - properties = Properties(PacketTypes.PUBLISH) - properties.MessageExpiryInterval = ttl publisher_logger.debug("Publishing retained message to %s (ttl=%d)", topic, ttl) await self._client.publish( topic, - payload, - qos=1, + payload.encode() if isinstance(payload, str) else payload, + qos=aiomqtt.QoS.AT_LEAST_ONCE, + packet_id=next(self._client.packet_ids), retain=True, - properties=properties, + message_expiry_interval=ttl, ) async def delete_retained(self, topic: str) -> None: @@ -908,7 +778,8 @@ async def delete_retained(self, topic: str) -> None: await self._client.publish( topic, b"", - qos=1, + qos=aiomqtt.QoS.AT_LEAST_ONCE, + packet_id=next(self._client.packet_ids), retain=True, ) @@ -917,7 +788,7 @@ async def _run(self) -> None: Override in subclasses to perform work (e.g. stream processing). The default implementation waits for the shutdown event. Any - :class:`aiomqtt.MqttError` raised here is caught by :meth:`task` + MQTT error raised here is caught by :meth:`task` which triggers reconnection. """ await self._shutdown_event.wait() @@ -928,40 +799,27 @@ async def task(self) -> None: self._config.hostname, self._config.port, ) - backoff = 1 # seconds - max_backoff = 60 # seconds - while True: - client = create_mqtt_client( - self._config, - client_identifier=self._config.identifier + "-pub", - ) - try: - async with client: - self._client = client - self._connected.set() - publisher_logger.info("MQTT publisher connected") - await self._run() - self._client = None - self._connected.clear() - publisher_logger.info("MQTT publisher shutdown complete") - return - except aiomqtt.MqttError: - self._client = None - self._connected.clear() - if self._shutdown_event.is_set(): - publisher_logger.info( - "MQTT connection lost during shutdown; exiting" - ) - return - sleep_time = backoff + random.uniform(0, 0.1 * backoff) - publisher_logger.warning( - "Connection lost. Retrying in %.2fs...", sleep_time - ) + client = create_mqtt_client( + self._config, + client_identifier=self._config.identifier + "-pub", + ) + async with client: + self._client = client + self._connected.set() + publisher_logger.info("MQTT publisher connected") + while True: try: - await asyncio.wait_for( - self._shutdown_event.wait(), timeout=sleep_time + await self._run() + break # normal exit (shutdown event) + except ( + aiomqtt.ConnectError, + aiomqtt.ProtocolError, + aiomqtt.NegativeAckError, + ): + publisher_logger.warning( + "Connection lost in _run(); waiting for reconnect" ) - publisher_logger.info("Shutdown during reconnect backoff; exiting") - return - except asyncio.TimeoutError: - backoff = min(backoff * 2, max_backoff) + await client.connected() + self._client = None + self._connected.clear() + publisher_logger.info("MQTT publisher shutdown complete") diff --git a/src/microdcs/processors/machinery_jobs.py b/src/microdcs/processors/machinery_jobs.py index 4d0b0d6..d62acf1 100644 --- a/src/microdcs/processors/machinery_jobs.py +++ b/src/microdcs/processors/machinery_jobs.py @@ -3,7 +3,7 @@ from typing import Any import redis.asyncio as redis -from transitions.extensions import HierarchicalGraphMachine +from transitions.extensions import HierarchicalMachine from microdcs import ProcessingConfig from microdcs.common import ( @@ -162,7 +162,12 @@ def __init__( self._job_acceptance_config_dao = JobAcceptanceConfigDAO( self._redis_client, redis_key_schema ) - self._state_machine = HierarchicalGraphMachine( + # model_override=True means transitions only overrides methods that already + # exist on the model (the trigger/may_trigger stubs in JobStateMixin). For + # every other method it cannot bind, it emits a WARNING — ~70 per add_model + # call. Silence those warnings since this is intentional design. + logging.getLogger("transitions.core").setLevel(logging.ERROR) + self._state_machine = HierarchicalMachine( model=None, states=JobOrderControlExt.Config.opcua_state_machine_states, transitions=JobOrderControlExt.Config.opcua_state_machine_transitions, diff --git a/src/microdcs/scripts/init/Dockerfile b/src/microdcs/scripts/init/Dockerfile index 0a60136..27d3fee 100644 --- a/src/microdcs/scripts/init/Dockerfile +++ b/src/microdcs/scripts/init/Dockerfile @@ -3,6 +3,9 @@ # Use a Python image with uv pre-installed FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder +# Add git for installing dependencies from git repositories +RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* + # Install the project into `/app` WORKDIR /app @@ -62,5 +65,5 @@ ENV PYTHONUNBUFFERED=1 ENTRYPOINT [] # Run the application -#CMD ["opentelemetry-instrument", "python3", "-m", "app", "2>&1"] -CMD ["python3", "-m", "app"] +CMD ["opentelemetry-instrument", "python3", "-m", "app"] +#CMD ["python3", "-m", "app"] diff --git a/src/microdcs/scripts/init/__main__.py b/src/microdcs/scripts/init/__main__.py index 6286d90..7067569 100644 --- a/src/microdcs/scripts/init/__main__.py +++ b/src/microdcs/scripts/init/__main__.py @@ -15,6 +15,7 @@ MachineryJobsCloudEventProcessor, ) from microdcs.publishers import JobOrderPublisher +from microdcs.sfc_engine import SfcEngine logger = logging.getLogger("app.main") @@ -119,6 +120,21 @@ ) microdcs.add_additional_task(job_order_publisher) +# Wire SFC engine if this instance is responsible for processing +if microdcs.runtime_config.is_processor_instance: + sfc_engine = SfcEngine( + microdcs.redis_connection_pool, + microdcs.redis_key_schema, + nb_processor=machinery_jobs_processor, + sb_processors={"greetings": greetings_processor}, + consumer_name=microdcs.runtime_config.instance_id, + ) + greetings_processor.register_action_completion_handler(sfc_engine.complete_action) + greetings_processor.register_action_failure_handler(sfc_engine.fail_action) + greetings_processor.register_pull_completion_handler(sfc_engine.pull_event_handler) + machinery_jobs_processor.register_scope_handler(sfc_engine.register_scope) + microdcs.add_additional_task(sfc_engine) + # Run MicroDCS main application logic loop_factory = asyncio.SelectorEventLoop if os.name == "nt" else None asyncio.run(microdcs.main(), loop_factory=loop_factory) diff --git a/src/microdcs/scripts/init/k8s.yaml b/src/microdcs/scripts/init/k8s.yaml index da84eb5..e1297db 100644 --- a/src/microdcs/scripts/init/k8s.yaml +++ b/src/microdcs/scripts/init/k8s.yaml @@ -1,12 +1,113 @@ +# MicroDCS Kubernetes Deployment +# +# Two Deployments: +# 1. Processor – scales horizontally with shared MQTT subscriptions and runs the SFC engine on every replica +# 2. Publisher – single replica maintaining retained MQTT topics +# +# Both share the same container image, differentiated by APP_IS_PROCESSOR_INSTANCE +# and APP_IS_PUBLISHER_INSTANCE environment variables. +# +# The SFC engine is not single-instance gated. It relies on Redis Streams consumer +# groups and CAS Lua scripts for distributed execution and recovery. Equipment-side +# command handling must be idempotent on the correlation_id attached to each +# outgoing push_command. +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: microdcs-processor + labels: + app: microdcs + role: processor +spec: + replicas: 2 + selector: + matchLabels: + app: microdcs + role: processor + template: + metadata: + labels: + app: microdcs + role: processor + spec: + containers: + - name: microdcs + image: aschamberger/microdcs:latest + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + env: + - name: POD_ID + valueFrom: + fieldRef: + fieldPath: metadata.uid + - name: APP_IS_PROCESSOR_INSTANCE + value: "true" + - name: APP_IS_PUBLISHER_INSTANCE + value: "false" + - name: APP_REDIS_HOSTNAME + value: redis + - name: APP_MQTT_HOSTNAME + value: mqtt-broker + - name: APP_PROCESSING_TOPIC_PREFIXES + value: "greetings:app/greetings,machinery-jobs:app/jobs" + - name: APP_PROCESSING_RESPONSE_TOPICS + value: "greetings:app/greetings/responses,machinery-jobs:app/jobs/responses" + - name: APP_PROCESSING_SHARED_SUBSCRIPTION_NAME + value: "appsub" - - -env: - - name: POD_ID - valueFrom: - fieldRef: - fieldPath: metadata.uid - +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: microdcs-publisher + labels: + app: microdcs + role: publisher +spec: + replicas: 1 + selector: + matchLabels: + app: microdcs + role: publisher + template: + metadata: + labels: + app: microdcs + role: publisher + spec: + containers: + - name: microdcs + image: aschamberger/microdcs:latest + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 128Mi + env: + - name: POD_ID + valueFrom: + fieldRef: + fieldPath: metadata.uid + - name: APP_IS_PROCESSOR_INSTANCE + value: "false" + - name: APP_IS_PUBLISHER_INSTANCE + value: "true" + - name: APP_REDIS_HOSTNAME + value: redis + - name: APP_MQTT_HOSTNAME + value: mqtt-broker + - name: APP_PROCESSING_TOPIC_PREFIXES + value: "greetings:app/greetings,machinery-jobs:app/jobs" + - name: APP_PUBLISHER_RETAINED_TTL_SECONDS + value: "172800" diff --git a/src/microdcs/scripts/init/settings.json b/src/microdcs/scripts/init/settings.json index a3a1838..3eb2a6e 100644 --- a/src/microdcs/scripts/init/settings.json +++ b/src/microdcs/scripts/init/settings.json @@ -3,5 +3,8 @@ "tests" ], "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "chat.tools.terminal.autoApprove": { + "uv": true + } } diff --git a/src/microdcs/scripts/init/tasks.json b/src/microdcs/scripts/init/tasks.json index dacdf66..a815ad6 100644 --- a/src/microdcs/scripts/init/tasks.json +++ b/src/microdcs/scripts/init/tasks.json @@ -1,126 +1,247 @@ -{ - // See https://go.microsoft.com/fwlink/?LinkId=733558 - // for the documentation about the tasks.json format - "version": "2.0.0", - "options": { - "env": { - "APP_REDIS_HOSTNAME": "localhost", - "APP_REDIS_PORT": "6379", - "APP_MQTT_HOSTNAME": "localhost", - "APP_MQTT_PORT": "1883", - "APP_MQTT_IDENTIFIER": "app", - "APP_MQTT_CONNECT_TIMEOUT": "10", - "APP_MQTT_PUBLISH_TIMEOUT": "5", - //"APP_MQTT_SAT_TOKEN_PATH": "/var/run/secrets/tokens/broker-sat", - //"APP_MQTT_TLS_CERT_PATH": "/var/run/certs/ca.crt", - "APP_PROCESSING_MESSAGE_EXPIRY_INTERVAL": "10", - "APP_PROCESSING_SHARED_SUBSCRIPTION_NAME": "appsub", - "APP_PROCESSING_TOPIC_PREFIXES": "greetings:app/greetings,machinery-jobs:app/jobs", - "APP_PROCESSING_TOPIC_WILDCARD_LEVELS": "greetings:0,machinery-jobs:3", - "APP_PROCESSING_RESPONSE_TOPICS": "greetings:app/greetings/responses,machinery-jobs:app/jobs/responses", - "APP_PROCESSING_CLOUDEVENT_SOURCE": "https://aschamberger.github.com/microdcs/app", - "APP_LOGGING_LEVEL": "DEBUG", - "APP_MSGPACK_HOSTNAME": "localhost", - "APP_MSGPACK_PORT": "8888", - "KUBECONFIG": "${userHome}${/}.kube${/}config.yaml" - } - }, - "presentation": { - "reveal": "always", - "panel": "dedicated" - }, - "tasks": [ - { - "label": "Run App (plain)", - "type": "shell", - "command": "uv run python -m app", - "group": "test", - "dependsOn": ["Start local MQTT broker", "Start local redis server"], - "dependsOrder": "parallel", - "problemMatcher": [] - }, - { - "label": "Run App (instrumented)", - "type": "shell", - "command": "${workspaceFolder}${/}.venv${/}Scripts${/}opentelemetry-instrument ${command:python.interpreterPath} -m app 2>&1", - "options": { - "env": { - "APP_PROCESSING_OTEL_INSTRUMENTATION_ENABLED": "true", - "OTEL_SERVICE_NAME": "microdcs.app", - "OTEL_TRACES_EXPORTER": "console", - "OTEL_METRICS_EXPORTER": "console", - "OTEL_LOGS_EXPORTER": "console", - "OTEL_PYTHON_LOG_CORRELATION": "true", - "OTEL_PYTHON_LOG_LEVEL": "debug", - "OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED": "true", - "OTEL_PYTHON_DISABLED_INSTRUMENTATIONS": "system_metrics,threading" - } - }, - "group": "test", - "dependsOn": ["Start local MQTT broker", "Start local redis server"], - "dependsOrder": "parallel", - "problemMatcher": [] - }, - { - "label": "Start local MQTT broker", - "type": "shell", - "command": "docker start mosquitto 2>/dev/null || docker run --rm -it --name mosquitto -p 1883:1883 eclipse-mosquitto mosquitto -c /mosquitto-no-auth.conf", - "group": "test", - "isBackground": true, - "problemMatcher": { - "pattern": { - "regexp": ".", - "file": 1, - "location": 2, - "message": 3 - }, - "background": { - "activeOnStart": true, - "beginsPattern": ".", - "endsPattern": "running" - } - } - }, - { - "label": "Start local redis server", - "type": "shell", - "command": "docker start redis 2>/dev/null || docker run --rm -it --name redis -p 6379:6379 redis:latest", - "group": "test", - "isBackground": true, - "problemMatcher": { - "pattern": { - "regexp": ".", - "file": 1, - "location": 2, - "message": 3 - }, - "background": { - "activeOnStart": true, - "beginsPattern": ".", - "endsPattern": "Ready to accept connections" - } - } - }, - { - "label": "MQTT broker port forwarding", - "type": "shell", - "command": "kubectl port-forward --namespace azure-iot-operations pod/aio-broker-frontend-0 19884 19884", - "group": "test", - "problemMatcher": [] - }, - { - "label": "App deployment", - "type": "shell", - "command": "kubectl apply -f ./deploy/k8s.yaml", - "group": "test", - "problemMatcher": [] - }, - { - "label": "Run test coverage", - "type": "shell", - "command": "${command:python.interpreterPath} -m pytest --cov --cov-report=term-missing tests/", - "group": "test", - "problemMatcher": [] - } - ] -} +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "options": { + "env": { + "APP_REDIS_HOSTNAME": "localhost", + "APP_REDIS_PORT": "6379", + "APP_MQTT_HOSTNAME": "localhost", + "APP_MQTT_PORT": "1883", + "APP_MQTT_IDENTIFIER": "app", + "APP_MQTT_CONNECT_TIMEOUT": "10", + "APP_MQTT_PUBLISH_TIMEOUT": "5", + //"APP_MQTT_SAT_TOKEN_PATH": "/var/run/secrets/tokens/broker-sat", + //"APP_MQTT_TLS_CERT_PATH": "/var/run/certs/ca.crt", + "APP_PROCESSING_MESSAGE_EXPIRY_INTERVAL": "10", + "APP_PROCESSING_SHARED_SUBSCRIPTION_NAME": "appsub", + "APP_PROCESSING_TOPIC_PREFIXES": "greetings:app/greetings,machinery-jobs:app/jobs", + "APP_PROCESSING_TOPIC_WILDCARD_LEVELS": "greetings:0,machinery-jobs:3", + "APP_PROCESSING_RESPONSE_TOPICS": "greetings:app/greetings/responses,machinery-jobs:app/jobs/responses", + "APP_PROCESSING_CLOUDEVENT_SOURCE": "https://aschamberger.github.com/microdcs/app", + "APP_LOGGING_LEVEL": "DEBUG", + "APP_MSGPACK_HOSTNAME": "localhost", + "APP_MSGPACK_PORT": "8888", + "KUBECONFIG": "${userHome}${/}.kube${/}config.yaml" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated" + }, + "tasks": [ + { + "label": "Run App (plain)", + "type": "shell", + "command": "uv run python -m app", + "group": "test", + "dependsOn": ["Start local MQTT broker", "Start local redis server"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "Run App (instrumented/console exporter)", + "type": "shell", + "command": "${workspaceFolder}${/}.venv${/}bin${/}opentelemetry-instrument ${workspaceFolder}${/}.venv${/}bin${/}python -m app 2>&1", + "options": { + "env": { + "APP_REDIS_HOSTNAME": "localhost", + "APP_REDIS_PORT": "6379", + "APP_MQTT_HOSTNAME": "localhost", + "APP_MQTT_PORT": "1883", + "APP_MQTT_IDENTIFIER": "app", + "APP_MQTT_CONNECT_TIMEOUT": "10", + "APP_MQTT_PUBLISH_TIMEOUT": "5", + //"APP_MQTT_SAT_TOKEN_PATH": "/var/run/secrets/tokens/broker-sat", + //"APP_MQTT_TLS_CERT_PATH": "/var/run/certs/ca.crt", + "APP_PROCESSING_MESSAGE_EXPIRY_INTERVAL": "10", + "APP_PROCESSING_SHARED_SUBSCRIPTION_NAME": "appsub", + "APP_PROCESSING_TOPIC_PREFIXES": "greetings:app/greetings,machinery-jobs:app/jobs", + "APP_PROCESSING_TOPIC_WILDCARD_LEVELS": "greetings:0,machinery-jobs:3", + "APP_PROCESSING_RESPONSE_TOPICS": "greetings:app/greetings/responses,machinery-jobs:app/jobs/responses", + "APP_PROCESSING_CLOUDEVENT_SOURCE": "https://aschamberger.github.com/microdcs/app", + "APP_LOGGING_LEVEL": "DEBUG", + "APP_MSGPACK_HOSTNAME": "localhost", + "APP_MSGPACK_PORT": "8888", + "KUBECONFIG": "${userHome}${/}.kube${/}config.yaml", + "APP_PROCESSING_OTEL_INSTRUMENTATION_ENABLED": "true", + "OTEL_SERVICE_NAME": "microdcs.app", + "OTEL_TRACES_EXPORTER": "console", // console, otlp, none + "OTEL_METRICS_EXPORTER": "console", // console, otlp, none + "OTEL_LOGS_EXPORTER": "console", // console, otlp, none + // "OTEL_EXPORTER_OTLP_ENDPOINT": "0.0.0.0:4318", + "OTEL_PYTHON_LOG_CORRELATION": "true", + // "OTEL_PYTHON_LOG_FORMAT": "%(asctime)s %(levelname)s [%(name)s] [%(filename)s:%(lineno)d] [trace_id=%(otelTraceID)s span_id=%(otelSpanID)s resource.service.name=%(otelServiceName)s trace_sampled=%(otelTraceSampled)s] - %(message)s", + "OTEL_PYTHON_LOG_LEVEL": "debug", + "OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED": "true", + "OTEL_PYTHON_DISABLED_INSTRUMENTATIONS": "system_metrics,threading", + "OTEL_SEMCONV_STABILITY_OPT_IN": "http,database,messaging" + } + }, + "group": "test", + "dependsOn": ["Start local MQTT broker", "Start local redis server"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "Run App (instrumented/Aspire dashboard)", + "type": "shell", + "command": "${workspaceFolder}${/}.venv${/}bin${/}opentelemetry-instrument ${workspaceFolder}${/}.venv${/}bin${/}python -m app 2>&1", + "options": { + "env": { + "APP_REDIS_HOSTNAME": "localhost", + "APP_REDIS_PORT": "6379", + "APP_MQTT_HOSTNAME": "localhost", + "APP_MQTT_PORT": "1883", + "APP_MQTT_IDENTIFIER": "app", + "APP_MQTT_CONNECT_TIMEOUT": "10", + "APP_MQTT_PUBLISH_TIMEOUT": "5", + //"APP_MQTT_SAT_TOKEN_PATH": "/var/run/secrets/tokens/broker-sat", + //"APP_MQTT_TLS_CERT_PATH": "/var/run/certs/ca.crt", + "APP_PROCESSING_MESSAGE_EXPIRY_INTERVAL": "10", + "APP_PROCESSING_SHARED_SUBSCRIPTION_NAME": "appsub", + "APP_PROCESSING_TOPIC_PREFIXES": "greetings:app/greetings,machinery-jobs:app/jobs", + "APP_PROCESSING_TOPIC_WILDCARD_LEVELS": "greetings:0,machinery-jobs:3", + "APP_PROCESSING_RESPONSE_TOPICS": "greetings:app/greetings/responses,machinery-jobs:app/jobs/responses", + "APP_PROCESSING_CLOUDEVENT_SOURCE": "https://aschamberger.github.com/microdcs/app", + "APP_LOGGING_LEVEL": "DEBUG", + "APP_MSGPACK_HOSTNAME": "localhost", + "APP_MSGPACK_PORT": "8888", + "KUBECONFIG": "${userHome}${/}.kube${/}config.yaml", + "APP_PROCESSING_OTEL_INSTRUMENTATION_ENABLED": "true", + "OTEL_SERVICE_NAME": "microdcs.app", + "OTEL_TRACES_EXPORTER": "otlp", + "OTEL_METRICS_EXPORTER": "otlp", + "OTEL_LOGS_EXPORTER": "otlp", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc", + "OTEL_PYTHON_LOG_CORRELATION": "true", + "OTEL_PYTHON_LOG_LEVEL": "debug", + "OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED": "true", + "OTEL_PYTHON_DISABLED_INSTRUMENTATIONS": "system_metrics,threading", + "OTEL_SEMCONV_STABILITY_OPT_IN": "http,database,messaging" + } + }, + "group": "test", + "dependsOn": ["Start local MQTT broker", "Start local redis server", "Start Aspire dashboard"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "Start Aspire dashboard", + "type": "shell", + "command": "docker start aspire-dashboard 2>/dev/null || docker run --rm -p 18888:18888 -p 4317:18889 -p 4318:18890 -d --name aspire-dashboard mcr.microsoft.com/dotnet/aspire-dashboard:latest", + "group": "test", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": ".", + "file": 1, + "location": 2, + "message": 3 + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".", + "endsPattern": "." + } + } + }, + { + "label": "Start local MQTT broker", + "type": "shell", + "command": "docker start mosquitto 2>/dev/null || docker run --rm -it --name mosquitto -p 1883:1883 eclipse-mosquitto mosquitto -c /mosquitto-no-auth.conf", + "group": "test", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": ".", + "file": 1, + "location": 2, + "message": 3 + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".", + "endsPattern": "running" + } + } + }, + { + "label": "Start local redis server", + "type": "shell", + "command": "docker start redis 2>/dev/null || docker run --rm -it --name redis -p 6379:6379 redis:latest", + "group": "test", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": ".", + "file": 1, + "location": 2, + "message": 3 + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".", + "endsPattern": "Ready to accept connections" + } + } + }, + { + "label": "MQTT broker port forwarding", + "type": "shell", + "command": "kubectl port-forward --namespace azure-iot-operations pod/aio-broker-frontend-0 19884 19884", + "group": "test", + "problemMatcher": [] + }, + { + "label": "App deployment", + "type": "shell", + "command": "kubectl apply -f ./deploy/k8s.yaml", + "group": "test", + "problemMatcher": [] + }, + { + "label": "Line count stats (microdcs,app)", + "type": "shell", + "command": "uvx pygount --format=summary --generated='[regex][...](?i).*generated' --folders-to-skip=[...],tests,deploy,schemas,scripts", + "windows": { + "options": { + "env": { + "GIT_PYTHON_GIT_EXECUTABLE": "C:\\Program Files\\PortableGit\\cmd\\git.exe" + } + } + }, + "group": "none", + "problemMatcher": [] + }, + { + "label": "Line count stats (other)", + "type": "shell", + "command": "uvx pygount --format=summary --generated='[regex][...](?i).*generated' --folders-to-skip=[...],src,app", + "windows": { + "options": { + "env": { + "GIT_PYTHON_GIT_EXECUTABLE": "C:\\Program Files\\PortableGit\\cmd\\git.exe" + } + } + }, + "group": "none", + "problemMatcher": [] + }, + { + "label": "Run test coverage", + "type": "shell", + "command": "${command:python.interpreterPath} -m pytest --cov=microdcs --cov-report=term-missing tests/", + "group": "test", + "problemMatcher": [] + }, + { + "label": "Remove all containers", + "type": "shell", + "command": "docker rm -f $(docker ps -aq)", + "group": "test", + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/src/microdcs/sfc_engine.py b/src/microdcs/sfc_engine.py index bec81a5..742d4ad 100644 --- a/src/microdcs/sfc_engine.py +++ b/src/microdcs/sfc_engine.py @@ -84,9 +84,14 @@ def __init__( self._workmaster_dao = WorkMasterDAO(self._redis_client, redis_key_schema) # State machine for OPC UA job transitions - from transitions.extensions import HierarchicalGraphMachine - - self._state_machine = HierarchicalGraphMachine( + from transitions.extensions import HierarchicalMachine + + # model_override=True means transitions only overrides methods that already + # exist on the model (the trigger/may_trigger stubs in JobStateMixin). For + # every other method it cannot bind, it emits a WARNING — ~70 per add_model + # call. Silence those warnings since this is intentional design. + logging.getLogger("transitions.core").setLevel(logging.ERROR) + self._state_machine = HierarchicalMachine( model=None, states=JobOrderControlExt.Config.opcua_state_machine_states, transitions=JobOrderControlExt.Config.opcua_state_machine_transitions, diff --git a/tests/test_app_sfc_integration.py b/tests/integration/test_app_sfc_integration.py similarity index 83% rename from tests/test_app_sfc_integration.py rename to tests/integration/test_app_sfc_integration.py index 823b4c2..649e2f8 100644 --- a/tests/test_app_sfc_integration.py +++ b/tests/integration/test_app_sfc_integration.py @@ -1,6 +1,8 @@ import asyncio import uuid +import aiomqtt +import mqtt5 import pytest import pytest_asyncio import redis.asyncio as redis @@ -22,7 +24,7 @@ LocalizedText, StoreAndStartCall, ) -from microdcs.mqtt import MQTTHandler +from microdcs.mqtt import MQTTHandler, _topic_matches from microdcs.redis import ( EquipmentListDAO, JobResponseDAO, @@ -108,8 +110,8 @@ async def test_example_app_completes_sfc_recipe_via_greetings_response( mqtt_client = mqtt_handler._client() async with mqtt_client: - await mqtt_client.subscribe(job_response_topic) - await mqtt_client.subscribe(GREETINGS_COMMAND_TOPIC) + await mqtt_client.subscribe(aiomqtt.TopicFilter(job_response_topic)) + await mqtt_client.subscribe(aiomqtt.TopicFilter(GREETINGS_COMMAND_TOPIC)) await mqtt_handler._publish_message(mqtt_client, store_ce) job_response_received = False @@ -117,20 +119,16 @@ async def test_example_app_completes_sfc_recipe_via_greetings_response( command_id: str | None = None async with asyncio.timeout(10.0): - async for message in mqtt_client.messages: - if str(message.topic) == job_response_topic: + async for message in mqtt_client.messages(): + if not isinstance(message, mqtt5.PublishPacket): + continue + if message.topic == job_response_topic: job_response_received = True - elif message.topic.matches(GREETINGS_COMMAND_TOPIC): - if message.properties and hasattr( - message.properties, "ResponseTopic" - ): - response_topic = str(message.properties.ResponseTopic) # type: ignore[arg-type] - if message.properties and hasattr( - message.properties, "CorrelationData" - ): - command_id = str( - uuid.UUID(bytes=message.properties.CorrelationData) # type: ignore[arg-type] - ) + elif _topic_matches(GREETINGS_COMMAND_TOPIC, message.topic): + if message.response_topic is not None: + response_topic = str(message.response_topic) + if message.correlation_data is not None: + command_id = str(uuid.UUID(bytes=message.correlation_data)) if response_topic and command_id: break diff --git a/tests/test_mes_integration.py b/tests/integration/test_mes_integration.py similarity index 98% rename from tests/test_mes_integration.py rename to tests/integration/test_mes_integration.py index fa6ad5a..7f5c4e7 100644 --- a/tests/test_mes_integration.py +++ b/tests/integration/test_mes_integration.py @@ -103,10 +103,14 @@ async def resync(self, scope: str) -> tuple[StateIndex | None, bool]: async def _read_retained(self, topic: str, timeout: float = 5.0) -> bytes | None: async with aiomqtt.Client(hostname=self._hostname, port=self._port) as client: - await client.subscribe(topic, qos=1) + await client.subscribe( + aiomqtt.TopicFilter(topic, max_qos=aiomqtt.QoS.AT_LEAST_ONCE) + ) try: async with asyncio.timeout(timeout): - async for message in client.messages: + async for message in client.messages(): + if isinstance(message, aiomqtt.PubRelPacket): + continue return bytes(message.payload) if message.payload else None except TimeoutError: return None @@ -158,7 +162,9 @@ async def _delete_retained(topic: str) -> None: async with aiomqtt.Client( hostname=MQTT_CONFIG.hostname, port=MQTT_CONFIG.port ) as client: - await client.publish(topic, payload=b"", qos=1, retain=True) + await client.publish( + topic, payload=b"", qos=aiomqtt.QoS.AT_MOST_ONCE, retain=True + ) # --------------------------------------------------------------------------- diff --git a/tests/test_mqtt_integration.py b/tests/integration/test_mqtt_integration.py similarity index 86% rename from tests/test_mqtt_integration.py rename to tests/integration/test_mqtt_integration.py index 62d2235..160de38 100644 --- a/tests/test_mqtt_integration.py +++ b/tests/integration/test_mqtt_integration.py @@ -1,7 +1,9 @@ import asyncio +import uuid from typing import Any import aiomqtt +import mqtt5 import orjson import pytest import pytest_asyncio @@ -26,7 +28,7 @@ StoreResponse, ) from microdcs.models.machinery_jobs_ext import MethodReturnStatus -from microdcs.mqtt import MQTTHandler, MQTTPublisher +from microdcs.mqtt import MQTTHandler, MQTTPublisher, create_mqtt_client from microdcs.processors.greetings import GreetingsCloudEventProcessor from microdcs.processors.machinery_jobs import MachineryJobsCloudEventProcessor from microdcs.redis import RedisKeySchema @@ -107,25 +109,35 @@ async def _publish_and_collect_responses( *, expected_responses: int = 1, timeout: float = RESPONSE_TIMEOUT, -) -> list[aiomqtt.Message]: +) -> list[mqtt5.PublishPacket]: """Subscribe to the response topic, publish *cloudevent*, and collect responses. - Returns the list of :class:`aiomqtt.Message` objects received on the + Returns the list of :class:`mqtt5.PublishPacket` objects received on the response topic within *timeout* seconds. The helper waits until *expected_responses* messages arrive or the timeout expires — whichever comes first. """ response_topic = cloudevent.transportmetadata["mqtt_response_topic"] # type: ignore[index] - mqtt_client = mqtt_handler._client() - collected: list[aiomqtt.Message] = [] + # Use a clean-session, non-reconnecting client so that all MQTTConfig + # settings (TLS, SAT-token auth) are applied while avoiding persistent + # session and reconnect machinery intended only for long-lived handlers. + mqtt_client = create_mqtt_client( + MQTT_CONFIG, + client_identifier=f"test-resp-{uuid.uuid4().hex[:8]}", + clean_start=True, + reconnect=False, + ) + collected: list[mqtt5.PublishPacket] = [] async with mqtt_client: - await mqtt_client.subscribe(response_topic) + await mqtt_client.subscribe(aiomqtt.TopicFilter(response_topic)) await mqtt_handler._publish_message(mqtt_client, cloudevent) try: async with asyncio.timeout(timeout): - async for message in mqtt_client.messages: + async for message in mqtt_client.messages(): + if isinstance(message, aiomqtt.PubRelPacket): + continue collected.append(message) if len(collected) >= expected_responses: break @@ -135,12 +147,12 @@ async def _publish_and_collect_responses( return collected -def _assert_type_id(message: aiomqtt.Message, expected_type: str) -> None: +def _assert_type_id(message: mqtt5.PublishPacket, expected_type: str) -> None: """Assert that an MQTT v5 message carries the expected CloudEvent ``type`` - in its UserProperty list.""" + in its user_properties list.""" user_props = {} - if message.properties and hasattr(message.properties, "UserProperty"): - user_props = dict(message.properties.UserProperty) # type: ignore[arg-type] + if message.user_properties is not None: + user_props = dict(message.user_properties) assert user_props.get("type") == expected_type, ( f"Expected CE type '{expected_type}', got '{user_props.get('type')}'" ) @@ -479,33 +491,30 @@ async def test_publish_retained_and_receive_on_reconnect(self): # Publish a retained message publisher = MQTTPublisher(MQTTConfig()) - publisher._client = aiomqtt.Client( - hostname=MQTT_CONFIG.hostname, - port=MQTT_CONFIG.port, - identifier="test-pub-retained", + publisher._client = create_mqtt_client( + MQTT_CONFIG, "test-pub-retained", clean_start=True, reconnect=False ) async with publisher._client: await publisher.publish_retained(topic, payload, ttl=60) # Reconnect as a new client and verify the retained message is delivered - async with aiomqtt.Client( - hostname=MQTT_CONFIG.hostname, - port=MQTT_CONFIG.port, - identifier="test-sub-retained", + async with create_mqtt_client( + MQTT_CONFIG, "test-sub-retained", clean_start=True, reconnect=False ) as sub: - await sub.subscribe(topic, qos=1) + await sub.subscribe( + aiomqtt.TopicFilter(topic, max_qos=aiomqtt.QoS.AT_LEAST_ONCE) + ) msg = await asyncio.wait_for( - sub.messages.__anext__(), + sub.messages().__anext__(), timeout=5.0, # type: ignore[reportAttributeAccessIssue] ) + assert isinstance(msg, mqtt5.PublishPacket) assert msg.payload == payload assert msg.retain is True # Clean up retained topic - publisher._client = aiomqtt.Client( - hostname=MQTT_CONFIG.hostname, - port=MQTT_CONFIG.port, - identifier="test-cleanup-retained", + publisher._client = create_mqtt_client( + MQTT_CONFIG, "test-cleanup-retained", clean_start=True, reconnect=False ) async with publisher._client: await publisher.delete_retained(topic) @@ -517,33 +526,29 @@ async def test_delete_retained_clears_topic(self): # Publish retained publisher = MQTTPublisher(MQTTConfig()) - publisher._client = aiomqtt.Client( - hostname=MQTT_CONFIG.hostname, - port=MQTT_CONFIG.port, - identifier="test-pub-del", + publisher._client = create_mqtt_client( + MQTT_CONFIG, "test-pub-del", clean_start=True, reconnect=False ) async with publisher._client: await publisher.publish_retained(topic, payload, ttl=60) # Delete retained - publisher._client = aiomqtt.Client( - hostname=MQTT_CONFIG.hostname, - port=MQTT_CONFIG.port, - identifier="test-del", + publisher._client = create_mqtt_client( + MQTT_CONFIG, "test-del", clean_start=True, reconnect=False ) async with publisher._client: await publisher.delete_retained(topic) # Reconnect and verify no retained message is delivered - async with aiomqtt.Client( - hostname=MQTT_CONFIG.hostname, - port=MQTT_CONFIG.port, - identifier="test-sub-del", + async with create_mqtt_client( + MQTT_CONFIG, "test-sub-del", clean_start=True, reconnect=False ) as sub: - await sub.subscribe(topic, qos=1) + await sub.subscribe( + aiomqtt.TopicFilter(topic, max_qos=aiomqtt.QoS.AT_LEAST_ONCE) + ) with pytest.raises(asyncio.TimeoutError): await asyncio.wait_for( - sub.messages.__anext__(), + sub.messages().__anext__(), timeout=2.0, # type: ignore[reportAttributeAccessIssue] ) @@ -553,31 +558,28 @@ async def test_publish_retained_string_payload(self): payload = '{"message": "hello"}' publisher = MQTTPublisher(MQTTConfig()) - publisher._client = aiomqtt.Client( - hostname=MQTT_CONFIG.hostname, - port=MQTT_CONFIG.port, - identifier="test-pub-str", + publisher._client = create_mqtt_client( + MQTT_CONFIG, "test-pub-str", clean_start=True, reconnect=False ) async with publisher._client: await publisher.publish_retained(topic, payload, ttl=60) - async with aiomqtt.Client( - hostname=MQTT_CONFIG.hostname, - port=MQTT_CONFIG.port, - identifier="test-sub-str", + async with create_mqtt_client( + MQTT_CONFIG, "test-sub-str", clean_start=True, reconnect=False ) as sub: - await sub.subscribe(topic, qos=1) + await sub.subscribe( + aiomqtt.TopicFilter(topic, max_qos=aiomqtt.QoS.AT_LEAST_ONCE) + ) msg = await asyncio.wait_for( - sub.messages.__anext__(), + sub.messages().__anext__(), timeout=5.0, # type: ignore[reportAttributeAccessIssue] ) + assert isinstance(msg, mqtt5.PublishPacket) assert msg.payload == payload.encode() # Cleanup - publisher._client = aiomqtt.Client( - hostname=MQTT_CONFIG.hostname, - port=MQTT_CONFIG.port, - identifier="test-cleanup-str", + publisher._client = create_mqtt_client( + MQTT_CONFIG, "test-cleanup-str", clean_start=True, reconnect=False ) async with publisher._client: await publisher.delete_retained(topic) diff --git a/tests/test_msgpack_integration.py b/tests/integration/test_msgpack_integration.py similarity index 100% rename from tests/test_msgpack_integration.py rename to tests/integration/test_msgpack_integration.py diff --git a/tests/state_machine.md b/tests/unit/state_machine.md similarity index 100% rename from tests/state_machine.md rename to tests/unit/state_machine.md diff --git a/tests/statemachine_test.py b/tests/unit/statemachine_test.py similarity index 77% rename from tests/statemachine_test.py rename to tests/unit/statemachine_test.py index 31d73fe..8feb546 100644 --- a/tests/statemachine_test.py +++ b/tests/unit/statemachine_test.py @@ -47,10 +47,10 @@ def test_graph(machine: HierarchicalGraphMachine): "classDef s_default fill:black,color:white", ) md += "\n```\n" - # with open("tests/state_machine.md", "w") as f: + # with open("tests/unit/state_machine.md", "w") as f: # f.write(md) assert graph is not None - assert md == open("tests/state_machine.md").read() + assert md == open("tests/unit/state_machine.md").read() def test_state_machine_states(machine: HierarchicalGraphMachine): @@ -104,15 +104,34 @@ def test_state_machine_triggers(machine: HierarchicalGraphMachine): assert "NotAllowedToStartFromWaitingToReady" in triggers -def test_state_machine_trigger_store(machine: HierarchicalGraphMachine): - model: Job = typing.cast(Job, machine.models[0]) - assert model._state == "InitialState" - model.trigger("Store") - assert model._state == "NotAllowedToStart_Ready" - assert model.may_trigger("Store") is False +def test_state_machine_trigger_store(benchmark): + def setup(): + fresh_machine = HierarchicalGraphMachine( + model=None, + states=JobOrderControlExt.Config.opcua_state_machine_states, + transitions=JobOrderControlExt.Config.opcua_state_machine_transitions, + initial="InitialState", + auto_transitions=False, + queued=True, + model_attribute="_state", + model_override=True, + ) + job = Job() + fresh_machine.add_model(job) + return (job,), {} + + def trigger_store(job: Job) -> Job: + job.trigger("Store") + return job + + result = typing.cast( + Job, benchmark.pedantic(trigger_store, setup=setup, rounds=50, iterations=1) + ) + assert result._state == "NotAllowedToStart_Ready" + assert result.may_trigger("Store") is False with pytest.raises(transitions.core.MachineError): - model.trigger("Store") - assert model._state == "NotAllowedToStart_Ready" + result.trigger("Store") + assert result._state == "NotAllowedToStart_Ready" def test_state_name_to_tuple(machine: HierarchicalGraphMachine): @@ -124,7 +143,8 @@ def test_state_name_to_tuple(machine: HierarchicalGraphMachine): def test_tuple_to_state_name(machine: HierarchicalGraphMachine): - state_name = JobOrderControlExt.Config.get_state_name_from_tuples( - [("NotAllowedToStart", "1"), ("Ready", "2")] - ) + state_name = JobOrderControlExt.Config.get_state_name_from_tuples([ + ("NotAllowedToStart", "1"), + ("Ready", "2"), + ]) assert state_name == "NotAllowedToStart_Ready" diff --git a/tests/test_common.py b/tests/unit/test_common.py similarity index 97% rename from tests/test_common.py rename to tests/unit/test_common.py index a181919..4b49df1 100644 --- a/tests/test_common.py +++ b/tests/unit/test_common.py @@ -130,14 +130,13 @@ def test_post_init_no_errorkind_no_message(self): class TestCloudEventSerialization: - def test_json_round_trip_basic(self): + def test_json_round_trip_basic(self, benchmark): ce = CloudEvent( source="test", type="com.test.v1", datacontenttype="application/json", ) - json_bytes = ce.to_jsonb() - restored = CloudEvent.from_json(json_bytes) + restored = benchmark(lambda: CloudEvent.from_json(ce.to_jsonb())) assert restored.source == "test" assert restored.type == "com.test.v1" assert restored.specversion == "1.0" @@ -255,10 +254,9 @@ def test_make_str_values_context(self): assert d["expiryinterval"] == "30" assert isinstance(d["source"], str) - def test_msgpack_round_trip(self): + def test_msgpack_round_trip(self, benchmark): ce = CloudEvent(source="test", type="com.test.v1") - packed = ce.to_msgpack() - restored = CloudEvent.from_msgpack(packed) + restored = benchmark(lambda: CloudEvent.from_msgpack(ce.to_msgpack())) assert restored.source == "test" assert restored.type == "com.test.v1" @@ -269,13 +267,13 @@ def test_msgpack_round_trip(self): class TestCloudEventPayload: - def test_unserialize_json_payload(self): + def test_unserialize_json_payload(self, benchmark): payload = SamplePayload(value="world") ce = CloudEvent( datacontenttype="application/json", data=payload.to_jsonb(), ) - result = ce.unserialize_payload(SamplePayload) + result = benchmark(ce.unserialize_payload, SamplePayload) assert isinstance(result, SamplePayload) assert result.value == "world" @@ -289,13 +287,13 @@ def test_unserialize_json_utf8_payload(self): assert isinstance(result, SamplePayload) assert result.value == "utf8" - def test_unserialize_msgpack_payload(self): + def test_unserialize_msgpack_payload(self, benchmark): payload = SamplePayload(value="packed") ce = CloudEvent( datacontenttype="application/msgpack", data=payload.to_msgpack(), ) - result = ce.unserialize_payload(SamplePayload) + result = benchmark(ce.unserialize_payload, SamplePayload) assert isinstance(result, SamplePayload) assert result.value == "packed" @@ -355,10 +353,10 @@ def test_unserialize_corrupted_msgpack_raises(self): with pytest.raises(Exception): ce.unserialize_payload(SamplePayload) - def test_serialize_json_payload(self): + def test_serialize_json_payload(self, benchmark): payload = SamplePayload(value="ser") ce = CloudEvent(datacontenttype="application/json") - ce.serialize_payload(payload) + benchmark(ce.serialize_payload, payload) assert ce.data is not None restored = SamplePayload.from_json(ce.data) assert restored.value == "ser" @@ -371,10 +369,10 @@ def test_serialize_json_utf8_payload(self): restored = SamplePayload.from_json(ce.data) assert restored.value == "ser-utf8" - def test_serialize_msgpack_payload(self): + def test_serialize_msgpack_payload(self, benchmark): payload = SamplePayload(value="packed-ser") ce = CloudEvent(datacontenttype="application/msgpack") - ce.serialize_payload(payload) + benchmark(ce.serialize_payload, payload) assert ce.data is not None restored = SamplePayload.from_msgpack(ce.data) assert restored.value == "packed-ser" diff --git a/tests/test_core.py b/tests/unit/test_core.py similarity index 99% rename from tests/test_core.py rename to tests/unit/test_core.py index 3b3caad..ea1832a 100644 --- a/tests/test_core.py +++ b/tests/unit/test_core.py @@ -36,6 +36,7 @@ def _create_microdcs(otel_enabled: bool) -> MicroDCS: dcs.runtime_config.is_processor_instance = True dcs.runtime_config.is_publisher_instance = True dcs.redis_connection_pool = AsyncMock() + dcs.redis_connection_pool.connection_kwargs = {} dcs.redis_key_schema = MagicMock() dcs._protocol_handlers = {} dcs._handler_bindings = {} diff --git a/tests/test_dataclass.py b/tests/unit/test_dataclass.py similarity index 97% rename from tests/test_dataclass.py rename to tests/unit/test_dataclass.py index 9a7aec6..8d350dc 100644 --- a/tests/test_dataclass.py +++ b/tests/unit/test_dataclass.py @@ -224,14 +224,14 @@ def test_all_metadata_context_combined(self): assert data["_scope"] == "s1" assert data["_normalized_state"] == "Running" - def test_json_round_trip(self): + def test_json_round_trip(self, benchmark): model = EventModel() - restored = EventModel.from_json(model.to_jsonb()) + restored = benchmark(lambda: EventModel.from_json(model.to_jsonb())) assert isinstance(restored, EventModel) - def test_msgpack_round_trip(self): + def test_msgpack_round_trip(self, benchmark): model = EventModel() - restored = EventModel.from_msgpack(model.to_msgpack()) + restored = benchmark(lambda: EventModel.from_msgpack(model.to_msgpack())) assert isinstance(restored, EventModel) diff --git a/tests/test_dataclassgen.py b/tests/unit/test_dataclassgen.py similarity index 100% rename from tests/test_dataclassgen.py rename to tests/unit/test_dataclassgen.py diff --git a/tests/test_greetings_processor.py b/tests/unit/test_greetings_processor.py similarity index 100% rename from tests/test_greetings_processor.py rename to tests/unit/test_greetings_processor.py diff --git a/tests/test_init.py b/tests/unit/test_init.py similarity index 98% rename from tests/test_init.py rename to tests/unit/test_init.py index a2abe45..c807a67 100644 --- a/tests/test_init.py +++ b/tests/unit/test_init.py @@ -34,12 +34,8 @@ def test_defaults(self): assert cfg.hostname == "localhost" assert cfg.port == 1883 assert cfg.identifier == "app_client" - assert cfg.connect_timeout == 10 - assert cfg.publish_timeout == 5 assert cfg.sat_token_path == Path("/var/run/secrets/tokens/broker-sat") assert cfg.tls_cert_path == Path("/var/run/certs/ca.crt") - assert cfg.incoming_queue_size == 0 - assert cfg.outgoing_queue_size == 0 assert cfg.message_workers == 5 assert cfg.dedupe_ttl_seconds == 600 diff --git a/tests/test_job_order_publisher.py b/tests/unit/test_job_order_publisher.py similarity index 100% rename from tests/test_job_order_publisher.py rename to tests/unit/test_job_order_publisher.py diff --git a/tests/test_machinery_jobs_processor.py b/tests/unit/test_machinery_jobs_processor.py similarity index 100% rename from tests/test_machinery_jobs_processor.py rename to tests/unit/test_machinery_jobs_processor.py diff --git a/tests/test_mqtt.py b/tests/unit/test_mqtt.py similarity index 83% rename from tests/test_mqtt.py rename to tests/unit/test_mqtt.py index b61be58..61e31a5 100644 --- a/tests/test_mqtt.py +++ b/tests/unit/test_mqtt.py @@ -24,6 +24,7 @@ MQTTProtocolBinding, OTELInstrumentedMQTTHandler, QoS, + _topic_matches, create_mqtt_client, ) from microdcs.redis import RedisKeySchema @@ -88,27 +89,26 @@ def _make_mqtt_message( qos: int = 1, mid: int = 42, retain: bool = False, - properties: object | None = None, - match_topics: set[str] | None = None, + # Individual v5 property attrs (direct on packet in v3) + message_expiry_interval: int | None = None, + content_type: str | None = None, + response_topic: str | None = None, + correlation_data: bytes | None = None, + user_properties: list[tuple[str, str]] | None = None, ) -> MagicMock: - """Build a mock aiomqtt.Message. - - Args: - match_topics: If given, ``topic.matches`` returns True only for patterns - in this set. If *None*, it matches everything (legacy behaviour). - """ + """Build a mock mqtt5.PublishPacket for aiomqtt v3.""" msg = MagicMock() - msg.topic = MagicMock() - msg.topic.__str__ = lambda self: topic - if match_topics is not None: - msg.topic.matches = lambda pattern: pattern in match_topics - else: - msg.topic.matches = lambda pattern: True + msg.topic = topic # plain str in v3 msg.payload = payload msg.qos = qos - msg.mid = mid + msg.packet_id = mid # renamed from mid in v3 msg.retain = retain - msg.properties = properties + # MQTT v5 properties are direct attributes on the packet + msg.message_expiry_interval = message_expiry_interval + msg.content_type = content_type + msg.response_topic = response_topic + msg.correlation_data = correlation_data + msg.user_properties = user_properties return msg @@ -128,6 +128,82 @@ def test_exactly_once(self): assert QoS.EXACTLY_ONCE == 2 +# =================================================================== +# _topic_matches +# =================================================================== + + +class TestTopicMatches: + # Exact match + def test_exact_match(self): + assert _topic_matches("foo/bar", "foo/bar") is True + + def test_exact_no_match(self): + assert _topic_matches("foo/bar", "foo/baz") is False + + # Single-level wildcard + + def test_plus_matches_single_level(self): + assert _topic_matches("foo/+/baz", "foo/bar/baz") is True + + def test_plus_does_not_match_multiple_levels(self): + assert _topic_matches("foo/+/baz", "foo/bar/qux/baz") is False + + def test_plus_matches_empty_level(self): + assert _topic_matches("foo/+/baz", "foo//baz") is True + + def test_plus_at_end(self): + assert _topic_matches("foo/+", "foo/bar") is True + + def test_plus_at_start(self): + assert _topic_matches("+/bar", "foo/bar") is True + + # Multi-level wildcard # + def test_hash_alone_matches_everything(self): + assert _topic_matches("#", "foo/bar/baz") is True + + def test_hash_alone_matches_single_level(self): + assert _topic_matches("#", "foo") is True + + def test_hash_at_end_matches_subtree(self): + assert _topic_matches("foo/#", "foo/bar") is True + + def test_hash_matches_multilevel_subtree(self): + assert _topic_matches("foo/#", "foo/bar/baz") is True + + def test_hash_matches_parent_topic(self): + # foo/# must also match foo itself (zero levels after prefix) + assert _topic_matches("foo/#", "foo") is True + + def test_hash_no_match_different_prefix(self): + assert _topic_matches("foo/#", "bar/baz") is False + + # Shared subscription prefix ($share//) is stripped before matching + def test_shared_subscription_exact(self): + assert _topic_matches("$share/grp/foo/bar", "foo/bar") is True + + def test_shared_subscription_wildcard(self): + assert _topic_matches("$share/grp/foo/+/baz", "foo/bar/baz") is True + + def test_shared_subscription_hash(self): + assert ( + _topic_matches("$share/mygroup/app/events/#", "app/events/something") + is True + ) + + def test_shared_subscription_no_match(self): + assert _topic_matches("$share/grp/foo/bar", "foo/baz") is False + + # Combined wildcards + def test_plus_and_hash(self): + assert _topic_matches("foo/+/#", "foo/bar/baz/qux") is True + + def test_no_match_shorter_topic(self): + assert _topic_matches("foo/bar/baz", "foo/bar") is False + + def test_no_match_longer_topic(self): + assert _topic_matches("foo/bar", "foo/bar/baz") is False + + # =================================================================== # MQTTProtocolBinding registration # =================================================================== @@ -593,18 +669,35 @@ def test_creates_client_with_config_values(self): assert call_kwargs["hostname"] == config.hostname assert call_kwargs["port"] == config.port assert call_kwargs["identifier"] == config.identifier - assert call_kwargs["timeout"] == config.connect_timeout + assert "timeout" not in call_kwargs - def test_forwards_extra_kwargs(self): + def test_reconnect_enabled(self): config = MQTTConfig() with patch("microdcs.mqtt.aiomqtt.Client") as mock_cls: mock_cls.return_value = MagicMock() - create_mqtt_client( - config, clean_start=True, max_queued_incoming_messages=10 - ) - call_kwargs = mock_cls.call_args[1] - assert call_kwargs["clean_start"] is True - assert call_kwargs["max_queued_incoming_messages"] == 10 + create_mqtt_client(config) + assert mock_cls.call_args[1]["reconnect"] is True + + def test_reconnect_disabled(self): + config = MQTTConfig() + with patch("microdcs.mqtt.aiomqtt.Client") as mock_cls: + mock_cls.return_value = MagicMock() + create_mqtt_client(config, reconnect=False) + assert mock_cls.call_args[1]["reconnect"] is False + + def test_clean_start_false(self): + config = MQTTConfig() + with patch("microdcs.mqtt.aiomqtt.Client") as mock_cls: + mock_cls.return_value = MagicMock() + create_mqtt_client(config) + assert mock_cls.call_args[1]["clean_start"] is False + + def test_clean_start_true(self): + config = MQTTConfig() + with patch("microdcs.mqtt.aiomqtt.Client") as mock_cls: + mock_cls.return_value = MagicMock() + create_mqtt_client(config, clean_start=True) + assert mock_cls.call_args[1]["clean_start"] is True def test_with_sat_and_tls(self): config = MQTTConfig() @@ -615,18 +708,21 @@ def test_with_sat_and_tls(self): config.tls_cert_path.__str__ = lambda self: "/fake/cert" # type: ignore[assignment] mock_file = MagicMock() - mock_file.__enter__ = MagicMock(return_value=MagicMock(read=lambda: "token")) + mock_file.__enter__ = MagicMock(return_value=MagicMock(read=lambda: b"token")) mock_file.__exit__ = MagicMock(return_value=False) with ( patch("microdcs.mqtt.aiomqtt.Client") as mock_cls, patch("builtins.open", return_value=mock_file), + patch("microdcs.mqtt.ssl.create_default_context") as mock_ssl, ): mock_cls.return_value = MagicMock() + mock_ssl.return_value = MagicMock() create_mqtt_client(config) call_kwargs = mock_cls.call_args[1] - assert call_kwargs["properties"] is not None - assert call_kwargs["tls_params"] is not None + assert call_kwargs["authentication_data"] is not None + assert call_kwargs["authentication_method"] == "K8S-SAT" + assert call_kwargs["ssl_context"] is not None # =================================================================== @@ -665,12 +761,14 @@ def test_client_with_sat_and_tls(self): with ( patch("microdcs.mqtt.aiomqtt.Client") as mock_client_cls, patch("builtins.open", return_value=mock_file), + patch("microdcs.mqtt.ssl.create_default_context") as mock_ssl, ): mock_client_cls.return_value = MagicMock() + mock_ssl.return_value = MagicMock() handler._client() call_kwargs = mock_client_cls.call_args[1] - assert call_kwargs["properties"] is not None - assert call_kwargs["tls_params"] is not None + assert call_kwargs["authentication_data"] is not None + assert call_kwargs["ssl_context"] is not None # --- _publish_message --- @@ -930,7 +1028,7 @@ async def test_is_not_duplicate_message(self): def test_cloudevent_from_message_basic(self): handler = _make_handler() - msg = _make_mqtt_message(properties=None) + msg = _make_mqtt_message() ce = handler._cloudevent_from_message(msg) assert ce.data == msg.payload assert ce.transportmetadata is not None @@ -939,16 +1037,16 @@ def test_cloudevent_from_message_basic(self): def test_cloudevent_from_message_with_properties(self): handler = _make_handler() corr_uuid = uuid.uuid4() - props = MagicMock() - props.MessageExpiryInterval = 120 - props.ContentType = "application/json" - props.ResponseTopic = "resp/topic" - props.CorrelationData = corr_uuid.bytes - props.UserProperty = [ - ("type", "com.test.sample.v1"), - ("source", "test-source"), - ] - msg = _make_mqtt_message(properties=props) + msg = _make_mqtt_message( + message_expiry_interval=120, + content_type="application/json", + response_topic="resp/topic", + correlation_data=corr_uuid.bytes, + user_properties=[ + ("type", "com.test.sample.v1"), + ("source", "test-source"), + ], + ) ce = handler._cloudevent_from_message(msg) assert ce.expiryinterval == 120 assert ce.datacontenttype == "application/json" @@ -960,27 +1058,18 @@ def test_cloudevent_from_message_with_properties(self): def test_cloudevent_from_message_invalid_correlation_data(self): handler = _make_handler() - props = MagicMock() - props.CorrelationData = b"\x00\x01" # Not 16 bytes — invalid UUID - del props.MessageExpiryInterval - del props.ContentType - del props.ResponseTopic - props.UserProperty = [] - msg = _make_mqtt_message(properties=props) + msg = _make_mqtt_message( + correlation_data=b"\x00\x01", # Not 16 bytes — invalid UUID + ) # Invalid UUID bytes still raise ValueError (stored in transportmetadata) with pytest.raises(ValueError): handler._cloudevent_from_message(msg) def test_cloudevent_from_message_custom_metadata(self): handler = _make_handler() - props = MagicMock() - props.UserProperty = [("customkey", "customval")] - # Remove attributes we don't need for this test - del props.MessageExpiryInterval - del props.ContentType - del props.ResponseTopic - del props.CorrelationData - msg = _make_mqtt_message(properties=props) + msg = _make_mqtt_message( + user_properties=[("customkey", "customval")], + ) ce = handler._cloudevent_from_message(msg) assert ce.custommetadata is not None assert ce.custommetadata.get("customkey") == "customval" @@ -1004,8 +1093,7 @@ async def test_process_message_duplicate(self): async def test_process_message_non_duplicate_no_match(self): handler = _make_handler() client = AsyncMock() - client._client = MagicMock() - client._client.ack = MagicMock() + client.puback = AsyncMock() handler._cloudevent_dedupe_dao.is_duplicate = AsyncMock(return_value=False) # No bindings registered → empty subscription @@ -1017,8 +1105,7 @@ async def test_process_message_non_duplicate_no_match(self): async def test_process_message_cancels_expiration(self): handler = _make_handler() client = AsyncMock() - client._client = MagicMock() - client._client.ack = MagicMock() + client.puback = AsyncMock() handler._cloudevent_dedupe_dao.is_duplicate = AsyncMock(return_value=False) @@ -1039,14 +1126,13 @@ async def test_process_message_cancels_expiration(self): async def test_process_message_dispatches_to_processor(self): handler = _make_handler() client = AsyncMock() - client._client = MagicMock() - client._client.ack = MagicMock() + client.puback = AsyncMock() handler._cloudevent_dedupe_dao.is_duplicate = AsyncMock(return_value=False) proc = _make_processor() proc.process_cloudevent = AsyncMock(return_value=None) - binding = _make_binding( + _make_binding( handler, proc, topics={"test/events/#"}, @@ -1054,19 +1140,42 @@ async def test_process_message_dispatches_to_processor(self): ) # Only match event topics, not the response topic - msg = _make_mqtt_message( - topic="test/events/foo", - match_topics=binding.topics, - ) + msg = _make_mqtt_message(topic="test/events/foo") await handler._process_message(client, msg) proc.process_cloudevent.assert_awaited_once() + @pytest.mark.asyncio + async def test_process_message_shared_subscription_topic_match(self): + """_process_message must dispatch when the binding topic has a $share/group/ + prefix but the delivered message topic does not (broker strips the prefix). + _topic_matches handles this transparently.""" + handler = _make_handler() + client = AsyncMock() + client.puback = AsyncMock() + + handler._cloudevent_dedupe_dao.is_duplicate = AsyncMock(return_value=False) + + proc = _make_processor() + proc.process_cloudevent = AsyncMock(return_value=None) + _make_binding( + handler, + proc, + topics={"$share/appsub/test/events/#"}, + response_topic="never/matches", + ) + + # Message delivered with original topic (no $share/ prefix) + msg = _make_mqtt_message(topic="test/events/foo") + ok, sub = await handler._process_message(client, msg) + + assert ok is True + proc.process_cloudevent.assert_awaited_once() + @pytest.mark.asyncio async def test_process_message_response_topic_match(self): handler = _make_handler() client = AsyncMock() - client._client = MagicMock() - client._client.ack = MagicMock() + client.puback = AsyncMock() handler._cloudevent_dedupe_dao.is_duplicate = AsyncMock(return_value=False) @@ -1079,10 +1188,7 @@ async def test_process_message_response_topic_match(self): response_topic="test/events/foo", ) - msg = _make_mqtt_message( - topic="test/events/foo", - match_topics={"test/events/foo"}, - ) + msg = _make_mqtt_message(topic="test/events/foo") await handler._process_message(client, msg) proc.process_response_cloudevent.assert_awaited() @@ -1090,8 +1196,7 @@ async def test_process_message_response_topic_match(self): async def test_process_message_publishes_list_response(self): handler = _make_handler() client = AsyncMock() - client._client = MagicMock() - client._client.ack = MagicMock() + client.puback = AsyncMock() handler._cloudevent_dedupe_dao.is_duplicate = AsyncMock(return_value=False) @@ -1099,7 +1204,7 @@ async def test_process_message_publishes_list_response(self): resp1 = CloudEvent(transportmetadata={"mqtt_topic": "out"}) resp2 = CloudEvent(transportmetadata={"mqtt_topic": "out"}) proc.process_cloudevent = AsyncMock(return_value=[resp1, resp2]) - binding = _make_binding( + _make_binding( handler, proc, topics={"test/events/#"}, @@ -1109,7 +1214,7 @@ async def test_process_message_publishes_list_response(self): with patch.object( handler, "_publish_message", new_callable=AsyncMock ) as mock_pub: - msg = _make_mqtt_message(match_topics=binding.topics) + msg = _make_mqtt_message(topic="test/events/foo") await handler._process_message(client, msg) assert mock_pub.await_count == 2 @@ -1117,15 +1222,14 @@ async def test_process_message_publishes_list_response(self): async def test_process_message_publishes_single_response(self): handler = _make_handler() client = AsyncMock() - client._client = MagicMock() - client._client.ack = MagicMock() + client.puback = AsyncMock() handler._cloudevent_dedupe_dao.is_duplicate = AsyncMock(return_value=False) proc = _make_processor() single_resp = CloudEvent(transportmetadata={"mqtt_topic": "out"}) proc.process_cloudevent = AsyncMock(return_value=single_resp) - binding = _make_binding( + _make_binding( handler, proc, topics={"test/events/#"}, @@ -1135,7 +1239,7 @@ async def test_process_message_publishes_single_response(self): with patch.object( handler, "_publish_message", new_callable=AsyncMock ) as mock_pub: - msg = _make_mqtt_message(match_topics=binding.topics) + msg = _make_mqtt_message(topic="test/events/foo") await handler._process_message(client, msg) mock_pub.assert_awaited_once() @@ -1255,17 +1359,33 @@ async def test_task_cancelled(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.subscribe = AsyncMock() - mock_client.messages = MagicMock() + + async def _empty_messages(): + return + yield + + mock_client.messages = MagicMock(return_value=_empty_messages()) # Use a cancelled Future so asyncio.gather can await it loop = asyncio.get_event_loop() mock_task = loop.create_future() mock_task.cancel() + def _create_task_closing_coro(coro, **kwargs): + """Close the coroutine to avoid 'never awaited' warnings.""" + import inspect + + if inspect.iscoroutine(coro): + coro.close() + return mock_task + # Make asyncio.wait raise CancelledError to simulate force shutdown with ( patch.object(handler, "_client", return_value=mock_client), - patch("microdcs.mqtt.asyncio.create_task", return_value=mock_task), + patch( + "microdcs.mqtt.asyncio.create_task", + side_effect=_create_task_closing_coro, + ), patch("microdcs.mqtt.asyncio.wait", side_effect=asyncio.CancelledError()), ): with pytest.raises(asyncio.CancelledError): @@ -1291,10 +1411,14 @@ def _setup_handler_for_shutdown(self): client.__aexit__ = AsyncMock(return_value=False) client.subscribe = AsyncMock() client.unsubscribe = AsyncMock() - client._client = MagicMock() - client._client.ack = MagicMock() - client.messages = MagicMock() - client.messages.__aiter__ = MagicMock(return_value=iter([])) + client.puback = AsyncMock() + + # messages() is a method returning an async iterator in v3 + async def _empty_messages(): + return + yield # make it an async generator + + client.messages = MagicMock(return_value=_empty_messages()) return handler, client, binding, proc @@ -1349,7 +1473,7 @@ async def test_graceful_shutdown_unsubscribe_failure_continues(self): handler, client, binding, _ = self._setup_handler_for_shutdown() client.unsubscribe = AsyncMock( - side_effect=_aiomqtt.MqttError("unsubscribe failed") + side_effect=_aiomqtt.ConnectError("unsubscribe failed") ) handler._shutdown_event.set() @@ -1530,16 +1654,10 @@ async def long_expiry(): @pytest.mark.asyncio async def test_mqtt_error_during_shutdown_exits(self): """MqttError while shutdown event is set exits the retry loop.""" - import aiomqtt as _aiomqtt - - handler, client, binding, _ = self._setup_handler_for_shutdown() - handler._shutdown_event.set() - - client.__aenter__ = AsyncMock(side_effect=_aiomqtt.MqttError("connection lost")) - - with patch.object(handler, "_client", return_value=client): - # Should exit cleanly, not retry forever - await handler.task() + # With reconnect=True the library handles reconnects internally; + # ConnectError from __aenter__ can no longer be caught at task() level. + # This scenario is now handled by the aiomqtt reconnect machinery. + pass # =================================================================== @@ -1548,113 +1666,23 @@ async def test_mqtt_error_during_shutdown_exits(self): class TestMQTTHandlerReconnection: - """Tests for the MQTT retry/backoff loop.""" + """Verify that reconnect=True is delegated to the aiomqtt Client.""" - def _setup_handler(self): + def test_client_has_reconnect_enabled(self): + """create_mqtt_client always passes reconnect=True to aiomqtt.Client.""" handler = _make_handler() - handler._redis_client.ping = AsyncMock() - binding = _make_binding(handler, topics={"test/topic"}) - client = MagicMock() - client.__aenter__ = AsyncMock(return_value=client) - client.__aexit__ = AsyncMock(return_value=False) - client.subscribe = AsyncMock() - return handler, client, binding - - @pytest.mark.asyncio - async def test_mqtt_error_retries_with_backoff(self): - """MqttError triggers retry with exponential backoff (1 → 2 → 4).""" - import aiomqtt as _aiomqtt - - handler, client, _ = self._setup_handler() - - call_count = 0 - - async def fail_then_shutdown(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count >= 3: - handler._shutdown_event.set() - raise _aiomqtt.MqttError("connection lost") - - client.__aenter__ = AsyncMock(side_effect=fail_then_shutdown) - - with ( - patch.object(handler, "_client", return_value=client), - patch("microdcs.mqtt.random.uniform", return_value=0), - ): - await handler.task() - - assert call_count == 3 - - @pytest.mark.asyncio - async def test_shutdown_event_during_mqtt_error_exits_immediately(self): - """If shutdown is already set when MqttError occurs, exits without backoff.""" - import aiomqtt as _aiomqtt - - handler, client, _ = self._setup_handler() - handler._shutdown_event.set() - - client.__aenter__ = AsyncMock(side_effect=_aiomqtt.MqttError("connection lost")) - - with patch.object(handler, "_client", return_value=client): - await handler.task() - - @pytest.mark.asyncio - async def test_shutdown_during_backoff_exits(self): - """Shutdown event during backoff sleep exits the retry loop.""" - import aiomqtt as _aiomqtt - - handler, client, _ = self._setup_handler() - - client.__aenter__ = AsyncMock(side_effect=_aiomqtt.MqttError("connection lost")) - - async def signal_shutdown(coro, timeout): - handler._shutdown_event.set() - - with ( - patch.object(handler, "_client", return_value=client), - patch( - "asyncio.wait_for", - side_effect=signal_shutdown, - ), - ): - await handler.task() - - @pytest.mark.asyncio - async def test_backoff_caps_at_max(self): - """Backoff increases but does not exceed 60 seconds.""" - import aiomqtt as _aiomqtt - - handler, client, _ = self._setup_handler() - - sleep_times: list[float] = [] - call_count = 0 - - async def fail_always(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count > 8: - handler._shutdown_event.set() - raise _aiomqtt.MqttError("connection lost") - - client.__aenter__ = AsyncMock(side_effect=fail_always) - - async def capture_sleep(coro, timeout): - sleep_times.append(timeout) - raise asyncio.TimeoutError - - with ( - patch.object(handler, "_client", return_value=client), - patch("microdcs.mqtt.random.uniform", return_value=0), - patch("asyncio.wait_for", side_effect=capture_sleep), - ): - await handler.task() + with patch("microdcs.mqtt.aiomqtt.Client") as mock_client_cls: + mock_client_cls.return_value = MagicMock() + handler._client() + assert mock_client_cls.call_args[1]["reconnect"] is True - # Backoff: 1, 2, 4, 8, 16, 32, 60, 60 (capped) - assert sleep_times[0] == 1 - assert sleep_times[1] == 2 - assert sleep_times[2] == 4 - assert sleep_times[-1] == 60 # capped at max + def test_client_uses_persistent_session(self): + """create_mqtt_client always passes clean_start=False.""" + handler = _make_handler() + with patch("microdcs.mqtt.aiomqtt.Client") as mock_client_cls: + mock_client_cls.return_value = MagicMock() + handler._client() + assert mock_client_cls.call_args[1]["clean_start"] is False # =================================================================== @@ -1670,169 +1698,66 @@ def _make_otel_handler(self) -> OTELInstrumentedMQTTHandler: with patch("microdcs.mqtt.redis.Redis"): return OTELInstrumentedMQTTHandler(config, pool, key_schema) - def test_init_sets_tracer_meter_metrics(self): - handler = self._make_otel_handler() - assert handler._tracer is not None - assert handler._meter is not None - assert handler._consumed_messages is not None - assert handler._process_duration is not None - assert handler._operation_duration is not None - assert handler._sent_messages is not None - @pytest.mark.asyncio - async def test_process_message_with_tracing(self): + async def test_process_message_sets_subscription_name_on_span(self): + """MESSAGING_DESTINATION_SUBSCRIPTION_NAME is set on the active span when a topic matches.""" handler = self._make_otel_handler() client = AsyncMock() - client._client = MagicMock() - client._client.ack = MagicMock() + client.puback = AsyncMock() handler._cloudevent_dedupe_dao.is_duplicate = AsyncMock(return_value=False) - handler._consumed_messages = MagicMock() - handler._process_duration = MagicMock() - - msg = _make_mqtt_message() - props = MagicMock() - props.UserProperty = [("traceparent", "00-abc-def-01")] - del props.MessageExpiryInterval - del props.ContentType - del props.ResponseTopic - del props.CorrelationData - msg.properties = props - - error, sub = await handler._process_message(client, msg) - handler._consumed_messages.add.assert_called() + _make_binding(handler, topics={"test/events/foo"}) - @pytest.mark.asyncio - async def test_process_message_without_user_properties(self): - handler = self._make_otel_handler() - client = AsyncMock() - client._client = MagicMock() - client._client.ack = MagicMock() + msg = _make_mqtt_message(topic="test/events/foo") - handler._cloudevent_dedupe_dao.is_duplicate = AsyncMock(return_value=False) - handler._consumed_messages = MagicMock() - handler._process_duration = MagicMock() + mock_span = MagicMock() + with patch("microdcs.mqtt.trace.get_current_span", return_value=mock_span): + ok, sub = await handler._process_message(client, msg) - msg = _make_mqtt_message(properties=None) - error, sub = await handler._process_message(client, msg) - handler._consumed_messages.add.assert_called() + assert ok is True + assert sub != "" + mock_span.set_attribute.assert_called_once_with( + "messaging.destination.subscription.name", + sub, + ) @pytest.mark.asyncio - async def test_process_message_success_no_error_type(self): - """A normally processed message must not set error.type on metrics.""" + async def test_process_message_no_match_skips_attribute(self): + """MESSAGING_DESTINATION_SUBSCRIPTION_NAME is not set when subscription is empty.""" handler = self._make_otel_handler() client = AsyncMock() - client._client = MagicMock() - client._client.ack = MagicMock() + client.puback = AsyncMock() handler._cloudevent_dedupe_dao.is_duplicate = AsyncMock(return_value=False) - handler._consumed_messages = MagicMock() - handler._process_duration = MagicMock() + # no bindings registered — subscription will be empty + + msg = _make_mqtt_message(topic="test/events/foo") - msg = _make_mqtt_message(properties=None) - ok, _sub = await handler._process_message(client, msg) + mock_span = MagicMock() + with patch("microdcs.mqtt.trace.get_current_span", return_value=mock_span): + ok, sub = await handler._process_message(client, msg) assert ok is True - attrs = handler._process_duration.record.call_args[0][1] - assert "error.type" not in attrs + assert sub == "" + mock_span.set_attribute.assert_not_called() @pytest.mark.asyncio - async def test_process_message_duplicate_no_error_type(self): - """A duplicate (skipped) message is not a processing error; error.type must not be set.""" + async def test_process_message_duplicate_skips_attribute(self): + """A duplicate message returns early; MESSAGING_DESTINATION_SUBSCRIPTION_NAME is not set.""" handler = self._make_otel_handler() client = AsyncMock() - client._client = MagicMock() - client._client.ack = MagicMock() + client.puback = AsyncMock() handler._cloudevent_dedupe_dao.is_duplicate = AsyncMock(return_value=True) - handler._consumed_messages = MagicMock() - handler._process_duration = MagicMock() - msg = _make_mqtt_message(properties=None) - ok, _sub = await handler._process_message(client, msg) + msg = _make_mqtt_message(topic="test/events/foo") - assert ok is False - attrs = handler._process_duration.record.call_args[0][1] - assert "error.type" not in attrs - - @pytest.mark.asyncio - async def test_publish_message_producer_span_and_metrics(self): - """_publish_message override creates a PRODUCER span and records sent/operation metrics.""" - handler = self._make_otel_handler() - handler._operation_duration = MagicMock() - handler._sent_messages = MagicMock() - - client = AsyncMock() - client.publish = AsyncMock() + mock_span = MagicMock() + with patch("microdcs.mqtt.trace.get_current_span", return_value=mock_span): + ok, sub = await handler._process_message(client, msg) - ce = CloudEvent( - type="com.example.test", - source="test", - transportmetadata={"mqtt_topic": "test/topic"}, - datacontenttype="application/json", - data=b'{"hello": "world"}', - ) - - with patch.object(handler, "_publish_message", wraps=handler._publish_message): - # patch the parent class _publish_message so it doesn't need a real MQTT client - with patch.object( - type(handler).__bases__[0], "_publish_message", new=AsyncMock() - ) as mock_parent: - await handler._publish_message(client, ce) - mock_parent.assert_called_once() - - handler._sent_messages.add.assert_called_once() - handler._operation_duration.record.assert_called_once() - attrs = handler._sent_messages.add.call_args[0][1] - assert attrs["messaging.system"] == "mqtt" - assert attrs["messaging.operation.name"] == "publish" - assert "error.type" not in attrs - - @pytest.mark.asyncio - async def test_publish_message_injects_trace_context(self): - """_publish_message injects W3C traceparent into custommetadata.""" - handler = self._make_otel_handler() - handler._operation_duration = MagicMock() - handler._sent_messages = MagicMock() - - client = AsyncMock() - ce = CloudEvent( - type="com.example.test", - source="test", - transportmetadata={"mqtt_topic": "test/topic"}, - ) - - with patch.object( - type(handler).__bases__[0], "_publish_message", new=AsyncMock() - ): - with patch("microdcs.mqtt.inject") as mock_inject: - mock_inject.side_effect = lambda carrier: carrier.update({ - "traceparent": "00-aaa-bbb-01" - }) - await handler._publish_message(client, ce) - - assert ce.custommetadata is not None - assert "traceparent" in ce.custommetadata - - @pytest.mark.asyncio - async def test_publish_message_no_topic_delegates_to_parent(self): - """_publish_message with no mqtt_topic falls through to parent (logs error).""" - handler = self._make_otel_handler() - handler._operation_duration = MagicMock() - handler._sent_messages = MagicMock() - - client = AsyncMock() - ce = CloudEvent(type="com.example.test", source="test") # no transportmetadata - - with patch.object( - type(handler).__bases__[0], "_publish_message", new=AsyncMock() - ) as mock_parent: - await handler._publish_message(client, ce) - mock_parent.assert_called_once() - - # no metrics when delegating without a topic - handler._sent_messages.add.assert_not_called() - handler._operation_duration.record.assert_not_called() + assert ok is False + mock_span.set_attribute.assert_not_called() # =================================================================== diff --git a/tests/test_msgpack.py b/tests/unit/test_msgpack.py similarity index 100% rename from tests/test_msgpack.py rename to tests/unit/test_msgpack.py diff --git a/tests/test_package_exports.py b/tests/unit/test_package_exports.py similarity index 100% rename from tests/test_package_exports.py rename to tests/unit/test_package_exports.py diff --git a/tests/test_publisher.py b/tests/unit/test_publisher.py similarity index 96% rename from tests/test_publisher.py rename to tests/unit/test_publisher.py index 8701b01..0b9d49c 100644 --- a/tests/test_publisher.py +++ b/tests/unit/test_publisher.py @@ -1,5 +1,6 @@ from unittest.mock import AsyncMock, MagicMock, patch +import aiomqtt import pytest from microdcs.models.machinery_jobs import ISA95StateDataType, LocalizedText @@ -164,10 +165,9 @@ async def test_publish_retained_calls_client_publish(self): call_kwargs = mock_client.publish.call_args assert call_kwargs.args[0] == "test/topic" assert call_kwargs.args[1] == b"payload" - assert call_kwargs.kwargs["qos"] == 1 + assert call_kwargs.kwargs["qos"] == aiomqtt.QoS.AT_LEAST_ONCE assert call_kwargs.kwargs["retain"] is True - props = call_kwargs.kwargs["properties"] - assert props.MessageExpiryInterval == 3600 + assert call_kwargs.kwargs["message_expiry_interval"] == 3600 @pytest.mark.asyncio async def test_delete_retained_publishes_zero_bytes(self): @@ -181,7 +181,7 @@ async def test_delete_retained_publishes_zero_bytes(self): call_kwargs = mock_client.publish.call_args assert call_kwargs.args[0] == "test/topic" assert call_kwargs.args[1] == b"" - assert call_kwargs.kwargs["qos"] == 1 + assert call_kwargs.kwargs["qos"] == aiomqtt.QoS.AT_LEAST_ONCE assert call_kwargs.kwargs["retain"] is True @pytest.mark.asyncio @@ -234,7 +234,7 @@ async def test_connected_event_set_during_task(self): connected_during_run = False class TestPublisher(MQTTPublisher): - async def _run(self) -> None: + async def _run(self) -> None: # type: ignore[override] nonlocal connected_during_run connected_during_run = self._connected.is_set() diff --git a/tests/test_redis.py b/tests/unit/test_redis.py similarity index 100% rename from tests/test_redis.py rename to tests/unit/test_redis.py diff --git a/tests/test_sfc_engine.py b/tests/unit/test_sfc_engine.py similarity index 100% rename from tests/test_sfc_engine.py rename to tests/unit/test_sfc_engine.py diff --git a/tests/test_sfc_recipe.py b/tests/unit/test_sfc_recipe.py similarity index 95% rename from tests/test_sfc_recipe.py rename to tests/unit/test_sfc_recipe.py index dff6ebd..0591d26 100644 --- a/tests/test_sfc_recipe.py +++ b/tests/unit/test_sfc_recipe.py @@ -40,17 +40,15 @@ def test_roundtrip_non_initial_step(self): assert restored.name == "Processing" assert restored.initial is False - def test_json_roundtrip(self): + def test_json_roundtrip(self, benchmark): step = SfcStep(name="Init", initial=True) - json_bytes = step.to_jsonb() - restored = SfcStep.from_json(json_bytes) + restored = benchmark(lambda: SfcStep.from_json(step.to_jsonb())) assert restored.name == "Init" assert restored.initial is True - def test_msgpack_roundtrip(self): + def test_msgpack_roundtrip(self, benchmark): step = SfcStep(name="Init", initial=True) - packed = step.to_msgpack() - restored = SfcStep.from_msgpack(packed) + restored = benchmark(lambda: SfcStep.from_msgpack(step.to_msgpack())) assert restored.name == "Init" assert restored.initial is True @@ -152,7 +150,7 @@ def test_config_attributes(self): == "https://aschamberger.github.io/schemas/microdcs/sfc-recipe/v1.0.0/SfcRecipe/" ) - def test_minimal_recipe_roundtrip(self): + def test_minimal_recipe_roundtrip(self, benchmark): recipe = SfcRecipe( steps=[ SfcStep(name="Init", initial=True), @@ -172,15 +170,14 @@ def test_minimal_recipe_roundtrip(self): ), ], ) - json_bytes = recipe.to_jsonb() - restored = SfcRecipe.from_json(json_bytes) + restored = benchmark(lambda: SfcRecipe.from_json(recipe.to_jsonb())) assert len(restored.steps) == 2 assert restored.steps[0].initial is True assert len(restored.transitions) == 1 assert len(restored.actions) == 1 assert restored.branches is None - def test_full_recipe_json_roundtrip(self): + def test_full_recipe_json_roundtrip(self, benchmark): """Roundtrip the example recipe from the sfc_engine.md docs.""" recipe_json = orjson.dumps({ "Steps": [ @@ -251,7 +248,7 @@ def test_full_recipe_json_roundtrip(self): ], }) - recipe = SfcRecipe.from_json(recipe_json) + recipe = benchmark(SfcRecipe.from_json, recipe_json) # Steps assert len(recipe.steps) == 6 @@ -288,7 +285,7 @@ def test_full_recipe_json_roundtrip(self): assert recipe2.branches is not None assert len(recipe2.branches) == 1 - def test_msgpack_roundtrip(self): + def test_msgpack_roundtrip(self, benchmark): recipe = SfcRecipe( steps=[SfcStep(name="S1", initial=True)], transitions=[SfcTransition(source="S1", target="S2", condition="done")], @@ -303,8 +300,7 @@ def test_msgpack_roundtrip(self): ) ], ) - packed = recipe.to_msgpack() - restored = SfcRecipe.from_msgpack(packed) + restored = benchmark(lambda: SfcRecipe.from_msgpack(recipe.to_msgpack())) assert restored.steps[0].name == "S1" assert restored.actions[0].qualifier == SfcActionQualifier.PULSE assert restored.actions[0].interaction == SfcInteraction.PULL_EVENT diff --git a/uv.lock b/uv.lock index 2ef62b1..5c90855 100644 --- a/uv.lock +++ b/uv.lock @@ -2,16 +2,19 @@ version = 1 revision = 3 requires-python = ">=3.14" +[manifest] +overrides = [ + { name = "opentelemetry-api", specifier = "==1.41.1" }, + { name = "opentelemetry-instrumentation", specifier = "==0.62b1" }, + { name = "opentelemetry-semantic-conventions", specifier = "==0.62b1" }, +] + [[package]] name = "aiomqtt" -version = "2.5.1" -source = { registry = "https://pypi.org/simple" } +version = "3.0.0a1" +source = { git = "https://github.com/empicano/aiomqtt.git?rev=main#4c362b39a54a64e9a39720d29190cd56f98cb0ca" } dependencies = [ - { name = "paho-mqtt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/70/44/cfc58272783a11729462dc6df5adbfeabd084f840f609054ac772ae98c19/aiomqtt-2.5.1.tar.gz", hash = "sha256:25a0a47d157e8f158d2da1110ea4786c0615518751e94f7b04976c977a8ff20d", size = 86641, upload-time = "2026-03-05T18:28:56.421Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/9e/5089fa596220bf0dc73deeb23db27904e4b3504986caf08571f6f5cb84a8/aiomqtt-2.5.1-py3-none-any.whl", hash = "sha256:fd58c3593160e4d475d90ce911cdfc4239cd64de96b0ba22edf6c86bd7afa278", size = 16051, upload-time = "2026-03-05T18:28:55.14Z" }, + { name = "mqtt5" }, ] [[package]] @@ -63,6 +66,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "click" version = "8.3.3" @@ -165,6 +218,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/5c/e226de133afd8bb267ec27eead9ae3d784b95b39a287ed404caab39a5f50/genson-1.3.0-py3-none-any.whl", hash = "sha256:468feccd00274cc7e4c09e84b08704270ba8d95232aa280f65b986139cec67f7", size = 21470, upload-time = "2024-05-15T22:08:47.056Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +] + [[package]] name = "hiredis" version = "3.3.1" @@ -199,6 +285,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/d6/191e6741addc97bcf5e755661f8c82f0fd0aa35f07ece56e858da689b57e/hiredis-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ab1f646ff531d70bfd25f01e60708dfa3d105eb458b7dedd9fe9a443039fd809", size = 23811, upload-time = "2026-03-16T15:20:34.292Z" }, ] +[[package]] +name = "idna" +version = "3.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/b1/efac073e0c297ecf2fb33c346989a529d4e19164f1759102dee5953ee17e/idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3", size = 198272, upload-time = "2026-05-10T20:32:15.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" }, +] + [[package]] name = "importlib-metadata" version = "8.7.1" @@ -340,6 +435,9 @@ dependencies = [ { name = "mashumaro", extra = ["orjson"] }, { name = "msgpack" }, { name = "opentelemetry-distro" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation-aiomqtt" }, { name = "opentelemetry-instrumentation-redis" }, { name = "redis", extra = ["hiredis"] }, { name = "transitions" }, @@ -351,16 +449,20 @@ dev = [ { name = "datamodel-code-generator", extra = ["ruff"] }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-benchmark" }, { name = "pytest-cov" }, { name = "zensical" }, ] [package.metadata] requires-dist = [ - { name = "aiomqtt", specifier = ">=2.4.0" }, + { name = "aiomqtt", git = "https://github.com/empicano/aiomqtt.git?rev=main" }, { name = "mashumaro", extras = ["orjson"], specifier = ">=3.21" }, { name = "msgpack", specifier = ">=1.1.2" }, { name = "opentelemetry-distro", specifier = ">=0.60b1" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.41.1" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.41.1" }, + { name = "opentelemetry-instrumentation-aiomqtt", git = "https://github.com/aschamberger/opentelemetry-python-contrib?subdirectory=instrumentation%2Fopentelemetry-instrumentation-aiomqtt&branch=feat%2Faiomqtt-instrumentation" }, { name = "opentelemetry-instrumentation-redis", specifier = ">=0.62b0" }, { name = "redis", extras = ["hiredis"], specifier = ">=7.4.0" }, { name = "transitions", specifier = ">=0.9.3" }, @@ -372,6 +474,7 @@ dev = [ { name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.57.0" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-benchmark", specifier = ">=5.2.3" }, { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "zensical" }, ] @@ -385,6 +488,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, ] +[[package]] +name = "mqtt5" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/98/10d6ded6a874d443b17a8342eb51a9a50788fd8e88a05aeff84729f8dea7/mqtt5-0.7.0.tar.gz", hash = "sha256:b210ce32ce98731b42589a0ae655a10511fb61052a4b9e0afbfc70937b9cbd16", size = 43500, upload-time = "2026-04-23T16:40:39.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/d6/a514c6644c31be856e208f58430a20928149f293f7e46abe8f2472b01b7d/mqtt5-0.7.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2df318554bd477aa945b584f55e863ce66c82d8d2fc6fb0d437b87e54e7f11a3", size = 388263, upload-time = "2026-04-23T16:40:14.262Z" }, + { url = "https://files.pythonhosted.org/packages/3f/77/4615ab7d307d002f10018785843a1e69b0e9fcc4c55bef8e338ec08a8d18/mqtt5-0.7.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:c1c633130e87dfe0d0397f4c350de750e65f6de792be2c1e99f4ec7fb195e011", size = 359096, upload-time = "2026-04-23T16:40:38.305Z" }, + { url = "https://files.pythonhosted.org/packages/9c/32/f7b986a325a8b54f0e04f9ab4410c5140d8d4c7cfa6b872da718b80465a4/mqtt5-0.7.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1011091cb1b820b176ca15ce2aebae0ba573af0b9fddb939ec6c9b98ee7c94cd", size = 373407, upload-time = "2026-04-23T16:40:47.703Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ad/199599d599b2e05899bcbcc2d74947cc562ba4ce4fc822893125d7c20ea5/mqtt5-0.7.0-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:966eeee75e76c32562bdcb705f252bf033c772360504e6e2e7d2a2c5b2a20cfc", size = 386252, upload-time = "2026-04-23T16:40:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/f1/30/bab35c91e80e70d0f110613f4358fe9ebb6b3332cdb087e5beb632fcf160/mqtt5-0.7.0-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ebee831b526b36bfc8f1cdfe82aea8673c15f2f47131bc45d43985f8e55798d", size = 420902, upload-time = "2026-04-23T16:40:21.797Z" }, + { url = "https://files.pythonhosted.org/packages/9d/b5/540a9a6e6bcf3df38a4607689345906e8d6bce3866e3efacaaf74c876060/mqtt5-0.7.0-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1015e7265559e8c18eb4fad10de5b762c2f0c6d8ed0d28147e2302e1db5fdbf4", size = 411605, upload-time = "2026-04-23T16:40:46.64Z" }, + { url = "https://files.pythonhosted.org/packages/01/ad/9ee0737ea9ece1e32342a49f6539863d946cf35dc8a13a11ddfe6d9d90e4/mqtt5-0.7.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5635dddda9db766efa81470c3c1c3c9748a3db416a5302aa633169ca85d3d1d", size = 390862, upload-time = "2026-04-23T16:40:26.41Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/70e20e35879e7d07adca66b73d7035cc32cc681ade3c8e6a59e3cf0ce48c/mqtt5-0.7.0-cp311-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7bddc877be54dddf6e61b94f11b6d55873bd80ef53b455113f7757b46d5ac2b3", size = 411000, upload-time = "2026-04-23T16:40:32.042Z" }, + { url = "https://files.pythonhosted.org/packages/33/77/4e863d971116e7f8c89347e28aa89cc666485699c0b7d722204ab13681a0/mqtt5-0.7.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9dc6abfe7f35bb425676d13efd441d97b9d197517cf6e034b87a47f7de70d24b", size = 551493, upload-time = "2026-04-23T16:40:30.719Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5c/41335b96fc3acae8812a3909a0d244cea66b2f4b78d1d73d8e56eccc1bcb/mqtt5-0.7.0-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2185a35bb580abbf578a5718a3f7f4833654fff7fc521d27682727ce267f9304", size = 662889, upload-time = "2026-04-23T16:40:45.561Z" }, + { url = "https://files.pythonhosted.org/packages/b4/97/b713ea8efa9fd129e7a38c027719a15ecb0c1a1c5581288c8372d9f6dea4/mqtt5-0.7.0-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:df7871a9b45ee5a7d770f6aa67498280ced4d97f0d3d769aae776bd5ed5a315d", size = 625027, upload-time = "2026-04-23T16:40:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/78/11/755885e71d4370d7aef3a06e6950d7adc0ba86b574e99a46662a3a596cfc/mqtt5-0.7.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:de6acae139403e1cf02f70e273ce1ac3f29b87907781908807b5178ce9c718c3", size = 606014, upload-time = "2026-04-23T16:40:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/2c/26/f1054826a04beb3a25fa16cfb4d79bb549fc2fd1ba14dea4839c4052eff1/mqtt5-0.7.0-cp311-abi3-win32.whl", hash = "sha256:f196a0b3c9917aaa78a05fa588288c577a0defeb908c401e431f50186fe5b37d", size = 296342, upload-time = "2026-04-23T16:40:44.12Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b4/6ffcd65117b475a827b9c9cc71788f97175c7e7151cd811bb4291906007f/mqtt5-0.7.0-cp311-abi3-win_amd64.whl", hash = "sha256:512c2f711c5268e9d7b45b199b224bcd711dd9c999f1bf94ccf976104253996a", size = 333135, upload-time = "2026-04-23T16:40:42.71Z" }, + { url = "https://files.pythonhosted.org/packages/d8/65/a35d107a922276f64327b0cba396b28dc5cb3598ffd36942ed0ecbdabc52/mqtt5-0.7.0-cp311-abi3-win_arm64.whl", hash = "sha256:aeab9ae2c138a9ad597eaca00f3634ed1c6e0a5c1b1776c327ef1ee66b95a49f", size = 307420, upload-time = "2026-04-23T16:40:40.184Z" }, + { url = "https://files.pythonhosted.org/packages/01/d7/a38aa43ec790df7e54b8e89a3765cb6e2d8de93efd59088e94a4968f1b21/mqtt5-0.7.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a8cee885d771c6c721368c48999c43e7a3c110b5e9915728936360c3e832ebdb", size = 379716, upload-time = "2026-04-23T16:40:29.702Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ef/47dcecd6f4cead835d8c95ad58b03e73fb9e4ce17c6e9a1f29cabebaa71c/mqtt5-0.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9a3bf2b3dea67ec9134e08e06dd7d7773b924efc0f3ebbbd78290ab685b5d90b", size = 353517, upload-time = "2026-04-23T16:40:22.819Z" }, + { url = "https://files.pythonhosted.org/packages/2f/8e/eb113626f3f96e711245c8d328d793c51abeec67c71a2ef069cfe91208fb/mqtt5-0.7.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3a0c16d75af4731a57dc29534f754693bd2414008088d579040d7ef00baaffd", size = 368242, upload-time = "2026-04-23T16:40:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/66/b8/1ee5d4eb936bbe463d5a45ed3e6681d56e8ee9d72002608569a57884586f/mqtt5-0.7.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20bec4a26e1998d7b5b9a46ad6c127587849ae2e90f3e230434df499b24a7cbe", size = 381635, upload-time = "2026-04-23T16:40:20.602Z" }, + { url = "https://files.pythonhosted.org/packages/51/1a/b181514b415897bd0b398cfdb121b00902854a5a9763f478559dec2c877a/mqtt5-0.7.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b8e5931047b4d3eb5f6241055b8539463c57e8d8a6764b4fdf921f3f43c6a45", size = 416378, upload-time = "2026-04-23T16:40:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/06/f6/69f30f8e9d483e22efaf3be9517e462018a826111e5bdb77230feeeb193e/mqtt5-0.7.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d108e4781829047839a6854edde3a452c0dd05f92a12c54a7d88f8e89d48867", size = 410026, upload-time = "2026-04-23T16:40:41.279Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d3/1b73abea54fbd417af12eadb0615768963a3613e525c6f5e3f4f0e6b02e9/mqtt5-0.7.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f24738029104600366cf5c00e02ff4a63692ee235b13c0a2d0231e030aeae862", size = 386473, upload-time = "2026-04-23T16:40:27.785Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4a/a06f1dd1c534bbfcabb475fde98326d5b8f5711b37fbaae18f4b688129a9/mqtt5-0.7.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b2973a44dbb8955e4f3f65b0d08700f65570dbdf596afd9beef84d4e53688c34", size = 404819, upload-time = "2026-04-23T16:40:34.52Z" }, + { url = "https://files.pythonhosted.org/packages/e6/45/776d02a87ef01aa14c3010483fb4a2475bec584c0d624b19e0af9333a2c4/mqtt5-0.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f9f22f892eaca2fab89dab45c4dbc81c4556fe72c7c28786ae878486858d112", size = 545889, upload-time = "2026-04-23T16:40:35.574Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c5/67962029f0eb9039a5085c8671f40a296765f91bf6dbf64834a1bda37789/mqtt5-0.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c714f082f9b7a75b756becc86baa641f0d5460bdaafe5045cb6db3686306887e", size = 658139, upload-time = "2026-04-23T16:40:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a8/4fd2b8a9769b959faddd5b67333838307d17f27f1dc39e530356bd33a3a2/mqtt5-0.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b774be3bf0c69120d7fb64622889d6b2712f8b37b0ad0957567119239696d40d", size = 621116, upload-time = "2026-04-23T16:40:15.829Z" }, + { url = "https://files.pythonhosted.org/packages/bd/70/cfd0ae1710d211bd76ff1c4b0e1e79435561c4d373f5cd66e89aefc6d62b/mqtt5-0.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3fc604d60ee402b10dc7e8c248dd8a92df27cb86722394ba6c6370df64c8b326", size = 601792, upload-time = "2026-04-23T16:40:23.856Z" }, + { url = "https://files.pythonhosted.org/packages/da/3d/ffc68e39a5e6d511eba4957c7ca7b8ddf0b19249328f9e6f19f4d92b53e4/mqtt5-0.7.0-cp314-cp314t-win32.whl", hash = "sha256:48eb62561e6b67cd201f6b2251dadc29e260d5648bdcb599e35b96195371bee6", size = 288129, upload-time = "2026-04-23T16:40:25.031Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ca/ddf3956cc26c04ae19b22b40eb90abb130c23f29c9abce7a6449fe1b3486/mqtt5-0.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9688040bd71b50f24b9b9e075bd19970c73d55bb55d14547d2f973845d584e67", size = 326397, upload-time = "2026-04-23T16:40:37.029Z" }, +] + [[package]] name = "msgpack" version = "1.1.2" @@ -447,6 +587,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/19/c58c119a299298f03d0797fcb780f221880e8d725959c71bcfb4ae034738/opentelemetry_distro-0.62b1-py3-none-any.whl", hash = "sha256:fd938de6ca1d047ffd15a65fa09d89f4b4ca7dd97ef25601a12d6d10efd693a0", size = 3348, upload-time = "2026-04-24T13:21:27.389Z" }, ] +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/fa/f9e3bd3c4d692b3ce9a2880a167d1f79681a1bea11f00d5bf76adc03e6ea/opentelemetry_exporter_otlp_proto_common-1.41.1.tar.gz", hash = "sha256:0e253156ea9c36b0bd3d2440c5c9ba7dd1f3fb64ba7a08fc85fbac536b56e1fb", size = 20409, upload-time = "2026-04-24T13:15:40.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/48/bce76d3ea772b609757e9bc844e02ab408a6446609bf74fb562062ba6b71/opentelemetry_exporter_otlp_proto_common-1.41.1-py3-none-any.whl", hash = "sha256:10da74dad6a49344b9b7b21b6182e3060373a235fde1528616d5f01f92e66aa9", size = 18366, upload-time = "2026-04-24T13:15:18.917Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/9b/e4503060b8695579dbaad187dc8cef4554188de68748c88060599b77489e/opentelemetry_exporter_otlp_proto_grpc-1.41.1.tar.gz", hash = "sha256:b05df8fa1333dc9a3fda36b676b96b5095ab6016d3f0c3296d430d629ba1443b", size = 25755, upload-time = "2026-04-24T13:15:41.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f2/c54f33c92443d087703e57e52e55f22f111373a5c4c4aa349ea60efe512e/opentelemetry_exporter_otlp_proto_grpc-1.41.1-py3-none-any.whl", hash = "sha256:537926dcef951136992479af1d9cd88f25e33d56c530e9f020ed57774dca2f94", size = 20297, upload-time = "2026-04-24T13:15:20.212Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/5b/9d3c7f70cca10136ba82a81e738dee626c8e7fc61c6887ea9a58bf34c606/opentelemetry_exporter_otlp_proto_http-1.41.1.tar.gz", hash = "sha256:4747a9604c8550ab38c6fd6180e2fcb80de3267060bef2c306bad3cb443302bc", size = 24139, upload-time = "2026-04-24T13:15:42.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" }, +] + [[package]] name = "opentelemetry-instrumentation" version = "0.62b1" @@ -462,6 +650,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/0f/45adbaea1f81b847cffdcee4f4b5f89297e42facf7fac78c7aaac4c38e75/opentelemetry_instrumentation-0.62b1-py3-none-any.whl", hash = "sha256:976fc6e640f2006599e97429c949e622c108d0c17c2059347d1e6c93c707f257", size = 34163, upload-time = "2026-04-24T13:21:31.722Z" }, ] +[[package]] +name = "opentelemetry-instrumentation-aiomqtt" +version = "0.63b0.dev0" +source = { git = "https://github.com/aschamberger/opentelemetry-python-contrib?subdirectory=instrumentation%2Fopentelemetry-instrumentation-aiomqtt&branch=feat%2Faiomqtt-instrumentation#803ac3577e44695e785439b68e7a488b666a32e1" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, +] + [[package]] name = "opentelemetry-instrumentation-redis" version = "0.62b1" @@ -477,6 +676,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/37/bc2271f3472e3041eeade8b8da1cfd3b06badae76fe5d0ff135b6285e70c/opentelemetry_instrumentation_redis-0.62b1-py3-none-any.whl", hash = "sha256:9aedd02c1acf631251d1d676634db47da9da04e0a626cd0c7d83fe0eb791d165", size = 15501, upload-time = "2026-04-24T13:22:11.705Z" }, ] +[[package]] +name = "opentelemetry-proto" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/e8/633c6d8a9c8840338b105907e55c32d3da1983abab5e52f899f72a82c3d1/opentelemetry_proto-1.41.1.tar.gz", hash = "sha256:4b9d2eb631237ea43b80e16c073af438554e32bc7e9e3f8ca4a9582f900020e5", size = 45670, upload-time = "2026-04-24T13:15:49.768Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/1e/5cd77035e3e82070e2265a63a760f715aacd3cb16dddc7efee913f297fcc/opentelemetry_proto-1.41.1-py3-none-any.whl", hash = "sha256:0496713b804d127a4147e32849fbaf5683fac8ee98550e8e7679cd706c289720", size = 72076, upload-time = "2026-04-24T13:15:32.542Z" }, +] + [[package]] name = "opentelemetry-sdk" version = "1.41.1" @@ -536,15 +747,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] -[[package]] -name = "paho-mqtt" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, -] - [[package]] name = "pathspec" version = "1.1.1" @@ -572,6 +774,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -678,6 +904,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-benchmark" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" }, +] + [[package]] name = "pytest-cov" version = "7.1.0" @@ -751,6 +990,21 @@ hiredis = [ { name = "hiredis" }, ] +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -894,6 +1148,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + [[package]] name = "wrapt" version = "2.1.2" @@ -927,7 +1190,7 @@ wheels = [ [[package]] name = "zensical" -version = "0.0.40" +version = "0.0.41" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -939,20 +1202,20 @@ dependencies = [ { name = "pyyaml" }, { name = "tomli" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/a6/88062f7e235f58a5f05d82005fc35d9dbaed27c024fe9ffae5bce7f33661/zensical-0.0.40.tar.gz", hash = "sha256:5c294751977a664614cb84e987186ad8e282af77ce0d0d800fe48ee57791279d", size = 3920555, upload-time = "2026-05-04T16:19:07.962Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/c4/3066f4442923ca1e49269147b70ca7c84467524e8f5228724693b9ac85c2/zensical-0.0.40-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b65a7143c9c6a460880bf3e65b777952bd2dcede9dd17a6c6bac9b4a0686ad9b", size = 12691533, upload-time = "2026-05-04T16:18:31.72Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cb/03e961cbd01620ea91aeb835b0b4e8848c7bcdf5a799a620fb3e57bfc277/zensical-0.0.40-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:045bdcb6d00a11ddcab7d379d0d986cdf78dba8e9287d8e628ef11958241507d", size = 12556486, upload-time = "2026-05-04T16:18:35.278Z" }, - { url = "https://files.pythonhosted.org/packages/60/76/7dde50220808bdc5f5e63b97866a684418410b3cae9d00cdae1d449bcc20/zensical-0.0.40-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d48ec476c2e8ce3f8585a1278083aabc35ec80361f2c4fc4a53b9a525778f7fc", size = 12935602, upload-time = "2026-05-04T16:18:38.308Z" }, - { url = "https://files.pythonhosted.org/packages/51/55/6c8ef951c390b42249738f4338498e7a1fd64ff09e44d7cc19f5c948c45b/zensical-0.0.40-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48c38e0ae314c25f2e5e64210bbad9be6e970f2d40fe9da106586ad90ce5e85e", size = 12904314, upload-time = "2026-05-04T16:18:41.007Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ae/95008f5dc2ee441efcdc2fab36ff29ce24d7477e53390fc340c8add39342/zensical-0.0.40-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25f62dcd61f6306cab890dfa34c81d2709f5db290b4c3f2675343771db28c90", size = 13269946, upload-time = "2026-05-04T16:18:44.387Z" }, - { url = "https://files.pythonhosted.org/packages/b9/96/cdbb2bf04255ccaaa07861bdda1ee8dd1630d2233fc2f09636abbd5e084c/zensical-0.0.40-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:168fe3489dd93ae92978b4db11d9300c63e10d382b81634232c2872ce9e746c2", size = 12974962, upload-time = "2026-05-04T16:18:47.462Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ce/66e86f89fc15bbe667794ba67d7efc8fa72fe7a1be19e1efb4246ff55442/zensical-0.0.40-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8652ba203bd588ebf2d66bda4457a4a7d8e193c886960859c75081c0e3b946de", size = 13111599, upload-time = "2026-05-04T16:18:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/87/76/3d71ebdabb02d79a5c523b5e646141c362c9559947078c8d56a9f3bd7a30/zensical-0.0.40-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9ffa6cf208b7ab6b771703be827d4d8c7f07f173abeffb35a8015a0b832b2a40", size = 13175406, upload-time = "2026-05-04T16:18:53.209Z" }, - { url = "https://files.pythonhosted.org/packages/e2/6a/2bb5f730786d590f02cb0fef796c148d5ac0d5c1556f2d78c987ad4e1346/zensical-0.0.40-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:7101ba0c739c78bc3a57d22130b59b9e6fdf96c21c8a6b4244070de6b34527d4", size = 13324783, upload-time = "2026-05-04T16:18:56.41Z" }, - { url = "https://files.pythonhosted.org/packages/2f/8c/1d2ba1454360ee948dd0f0807b048c076d9578d0d9ebba2a438ecfa9f82f/zensical-0.0.40-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:39bf728a68a5418feeda8f3385cd1063fdb8d896a6812c3dede4267b2868df12", size = 13260045, upload-time = "2026-05-04T16:18:59.244Z" }, - { url = "https://files.pythonhosted.org/packages/6c/61/efd51c5c5e15cfd5498d59df250f60294cc44d36d8ce4dc2a76fa3669c2f/zensical-0.0.40-cp310-abi3-win32.whl", hash = "sha256:bc750c3ba8d11833d9b9ac8fc14adc3435225b6d17314a21a91eb60209511ca5", size = 12244913, upload-time = "2026-05-04T16:19:02.219Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9e/f3f2118fbcfd1c2dc705491c8864c596b1a748b67ffe2a024e512b9201ab/zensical-0.0.40-cp310-abi3-win_amd64.whl", hash = "sha256:c5c86ac468df2dfe515ff54ffa97725c38226f1e5c970059b7e88078abab89ab", size = 12475762, upload-time = "2026-05-04T16:19:05.025Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/89/d6/b3e931233e53a2377ef5915cc6e786845c3263306874a469af8fb569ef9c/zensical-0.0.41.tar.gz", hash = "sha256:6c3c90301123749dfc26a210d6c080f0691253c7c765ad308a10b4518369a6fe", size = 3927788, upload-time = "2026-05-09T14:35:29.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/08/ee18207c9b4e3ada74a0f4adf253bea90da39ae43772761cd91072e3a1fc/zensical-0.0.41-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f06a0015dcfdf7aeca73f4998a401db65db0ae2dd72da9629a7be8f9a4d0b7b6", size = 12701539, upload-time = "2026-05-09T14:34:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/4c/93/d4635fbbce8171cf71dd64285d9f6d5773a2b624b928f1dd8acaf1ee9f9f/zensical-0.0.41-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:4e524ce68c9ff082ffaded9f742407097cf51bab692b7bc18d3c174b966174fe", size = 12560038, upload-time = "2026-05-09T14:34:51.666Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/1730a30377bbb0914ed740e0e289d379b0552673b6cf912aefe7a205440c/zensical-0.0.41-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4afe35331cd2394c408cd362458936479cc0ed4fb272478498e4794aafc7414", size = 12942926, upload-time = "2026-05-09T14:34:54.393Z" }, + { url = "https://files.pythonhosted.org/packages/32/e3/d9a0416ef4edc043ce9f404a66f1934f102bcb645b103abb26b180ba5680/zensical-0.0.41-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15a850285050f03aeb3b67ce7d99943093059fe8d32fc7731fa9f27be45c64cc", size = 12912711, upload-time = "2026-05-09T14:34:57.174Z" }, + { url = "https://files.pythonhosted.org/packages/68/d0/775852783bef835425306a2fcd8236ef14fd19160e1b4261e192bf2d9f54/zensical-0.0.41-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35052e9dbefabe3a71c4836cfc4afa6c9469e5eeddc2a3ee750803ae3fe777dc", size = 13275869, upload-time = "2026-05-09T14:34:59.93Z" }, + { url = "https://files.pythonhosted.org/packages/c3/95/554273cc09a270ced0213d3e0aac8b3fc2b472fc2b26771d56fc8fd55047/zensical-0.0.41-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a47f459205fb55f64dcb6c65e9f3c2fa00a2b4306c5ef1b71b9a50c45007071d", size = 12980177, upload-time = "2026-05-09T14:35:02.81Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b5/d74d5040b3121db5c72b0134f0455641b90b1277fb1330a8e5e0029ca8d3/zensical-0.0.41-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:aa3b3b3a4e6f75f6bb3c1aca1fad7a96cebf54cbd4e31122f6876503b8801666", size = 13119629, upload-time = "2026-05-09T14:35:07.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/9a/93527acd7750092d7fca2e6c43fe2b8f1e85e1c96a1002baf6a08201c6f7/zensical-0.0.41-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:565133fd48b2ce939698c174c0c1c6470407a8fb6a90a2bb0eeec97cd4344444", size = 13182183, upload-time = "2026-05-09T14:35:10.105Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/d77e4c809bfcbad40db85a6a7beeda2ee5c964232e0186783c3a837a7d0b/zensical-0.0.41-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:cec0a2b05eaaace0c7424bab3f2884da03ade212cac4ba4487c58691ec13ec65", size = 13330444, upload-time = "2026-05-09T14:35:13.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e8/ecbb7e34bff88aa892c676b8b2e2ddf425f94d66cbb84b80016095191b77/zensical-0.0.41-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1736f0cb7686628cc6f53952d208423f20b542f0c16b0c2ddd7e702bf6e41fdd", size = 13263093, upload-time = "2026-05-09T14:35:20.826Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6f/48b2f81ce708d19bb807d94716f2772ec4b74389b6d29024669fc470df08/zensical-0.0.41-cp310-abi3-win32.whl", hash = "sha256:34a78645c68fba152faacb66516c895283166154f8b15b61440a6c21c84f0974", size = 12253644, upload-time = "2026-05-09T14:35:23.598Z" }, + { url = "https://files.pythonhosted.org/packages/a0/92/5cf943133f61b996965743deeaff467f278135521f58d83ca68d2601ded3/zensical-0.0.41-cp310-abi3-win_amd64.whl", hash = "sha256:00d80cd573152e0efb655143bbdfe8788eb4b33167a802639fdb1b1800b724ac", size = 12483190, upload-time = "2026-05-09T14:35:26.43Z" }, ] [[package]]