From 6e81c09c04c570b6b6f92ec0e534deb397db21ec Mon Sep 17 00:00:00 2001 From: TheiLLeniumStudios <104288623+TheiLLeniumStudios@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:45:48 +0200 Subject: [PATCH] feat: json logging support --- README.md | 5 ++ config.example.toml | 3 +- poetry.lock | 74 ++++++++++++++++--------- tests/test_json_output.py | 91 +++++++++++++++++++++++++++++++ trafficlight/config.py | 1 + trafficlight/output/__init__.py | 3 + trafficlight/output/json_.py | 35 ++++++++++++ trafficlight/proto_utils/proto.py | 31 +++++++++++ 8 files changed, 217 insertions(+), 26 deletions(-) create mode 100644 tests/test_json_output.py create mode 100644 trafficlight/output/json_.py diff --git a/README.md b/README.md index 4a48bd9..616d545 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ the clutter There's also an option to simply print all requests or send them to Discord. You can use those if you don't like the UI. +Set `output = "json"` to emit one decoded-JSON object per RPC (JSONL) to stdout instead of a +rendered view — each line carries `timestamp`, `rpc_id`, `rpc_status`, `rpc_handle`, and a `protos` +list where every request/response is the **decoded** proto as JSON (no base64 blobs). Pipe it +straight to a file or `jq`, e.g. `trafficlight run > traffic.jsonl`. + ## TrafficLight CLI - `trafficlight run` to run the TUI diff --git a/config.example.toml b/config.example.toml index 3bc1c74..b58d6f5 100644 --- a/config.example.toml +++ b/config.example.toml @@ -2,10 +2,11 @@ host = "0.0.0.0" port = 3335 # Host and Port for the Receiver -output = "ui/print/discord" +output = "ui/print/discord/json" # ui = cool interactive ui # print = simple log # discord = discord webhooks +# json = one decoded-JSON object per rpc (JSONL) to stdout — pipe to a file / jq webhook = "" # your webhook url in case you set "discord" for output \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 9e32d8b..12eb472 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "aiohttp" @@ -6,6 +6,7 @@ version = "3.8.3" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "aiohttp-3.8.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ba71c9b4dcbb16212f334126cc3d8beb6af377f6703d9dc2d9fb3874fd667ee9"}, {file = "aiohttp-3.8.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d24b8bb40d5c61ef2d9b6a8f4528c2f17f1c5d2d31fed62ec860f6006142e83e"}, @@ -106,7 +107,7 @@ multidict = ">=4.5,<7.0" yarl = ">=1.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns", "cchardet"] +speedups = ["Brotli", "aiodns", "cchardet ; python_version < \"3.10\""] [[package]] name = "aiosignal" @@ -114,6 +115,7 @@ version = "1.3.1" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, @@ -128,6 +130,7 @@ version = "4.0.2" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, @@ -139,6 +142,7 @@ version = "22.2.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, @@ -149,7 +153,7 @@ cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] dev = ["attrs[docs,tests]"] docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"] tests = ["attrs[tests-no-zope]", "zope.interface"] -tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] +tests-no-zope = ["cloudpickle ; platform_python_implementation == \"CPython\"", "cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990) ; platform_python_implementation == \"CPython\"", "mypy (>=0.971,<0.990) ; platform_python_implementation == \"CPython\"", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version < \"3.11\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version < \"3.11\"", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] [[package]] name = "bbpb" @@ -157,13 +161,14 @@ version = "1.0.0" description = "Library for working with protobuf messages without a protobuf type definition." optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] files = [ {file = "bbpb-1.0.0-py2.py3-none-any.whl", hash = "sha256:28d45356f03faa691689b663018b2f488621abc002d17e90eb99963994837c4c"}, {file = "bbpb-1.0.0.tar.gz", hash = "sha256:d1ef48f2ebadb92b80094d6b613bdc01b931229595180823936756deb35e7b43"}, ] [package.dependencies] -protobuf = {version = ">=3,<4", markers = "python_version >= \"3\" and python_version < \"4\""} +protobuf = {version = ">=3,<4", markers = "python_version == \"3\""} six = ">=1.16,<2.0" [[package]] @@ -172,6 +177,7 @@ version = "2022.12.7" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, @@ -183,6 +189,7 @@ version = "2.1.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.6.0" +groups = ["main"] files = [ {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, @@ -197,6 +204,7 @@ version = "8.1.3" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, @@ -211,6 +219,8 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +markers = "platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -222,6 +232,7 @@ version = "0.9.1" description = "Python parser for the CommonMark Markdown spec" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, @@ -236,6 +247,7 @@ version = "2.1.0" description = "A Python wrapper for the Discord API" optional = false python-versions = ">=3.8.0" +groups = ["main"] files = [ {file = "discord.py-2.1.0-py3-none-any.whl", hash = "sha256:a2cfa9f09e3013aaaa43600cc8dfaf67c532dd34afcb71e550f5a0dc9133a5e0"}, {file = "discord.py-2.1.0.tar.gz", hash = "sha256:027ccdd22b5bb66a9e19cbd8daa1bc74b49271a16a074d57e52f288fcfa208e8"}, @@ -246,7 +258,7 @@ aiohttp = ">=3.7.4,<4" [package.extras] docs = ["sphinx (==4.4.0)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport", "typing-extensions (>=4.3,<5)"] -speed = ["Brotli", "aiodns (>=1.1)", "cchardet (==2.1.7)", "orjson (>=3.5.4)"] +speed = ["Brotli", "aiodns (>=1.1)", "cchardet (==2.1.7) ; python_version < \"3.10\"", "orjson (>=3.5.4)"] test = ["coverage[toml]", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "typing-extensions (>=4.3,<5)"] voice = ["PyNaCl (>=1.3.0,<1.6)"] @@ -256,6 +268,7 @@ version = "1.3.3" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff8bf625fe85e119553b5383ba0fb6aa3d0ec2ae980295aaefa552374926b3f4"}, {file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dfbac4c2dfcc082fcf8d942d1e49b6aa0766c19d3358bd86e2000bf0fa4a9cf0"}, @@ -339,6 +352,7 @@ version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" +groups = ["main"] files = [ {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, @@ -350,6 +364,7 @@ version = "4.13.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "importlib_metadata-4.13.0-py3-none-any.whl", hash = "sha256:8a8a81bcf996e74fee46f0d16bd3eaa382a7eb20fd82445c3ad11f4090334116"}, {file = "importlib_metadata-4.13.0.tar.gz", hash = "sha256:dd0173e8f150d6815e098fd354f6414b0f079af4644ddfe90c71e2fc6174346d"}, @@ -361,7 +376,7 @@ zipp = ">=0.5" [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] perf = ["ipython"] -testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] +testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-perf (>=0.9.2)"] [[package]] name = "Levenshtein" @@ -369,6 +384,7 @@ version = "0.20.9" description = "Python extension for computing string edit distances and similarities." optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "Levenshtein-0.20.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:105c239ec786750cd5136991c58196b440cc39b6acf3ec8227f6562c9a94e4b9"}, {file = "Levenshtein-0.20.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f7728bea7fe6dc55ceecde0dcda4287e74fe3b6733ad42530f46aaa8d2f81d0"}, @@ -483,6 +499,7 @@ version = "1.0.4" description = "MessagePack serializer" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4ab251d229d10498e9a2f3b1e68ef64cb393394ec477e3370c457f9430ce9250"}, {file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:112b0f93202d7c0fef0b7810d465fde23c746a2d482e1e2de2aafd2ce1492c88"}, @@ -544,6 +561,7 @@ version = "6.0.4" description = "multidict implementation" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8"}, {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171"}, @@ -627,6 +645,7 @@ version = "0.991" description = "Optional static typing for Python" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "mypy-0.991-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7d17e0a9707d0772f4a7b878f04b4fd11f6f5bcb9b3813975a9b13c9332153ab"}, {file = "mypy-0.991-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0714258640194d75677e86c786e80ccf294972cc76885d3ebbb560f11db0003d"}, @@ -662,7 +681,6 @@ files = [ [package.dependencies] mypy-extensions = ">=0.4.3" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = ">=3.10" [package.extras] @@ -677,6 +695,7 @@ version = "0.4.3" description = "Experimental type system extensions for programs checked with the mypy typechecker." optional = false python-versions = "*" +groups = ["main"] files = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, @@ -688,6 +707,7 @@ version = "2.0.0" description = "A tiny, secure, URL-friendly, unique string ID generator for Python" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "nanoid-2.0.0-py3-none-any.whl", hash = "sha256:90aefa650e328cffb0893bbd4c236cfd44c48bc1f2d0b525ecc53c3187b653bb"}, {file = "nanoid-2.0.0.tar.gz", hash = "sha256:5a80cad5e9c6e9ae3a41fa2fb34ae189f7cb420b2a5d8f82bd9d23466e4efa68"}, @@ -699,6 +719,7 @@ version = "3.20.3" description = "Protocol Buffers" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "protobuf-3.20.3-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:f4bd856d702e5b0d96a00ec6b307b0f51c1982c2bf9c0052cf9019e9a544ba99"}, {file = "protobuf-3.20.3-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9aae4406ea63d825636cc11ffb34ad3379335803216ee3a856787bcf5ccc751e"}, @@ -730,6 +751,7 @@ version = "1.10.2" description = "Data validation and settings management using python type hints" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "pydantic-1.10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bb6ad4489af1bac6955d38ebcb95079a836af31e4c4f74aba1ca05bb9f6027bd"}, {file = "pydantic-1.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1f5a63a6dfe19d719b1b6e6106561869d2efaca6167f84f5ab9347887d78b98"}, @@ -782,13 +804,14 @@ version = "2.13.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"}, {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, ] [package.extras] -plugins = ["importlib-metadata"] +plugins = ["importlib-metadata ; python_version < \"3.8\""] [[package]] name = "pyperclip" @@ -796,6 +819,7 @@ version = "1.8.2" description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "pyperclip-1.8.2.tar.gz", hash = "sha256:105254a8b04934f0bc84e9c24eb360a591aaf6535c9def5f29d92af107a9bf57"}, ] @@ -806,6 +830,7 @@ version = "0.20.9" description = "Python extension for computing string edit distances and similarities." optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "python-Levenshtein-0.20.9.tar.gz", hash = "sha256:4c507b1e26de29374153982fa477cea741edf095d892773343b4961beacac834"}, {file = "python_Levenshtein-0.20.9-py3-none-any.whl", hash = "sha256:2a6f8c97ba554d7399e0b450e1fce5d90d6354b1c1762e419671de27f25736c5"}, @@ -820,6 +845,7 @@ version = "2.13.7" description = "rapid fuzzy string matching" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "rapidfuzz-2.13.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b75dd0928ce8e216f88660ab3d5c5ffe990f4dd682fd1709dba29d5dafdde6de"}, {file = "rapidfuzz-2.13.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:24d3fea10680d085fd0a4d76e581bfb2b1074e66e78fd5964d4559e1fcd2a2d4"}, @@ -921,6 +947,7 @@ version = "2.28.1" description = "Python HTTP for Humans." optional = false python-versions = ">=3.7, <4" +groups = ["main"] files = [ {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, @@ -942,6 +969,7 @@ version = "13.0.1" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.7.0" +groups = ["main"] files = [ {file = "rich-13.0.1-py3-none-any.whl", hash = "sha256:41fe1d05f433b0f4724cda8345219213d2bfa472ef56b2f64f415b5b94d51b04"}, {file = "rich-13.0.1.tar.gz", hash = "sha256:25f83363f636995627a99f6e4abc52ed0970ebbd544960cc63cbb43aaac3d6f0"}, @@ -960,6 +988,7 @@ version = "1.16.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] files = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, @@ -971,6 +1000,7 @@ version = "0.9.1" description = "Modern Text User Interface framework" optional = false python-versions = ">=3.7,<4.0" +groups = ["main"] files = [ {file = "textual-0.9.1-py3-none-any.whl", hash = "sha256:97613f6855a10f82b1384346480c550324efed73f6d123b9064c277477eaa634"}, {file = "textual-0.9.1.tar.gz", hash = "sha256:117816bbea6ca4009317ca079caf9af7653d577c3f849b58da0f2580bed239d0"}, @@ -990,6 +1020,7 @@ version = "0.19.0" description = "Fuzzy string matching in python" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "thefuzz-0.19.0-py2.py3-none-any.whl", hash = "sha256:4fcdde8e40f5ca5e8106bc7665181f9598a9c8b18b0a4d38c41a095ba6788972"}, {file = "thefuzz-0.19.0.tar.gz", hash = "sha256:6f7126db2f2c8a54212b05e3a740e45f4291c497d75d20751728f635bb74aa3d"}, @@ -1001,23 +1032,13 @@ python-levenshtein = {version = ">=0.12", optional = true, markers = "extra == \ [package.extras] speedup = ["python-levenshtein (>=0.12)"] -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - [[package]] name = "typing-extensions" version = "4.4.0" description = "Backported and Experimental Type Hints for Python 3.7+" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, @@ -1029,14 +1050,15 @@ version = "1.26.13" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +groups = ["main"] files = [ {file = "urllib3-1.26.13-py2.py3-none-any.whl", hash = "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc"}, {file = "urllib3-1.26.13.tar.gz", hash = "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +brotli = ["brotli (>=1.0.9) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation != \"CPython\"", "brotlipy (>=0.6.0) ; os_name == \"nt\" and python_version < \"3\""] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] @@ -1045,6 +1067,7 @@ version = "1.8.2" description = "Yet another URL library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "yarl-1.8.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bb81f753c815f6b8e2ddd2eef3c855cf7da193b82396ac013c661aaa6cc6b0a5"}, {file = "yarl-1.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:47d49ac96156f0928f002e2424299b2c91d9db73e08c4cd6742923a086f1c863"}, @@ -1132,6 +1155,7 @@ version = "3.11.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "zipp-3.11.0-py3-none-any.whl", hash = "sha256:83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa"}, {file = "zipp-3.11.0.tar.gz", hash = "sha256:a7a22e05929290a67401440b39690ae6563279bced5f314609d9d03798f56766"}, @@ -1139,9 +1163,9 @@ files = [ [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] -testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8 ; python_version < \"3.12\"", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\""] [metadata] -lock-version = "2.0" -python-versions = "^3.10" -content-hash = "dd74fe7164d20eeac8a5cc1dcf314fe8570b3283979e37dcbb69fe3a20df26f9" \ No newline at end of file +lock-version = "2.1" +python-versions = "^3.11" +content-hash = "9a6a68683a81c63e61d28281487ea29c9cd7871756e881963f75ca4005d6df7f" diff --git a/tests/test_json_output.py b/tests/test_json_output.py new file mode 100644 index 0000000..b02260f --- /dev/null +++ b/tests/test_json_output.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import asyncio +import base64 +import io +import json +import unittest +from contextlib import redirect_stdout + +from trafficlight import protos +from trafficlight.output.json_ import JsonOutput +from trafficlight.proto_utils.proto import Proto, Request + + +class MessageToJsonTests(unittest.TestCase): + def test_decoded_request_reports_type_and_dict_data(self) -> None: + # method 106 = GET_MAP_OBJECTS; empty bytes decode to an all-defaults GetMapObjectsProto. + obj = Request(106, "").to_json_obj() + self.assertEqual(obj["type"], "GetMapObjectsProto") + self.assertTrue(obj["decoded"]) + self.assertIsInstance(obj["data"], dict) + # No base64 blob — the raw bytes never appear in the JSON object. + self.assertNotIn("payload", obj) + + def test_unknown_method_falls_back_to_blackbox(self) -> None: + # Unknown method id => no proto name; still decode the wire bytes generically. + # field 1 (varint) = 5 -> b"\x08\x05" + obj = Request(9_999_999, "CAU=").to_json_obj() + self.assertIsNone(obj["type"]) + self.assertFalse(obj["decoded"]) + self.assertIsInstance(obj["data"], dict) + self.assertEqual(obj["data"].get("1"), 5) + + +class ProtoToJsonTests(unittest.TestCase): + def test_proxy_rpc_nests_the_inner_decoded_method(self) -> None: + # method 5012 wraps a real method inside ProxyRequestProto.action + .payload. + inner_req = protos.GetMapObjectsProto(cell_id=[7]).SerializeToString() + inner_resp = protos.GetMapObjectsOutProto().SerializeToString() + outer_req = protos.ProxyRequestProto(action=106, payload=inner_req).SerializeToString() + outer_resp = protos.ProxyResponseProto(payload=inner_resp).SerializeToString() + + proto = Proto( + rpc_id=1, + method_value=5012, + raw_request=base64.b64encode(outer_req).decode(), + raw_response=base64.b64encode(outer_resp).decode(), + ) + obj = proto.to_json_obj() + + self.assertEqual(obj["method"], 5012) + self.assertEqual(obj["request"]["type"], "ProxyRequestProto") + self.assertIn("proxy", obj) + proxy = obj["proxy"] + self.assertEqual(proxy["method"], 106) + self.assertEqual(proxy["request"]["type"], "GetMapObjectsProto") + self.assertEqual(proxy["request"]["data"], {"cell_id": ["7"]}) + + def test_non_proxy_rpc_has_no_proxy_key(self) -> None: + obj = Proto(rpc_id=1, method_value=106, raw_request="", raw_response="").to_json_obj() + self.assertNotIn("proxy", obj) + + +class JsonOutputEnvelopeTests(unittest.TestCase): + def test_add_record_emits_one_jsonl_object_with_rotom_fields(self) -> None: + proto = Proto(rpc_id=42, method_value=106, raw_request="", raw_response="") + out = JsonOutput() + + buf = io.StringIO() + with redirect_stdout(buf): + asyncio.run(out.add_record(rpc_id=42, rpc_status=1, protos=[proto], rpc_handle=7)) + + lines = [ln for ln in buf.getvalue().splitlines() if ln.strip()] + self.assertEqual(len(lines), 1, "exactly one JSONL record per rpc envelope") + rec = json.loads(lines[0]) + + self.assertEqual(rec["rpc_id"], 42) + self.assertEqual(rec["rpc_status"], 1) + self.assertEqual(rec["rpc_handle"], 7) + self.assertIn("timestamp", rec) + + self.assertEqual(len(rec["protos"]), 1) + p = rec["protos"][0] + self.assertEqual(p["method"], 106) + self.assertEqual(p["method_name"], "METHOD_GET_MAP_OBJECTS") + self.assertEqual(p["request"]["type"], "GetMapObjectsProto") + self.assertIn("response", p) + + +if __name__ == "__main__": + unittest.main() diff --git a/trafficlight/config.py b/trafficlight/config.py index 1eeeb1b..dd7982e 100644 --- a/trafficlight/config.py +++ b/trafficlight/config.py @@ -11,6 +11,7 @@ class Output(Enum): UI = "ui" PRINT = "print" DISCORD = "discord" + JSON = "json" class Config(BaseModel): diff --git a/trafficlight/output/__init__.py b/trafficlight/output/__init__.py index 27cd20e..183545e 100644 --- a/trafficlight/output/__init__.py +++ b/trafficlight/output/__init__.py @@ -1,6 +1,7 @@ from trafficlight.config import Output as _OutputType from .base import BaseOutput from .discord import DiscordOutput +from .json_ import JsonOutput from .print_ import PrintOutput from .ui import UiOutput @@ -10,5 +11,7 @@ def get_output(output_type: _OutputType) -> BaseOutput: return PrintOutput() elif output_type == _OutputType.DISCORD: return DiscordOutput() + elif output_type == _OutputType.JSON: + return JsonOutput() return UiOutput() diff --git a/trafficlight/output/json_.py b/trafficlight/output/json_.py new file mode 100644 index 0000000..ddc1ade --- /dev/null +++ b/trafficlight/output/json_.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import json +from datetime import datetime +from typing import TYPE_CHECKING + +from .base import BaseOutput + +if TYPE_CHECKING: + from trafficlight.proto_utils.proto import Proto + + +class JsonOutput(BaseOutput): + """Emit one JSON object per RPC envelope (JSONL) to stdout. + + Same information the `print` output shows, but as machine-readable JSON instead of a rendered + table — pipe it to a file (`trafficlight run > traffic.jsonl`) and query it with jq/scripts. + + The record mirrors the Rotom-style envelope the receiver ingests (rpc id / status / handle + + a `protos` list keyed by `method`), except the request/response are the DECODED protos as JSON + objects — never the raw base64 payloads. + """ + + async def start(self) -> None: + pass + + async def add_record(self, rpc_id: int, rpc_status: int, protos: list[Proto], rpc_handle: int | None = None) -> None: + record = { + "timestamp": datetime.now().isoformat(), + "rpc_id": rpc_id, + "rpc_status": rpc_status, + "rpc_handle": rpc_handle, + "protos": [proto.to_json_obj() for proto in protos], + } + print(json.dumps(record, ensure_ascii=False)) diff --git a/trafficlight/proto_utils/proto.py b/trafficlight/proto_utils/proto.py index c90773b..a1145df 100644 --- a/trafficlight/proto_utils/proto.py +++ b/trafficlight/proto_utils/proto.py @@ -8,6 +8,7 @@ from blackboxprotobuf.lib.api import decode_message, _json_safe_transform from google.protobuf import text_format, descriptor from google.protobuf.internal.enum_type_wrapper import EnumTypeWrapper +from google.protobuf.json_format import MessageToDict from google.protobuf.message import Message as ProtobufMessage from trafficlight import protos @@ -122,6 +123,24 @@ def to_string(self, one_line: bool = True) -> str: else: return text_format.MessageToString(self.payload, as_one_line=one_line) + def to_json_obj(self) -> dict: + """A JSON-safe view of this message: the proto decoded to a dict (never a base64 blob). + + - decoded proto -> {"type": , "decoded": True, "data": {..}} + - undecodable -> {"type": , "decoded": False, "data": } + + `data` holds the actual field values, keyed by proto field name (blackbox falls back to + wire field numbers). Genuine `bytes` sub-fields still render base64 — JSON has no bytes type — + but the message envelope itself is never dumped as one opaque base64 string. + """ + if self.payload is not None: + return { + "type": self.name, + "decoded": True, + "data": MessageToDict(self.payload, preserving_proto_field_name=True), + } + return {"type": self.name, "decoded": False, "data": self.blackbox} + class Request(Message): messages = MESSAGES @@ -155,6 +174,18 @@ def messages(self) -> Iterable[Message]: yield self.request yield self.response + def to_json_obj(self) -> dict: + """This RPC method's request+response as decoded JSON, plus a nested proxy when present.""" + obj = { + "method": self.method_value, + "method_name": self.method_name, + "request": self.request.to_json_obj(), + "response": self.response.to_json_obj(), + } + if self.proxy is not None: + obj["proxy"] = self.proxy.to_json_obj() + return obj + @staticmethod def get_message_name(messages: dict, value: int) -> str | None: try: