diff --git a/.bumpversion.cfg b/.bumpversion.cfg index c6a55f4..8c0bfc2 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.9.1 +current_version = 1.9.2 commit = False tag = False diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 575b607..7848b7e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 79502e6..ae65cf3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,6 +48,8 @@ repos: hooks: - id: bandit args: ["--skip=B101"] + additional_dependencies: + - pbr - repo: https://github.com/Lucas-C/pre-commit-hooks-markup rev: v1.0.1 hooks: diff --git a/poetry.lock b/poetry.lock index 48a7721..9bde5aa 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.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 = "aiodns" @@ -6,6 +6,7 @@ version = "3.2.0" description = "Simple DNS resolver for asyncio" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "aiodns-3.2.0-py3-none-any.whl", hash = "sha256:e443c0c27b07da3174a109fd9e736d69058d808f144d3c9d56dbd1776964c5f5"}, {file = "aiodns-3.2.0.tar.gz", hash = "sha256:62869b23409349c21b072883ec8998316b234c9a9e36675756e8e317e8768f72"}, @@ -20,6 +21,7 @@ version = "2.4.0" description = "Happy Eyeballs for asyncio" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "aiohappyeyeballs-2.4.0-py3-none-any.whl", hash = "sha256:7ce92076e249169a13c2f49320d1967425eaf1f407522d707d59cac7628d62bd"}, {file = "aiohappyeyeballs-2.4.0.tar.gz", hash = "sha256:55a1714f084e63d49639800f95716da97a1f173d46a16dfcfda0016abb93b6b2"}, @@ -31,6 +33,7 @@ version = "3.10.5" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:18a01eba2574fb9edd5f6e5fb25f66e6ce061da5dab5db75e13fe1558142e0a3"}, {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:94fac7c6e77ccb1ca91e9eb4cb0ac0270b9fb9b289738654120ba8cebb1189c6"}, @@ -135,7 +138,7 @@ multidict = ">=4.5,<7.0" yarl = ">=1.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.2.0) ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "brotlicffi ; platform_python_implementation != \"CPython\""] [[package]] name = "aiosignal" @@ -143,6 +146,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"}, @@ -157,6 +161,7 @@ version = "4.4.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, @@ -170,7 +175,7 @@ typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] trio = ["trio (>=0.23)"] [[package]] @@ -179,6 +184,7 @@ version = "2.15.8" description = "An abstract syntax tree for Python with inference support." optional = false python-versions = ">=3.7.2" +groups = ["dev"] files = [ {file = "astroid-2.15.8-py3-none-any.whl", hash = "sha256:1aa149fc5c6589e3d0ece885b4491acd80af4f087baafa3fb5203b113e68cd3c"}, {file = "astroid-2.15.8.tar.gz", hash = "sha256:6c107453dffee9055899705de3c9ead36e74119cee151e5a9aaf7f0b0e020a6a"}, @@ -198,6 +204,8 @@ version = "4.0.3" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.7" +groups = ["main"] +markers = "python_version < \"3.11\"" files = [ {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, @@ -209,18 +217,19 @@ version = "24.2.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, ] [package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] [[package]] name = "bandit" @@ -228,6 +237,7 @@ version = "1.7.9" description = "Security oriented static analyser for python code." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "bandit-1.7.9-py3-none-any.whl", hash = "sha256:52077cb339000f337fb25f7e045995c4ad01511e716e5daac37014b9752de8ec"}, {file = "bandit-1.7.9.tar.gz", hash = "sha256:7c395a436743018f7be0a4cbb0a4ea9b902b6d87264ddecf8cfdc73b4f78ff61"}, @@ -243,7 +253,7 @@ stevedore = ">=1.20.0" baseline = ["GitPython (>=3.1.30)"] sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"] test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] -toml = ["tomli (>=1.1.0)"] +toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""] yaml = ["PyYAML"] [[package]] @@ -252,6 +262,7 @@ version = "23.12.1" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "black-23.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2"}, {file = "black-23.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba"}, @@ -288,7 +299,7 @@ typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +d = ["aiohttp (>=3.7.4) ; sys_platform != \"win32\" or implementation_name != \"pypy\"", "aiohttp (>=3.7.4,!=3.9.0) ; sys_platform == \"win32\" and implementation_name == \"pypy\""] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] @@ -298,6 +309,7 @@ version = "1.1.0" description = "Python bindings for the Brotli compression library" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "Brotli-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752"}, {file = "Brotli-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9"}, @@ -309,6 +321,10 @@ files = [ {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d"}, {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0"}, {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec"}, {file = "Brotli-1.1.0-cp310-cp310-win32.whl", hash = "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2"}, {file = "Brotli-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128"}, {file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc"}, @@ -321,8 +337,14 @@ files = [ {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9"}, {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265"}, {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b"}, {file = "Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50"}, {file = "Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1"}, + {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28"}, + {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f"}, {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409"}, {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2"}, {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451"}, @@ -333,8 +355,24 @@ files = [ {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180"}, {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248"}, {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839"}, {file = "Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0"}, {file = "Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951"}, + {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5"}, + {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7"}, + {file = "Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0"}, + {file = "Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b"}, {file = "Brotli-1.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1"}, {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d"}, {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b"}, @@ -344,6 +382,10 @@ files = [ {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2"}, {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354"}, {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52"}, {file = "Brotli-1.1.0-cp36-cp36m-win32.whl", hash = "sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460"}, {file = "Brotli-1.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579"}, {file = "Brotli-1.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c"}, @@ -355,6 +397,10 @@ files = [ {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74"}, {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b"}, {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c"}, {file = "Brotli-1.1.0-cp37-cp37m-win32.whl", hash = "sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95"}, {file = "Brotli-1.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68"}, {file = "Brotli-1.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3"}, @@ -367,6 +413,10 @@ files = [ {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a"}, {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088"}, {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a"}, {file = "Brotli-1.1.0-cp38-cp38-win32.whl", hash = "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b"}, {file = "Brotli-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0"}, {file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a"}, @@ -379,6 +429,10 @@ files = [ {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c"}, {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d"}, {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb"}, {file = "Brotli-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64"}, {file = "Brotli-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467"}, {file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"}, @@ -390,6 +444,7 @@ version = "1.0.1" description = "Version-bump your software with a single command!" optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "bump2version-1.0.1-py2.py3-none-any.whl", hash = "sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410"}, {file = "bump2version-1.0.1.tar.gz", hash = "sha256:762cb2bfad61f4ec8e2bdf452c7c267416f8c70dd9ecb1653fd0bbb01fa936e6"}, @@ -401,6 +456,8 @@ version = "2.1.7" description = "cChardet is high speed universal character encoding detector." optional = false python-versions = "*" +groups = ["main"] +markers = "python_full_version <= \"3.10.0\"" files = [ {file = "cchardet-2.1.7-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c6f70139aaf47ffb94d89db603af849b82efdf756f187cdd3e566e30976c519f"}, {file = "cchardet-2.1.7-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5a25f9577e9bebe1a085eec2d6fdd72b7a9dd680811bba652ea6090fb2ff472f"}, @@ -439,6 +496,7 @@ version = "2024.8.30" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, @@ -450,6 +508,7 @@ version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, @@ -519,6 +578,7 @@ files = [ {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, ] +markers = {dev = "platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = "*" @@ -529,6 +589,7 @@ version = "3.4.0" description = "Validate configuration and produce human readable error messages." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, @@ -540,6 +601,7 @@ version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" +groups = ["dev"] files = [ {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, @@ -639,6 +701,7 @@ version = "8.1.7" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, @@ -653,6 +716,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 = ["dev"] +markers = "sys_platform == \"win32\" or 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"}, @@ -664,6 +729,7 @@ version = "7.6.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, @@ -743,7 +809,7 @@ files = [ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" @@ -751,6 +817,7 @@ version = "43.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, @@ -800,6 +867,7 @@ version = "1.5.0" description = "Tool for detecting secrets in the codebase" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "detect_secrets-1.5.0-py3-none-any.whl", hash = "sha256:e24e7b9b5a35048c313e983f76c4bd09dad89f045ff059e354f9943bf45aa060"}, {file = "detect_secrets-1.5.0.tar.gz", hash = "sha256:6bb46dcc553c10df51475641bb30fd69d25645cc12339e46c824c1e0c388898a"}, @@ -819,6 +887,7 @@ version = "0.3.8" description = "serialize all of Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"}, {file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"}, @@ -834,6 +903,7 @@ version = "0.3.8" description = "Distribution utilities" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, @@ -845,6 +915,7 @@ version = "2.2.0" description = "Logging formatters for ECS (Elastic Common Schema) in Python" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "ecs_logging-2.2.0-py3-none-any.whl", hash = "sha256:f6e22d267770b06f797076f49b5fcc9d97108b22f452f5f9ed4b5367b1e61b5b"}, {file = "ecs_logging-2.2.0.tar.gz", hash = "sha256:1dc9e216f614129db0e6a2f9f926da4e4cf8edf8de16d1045a20aa8e950291d3"}, @@ -859,6 +930,8 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -873,6 +946,7 @@ version = "0.114.2" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "fastapi-0.114.2-py3-none-any.whl", hash = "sha256:44474a22913057b1acb973ab90f4b671ba5200482e7622816d79105dcece1ac5"}, {file = "fastapi-0.114.2.tar.gz", hash = "sha256:0adb148b62edb09e8c6eeefa3ea934e8f276dabc038c5a82989ea6346050c3da"}, @@ -893,6 +967,7 @@ version = "3.16.0" description = "A platform independent file lock." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "filelock-3.16.0-py3-none-any.whl", hash = "sha256:f6ed4c963184f4c84dd5557ce8fece759a3724b37b80c6c4f20a2f63a4dc6609"}, {file = "filelock-3.16.0.tar.gz", hash = "sha256:81de9eb8453c769b63369f87f11131a7ab04e367f8d97ad39dc230daa07e3bec"}, @@ -901,7 +976,7 @@ files = [ [package.extras] docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.1.1)", "pytest (>=8.3.2)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.3)"] -typing = ["typing-extensions (>=4.12.2)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] [[package]] name = "frozenlist" @@ -909,6 +984,7 @@ version = "1.4.1" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, @@ -995,6 +1071,7 @@ version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, @@ -1006,6 +1083,7 @@ version = "1.0.5" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, @@ -1027,6 +1105,7 @@ version = "0.27.2" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, @@ -1040,7 +1119,7 @@ idna = "*" sniffio = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -1052,6 +1131,7 @@ version = "2.6.0" description = "File identification library for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"}, {file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"}, @@ -1066,6 +1146,7 @@ version = "3.9" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["main", "dev"] files = [ {file = "idna-3.9-py3-none-any.whl", hash = "sha256:69297d5da0cc9281c77efffb4e730254dd45943f45bbfb461de5991713989b1e"}, {file = "idna-3.9.tar.gz", hash = "sha256:e5c5dafde284f26e9e0f28f6ea2d6400abd5ca099864a67f576f3981c6476124"}, @@ -1080,6 +1161,7 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -1091,6 +1173,7 @@ version = "5.13.2" description = "A Python utility / library to sort Python imports." optional = false python-versions = ">=3.8.0" +groups = ["dev"] files = [ {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, @@ -1105,6 +1188,7 @@ version = "1.10.0" description = "A fast and thorough lazy object proxy." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "lazy-object-proxy-1.10.0.tar.gz", hash = "sha256:78247b6d45f43a52ef35c25b5581459e85117225408a4128a3daf8bf9648ac69"}, {file = "lazy_object_proxy-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:855e068b0358ab916454464a884779c7ffa312b8925c6f7401e952dcf3b89977"}, @@ -1147,17 +1231,25 @@ files = [ [[package]] name = "libadvian" -version = "1.4.0" +version = "1.10.0" description = "Small helpers that do not warrant their own library" optional = false -python-versions = ">=3.6.2,<4.0" -files = [ - {file = "libadvian-1.4.0-py3-none-any.whl", hash = "sha256:f1c41a0c4777c4b3f36e9d94a9fb2c3c76c6fa4bc7922d7002ba9b06ee51888e"}, - {file = "libadvian-1.4.0.tar.gz", hash = "sha256:433579a941d5a833022000892da73f0e69b4cfed7d538f4224597d61bb19b8cd"}, -] +python-versions = ">=3.9,<4.0" +groups = ["main"] +files = [] +develop = false [package.extras] -http = ["frozendict (>=2.3,<3.0)", "requests (>=2.28,<3.0)"] +all = ["http"] +http = ["frozendict", "requests"] +logstash = ["http"] +vector = ["http"] + +[package.source] +type = "git" +url = "https://gitlab.com/advian-oss/python-libadvian.git" +reference = "1002a851ba6284551132dbef075c2fa0e1ba110d" +resolved_reference = "1002a851ba6284551132dbef075c2fa0e1ba110d" [[package]] name = "markdown-it-py" @@ -1165,6 +1257,7 @@ version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -1189,6 +1282,7 @@ version = "0.7.0" description = "McCabe checker, plugin for flake8" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, @@ -1200,6 +1294,7 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -1211,6 +1306,7 @@ version = "6.1.0" description = "multidict implementation" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60"}, {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1"}, @@ -1315,6 +1411,7 @@ version = "1.11.2" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, @@ -1362,6 +1459,7 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -1373,6 +1471,7 @@ version = "1.9.1" description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] files = [ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, @@ -1384,6 +1483,7 @@ version = "24.1" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, @@ -1395,6 +1495,7 @@ version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -1406,6 +1507,7 @@ version = "6.1.0" description = "Python Build Reasonableness" optional = false python-versions = ">=2.6" +groups = ["dev"] files = [ {file = "pbr-6.1.0-py2.py3-none-any.whl", hash = "sha256:a776ae228892d8013649c0aeccbb3d5f99ee15e005a4cbb7e61d55a067b28a2a"}, {file = "pbr-6.1.0.tar.gz", hash = "sha256:788183e382e3d1d7707db08978239965e8b9e4e5ed42669bf4758186734d5f24"}, @@ -1417,6 +1519,7 @@ version = "4.3.3" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "platformdirs-4.3.3-py3-none-any.whl", hash = "sha256:50a5450e2e84f44539718293cbb1da0a0885c9d14adf21b77bae4e66fc99d9b5"}, {file = "platformdirs-4.3.3.tar.gz", hash = "sha256:d4e0b7d8ec176b341fb03cb11ca12d0276faa8c485f9cd218f613840463fc2c0"}, @@ -1433,6 +1536,7 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -1448,6 +1552,7 @@ version = "3.5.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"}, {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"}, @@ -1466,6 +1571,7 @@ version = "4.4.0" description = "Python interface for c-ares" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pycares-4.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:24da119850841d16996713d9c3374ca28a21deee056d609fbbed29065d17e1f6"}, {file = "pycares-4.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8f64cb58729689d4d0e78f0bfb4c25ce2f851d0274c0273ac751795c04b8798a"}, @@ -1532,10 +1638,12 @@ version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] +markers = {dev = "platform_python_implementation != \"PyPy\""} [[package]] name = "pydantic" @@ -1543,6 +1651,7 @@ version = "1.10.18" description = "Data validation and settings management using python type hints" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "pydantic-1.10.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e405ffcc1254d76bb0e760db101ee8916b620893e6edfbfee563b3c6f7a67c02"}, {file = "pydantic-1.10.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e306e280ebebc65040034bff1a0a81fd86b2f4f05daac0131f29541cafd80b80"}, @@ -1602,6 +1711,7 @@ version = "2.18.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, @@ -1616,6 +1726,7 @@ version = "2.17.7" description = "python code static checker" optional = false python-versions = ">=3.7.2" +groups = ["dev"] files = [ {file = "pylint-2.17.7-py3-none-any.whl", hash = "sha256:27a8d4c7ddc8c2f8c18aa0050148f89ffc09838142193fdbe98f172781a3ff87"}, {file = "pylint-2.17.7.tar.gz", hash = "sha256:f4fcac7ae74cfe36bc8451e931d8438e4a476c20314b1101c458ad0f05191fad"}, @@ -1639,30 +1750,13 @@ typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\"" spelling = ["pyenchant (>=3.2,<4.0)"] testutils = ["gitpython (>3)"] -[[package]] -name = "pyopenssl" -version = "24.2.1" -description = "Python wrapper module around the OpenSSL library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pyOpenSSL-24.2.1-py3-none-any.whl", hash = "sha256:967d5719b12b243588573f39b0c677637145c7a1ffedcd495a487e58177fbb8d"}, - {file = "pyopenssl-24.2.1.tar.gz", hash = "sha256:4247f0dbe3748d560dcbb2ff3ea01af0f9a1a001ef5f7c4c647956ed8cbf0e95"}, -] - -[package.dependencies] -cryptography = ">=41.0.5,<44" - -[package.extras] -docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx-rtd-theme"] -test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] - [[package]] name = "pytest" version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, @@ -1685,6 +1779,7 @@ version = "0.23.8" description = "Pytest support for asyncio" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, @@ -1703,6 +1798,7 @@ version = "4.1.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, @@ -1721,6 +1817,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -1783,6 +1880,7 @@ version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, @@ -1804,6 +1902,7 @@ version = "13.8.1" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.7.0" +groups = ["dev"] files = [ {file = "rich-13.8.1-py3-none-any.whl", hash = "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06"}, {file = "rich-13.8.1.tar.gz", hash = "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a"}, @@ -1812,7 +1911,6 @@ files = [ [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] @@ -1823,6 +1921,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1834,6 +1933,7 @@ version = "0.38.5" description = "The little ASGI library that shines." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "starlette-0.38.5-py3-none-any.whl", hash = "sha256:632f420a9d13e3ee2a6f18f437b0a9f1faecb0bc42e1942aa2ea0e379a4c4206"}, {file = "starlette-0.38.5.tar.gz", hash = "sha256:04a92830a9b6eb1442c766199d62260c3d4dc9c4f9188360626b1e0273cb7077"}, @@ -1852,6 +1952,7 @@ version = "5.3.0" description = "Manage dynamic plugins for Python applications" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "stevedore-5.3.0-py3-none-any.whl", hash = "sha256:1efd34ca08f474dad08d9b19e934a22c68bb6fe416926479ba29e5013bcc8f78"}, {file = "stevedore-5.3.0.tar.gz", hash = "sha256:9a64265f4060312828151c204efbe9b7a9852a0d9228756344dbc7e4023e375a"}, @@ -1866,6 +1967,8 @@ version = "2.0.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, @@ -1877,6 +1980,7 @@ version = "0.13.2" description = "Style preserving TOML library" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, @@ -1888,6 +1992,7 @@ version = "1.16.0.20240331" description = "Typing stubs for cffi" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "types-cffi-1.16.0.20240331.tar.gz", hash = "sha256:b8b20d23a2b89cfed5f8c5bc53b0cb8677c3aac6d970dbc771e28b9c698f5dee"}, {file = "types_cffi-1.16.0.20240331-py3-none-any.whl", hash = "sha256:a363e5ea54a4eb6a4a105d800685fde596bc318089b025b27dee09849fe41ff0"}, @@ -1902,6 +2007,7 @@ version = "24.1.0.20240722" description = "Typing stubs for pyOpenSSL" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "types-pyOpenSSL-24.1.0.20240722.tar.gz", hash = "sha256:47913b4678a01d879f503a12044468221ed8576263c1540dcb0484ca21b08c39"}, {file = "types_pyOpenSSL-24.1.0.20240722-py3-none-any.whl", hash = "sha256:6a7a5d2ec042537934cfb4c9d4deb0e16c4c6250b09358df1f083682fe6fda54"}, @@ -1917,6 +2023,7 @@ version = "74.1.0.20240907" description = "Typing stubs for setuptools" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "types-setuptools-74.1.0.20240907.tar.gz", hash = "sha256:0abdb082552ca966c1e5fc244e4853adc62971f6cd724fb1d8a3713b580e5a65"}, {file = "types_setuptools-74.1.0.20240907-py3-none-any.whl", hash = "sha256:15b38c8e63ca34f42f6063ff4b1dd662ea20086166d5ad6a102e670a52574120"}, @@ -1928,6 +2035,7 @@ version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, @@ -1939,13 +2047,14 @@ version = "2.2.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -1956,6 +2065,7 @@ version = "20.26.4" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "virtualenv-20.26.4-py3-none-any.whl", hash = "sha256:48f2695d9809277003f30776d155615ffc11328e6a0a8c1f0ec80188d7874a55"}, {file = "virtualenv-20.26.4.tar.gz", hash = "sha256:c17f4e0f3e6036e9f26700446f85c76ab11df65ff6d8a9cbfad9f71aabfcf23c"}, @@ -1968,7 +2078,7 @@ platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] [[package]] name = "wrapt" @@ -1976,6 +2086,7 @@ version = "1.16.0" description = "Module for decorators, wrappers and monkey patching." optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, @@ -2055,6 +2166,7 @@ version = "1.11.1" description = "Yet another URL library" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "yarl-1.11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:400cd42185f92de559d29eeb529e71d80dfbd2f45c36844914a4a34297ca6f00"}, {file = "yarl-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8258c86f47e080a258993eed877d579c71da7bda26af86ce6c2d2d072c11320d"}, @@ -2155,6 +2267,6 @@ idna = ">=2.0" multidict = ">=4.0" [metadata] -lock-version = "2.0" -python-versions = "^3.8" -content-hash = "26010099e5ac40a24d1152c5a2c2960aa1b2f89432ad59bb12080677426866a2" +lock-version = "2.1" +python-versions = "^3.9" +content-hash = "e2ef1b613e3dd2236fae68a6a09c838a14e6f1121bf17a10d24fd1a380e277e0" diff --git a/pyproject.toml b/pyproject.toml index 3e75d84..1f6d175 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "libpvarki" -version = "1.9.1" +version = "1.9.2" description = "Common helpers like standard logging init" authors = ["Eero af Heurlin "] homepage = "https://github.com/pvarki/python-libpvarki/" @@ -8,7 +8,6 @@ repository = "https://github.com/pvarki/python-libpvarki/" license = "MIT" readme = "README.rst" - [tool.black] line-length = 120 target-version = ['py38'] @@ -56,19 +55,17 @@ omit = ["tests/*"] branch = true [tool.poetry.dependencies] -python = "^3.8" +python = "^3.9" ecs-logging = "^2.0" fastapi = ">0.89,<1.0" # caret behaviour on 0.x is to lock to 0.x.* # FIXME: Migrate to v2, see https://docs.pydantic.dev/2.3/migration/#basesettings-has-moved-to-pydantic-settings pydantic= ">=1.10,<2.0" cryptography = ">=41.0" -libadvian = "^1.4" aiohttp = ">=3.10.2,<4.0" aiodns = "^3.0" brotli = "^1.0" cchardet = { version="^2.1", python="<=3.10"} -# FIXME: Once everything using this is migrared to cryptography drop the dep -pyopenssl = ">=23.2" +libadvian = { git = "https://gitlab.com/advian-oss/python-libadvian.git", rev = "1002a851ba6284551132dbef075c2fa0e1ba110d" } # pragma: allowlist secret [tool.poetry.group.dev.dependencies] diff --git a/src/libpvarki/__init__.py b/src/libpvarki/__init__.py index a4c8870..3a7f294 100644 --- a/src/libpvarki/__init__.py +++ b/src/libpvarki/__init__.py @@ -1,2 +1,2 @@ """ Common helpers like standard logging init """ -__version__ = "1.9.1" # NOTE Use `bump2version --config-file patch` to bump versions correctly +__version__ = "1.9.2" # NOTE Use `bump2version --config-file patch` to bump versions correctly diff --git a/src/libpvarki/auditlogging/README.md b/src/libpvarki/auditlogging/README.md new file mode 100644 index 0000000..eddf198 --- /dev/null +++ b/src/libpvarki/auditlogging/README.md @@ -0,0 +1,228 @@ +# libpvarki.auditlogging + +`libpvarki` module for providing structured audit logging compliant with organizational requirements. + +## Structure + +``` +auditlogging/ +├── README.md +├── src/ +│ └── libpvarki/ +│ └── auditlogging/ +│ ├── __init__.py # Public API, AUDIT level setup +│ ├── context.py # ContextVars for async-safe request context +│ ├── middleware.py # FastAPI AuditMiddleware +│ ├── helpers.py # audit_log() and convenience functions +│ ├── propagation.py # Service-to-service header propagation +│ └── py.typed # PEP 561 marker +└── tests/ + └── test_auditlogging.py +``` + +## Installation + +Copy the `auditlogging/` directory into `libpvarki/src/libpvarki/`: + +```bash +cp -r src/libpvarki/auditlogging /path/to/python-libpvarki/src/libpvarki/ +cp tests/test_auditlogging.py /path/to/python-libpvarki/tests/ +``` + +### Prerequisites + +Requires libadvian with MR #15 for native AUDIT level. Until merged: + +```toml +# pyproject.toml +[tool.poetry.dependencies] +libadvian = { git = "https://gitlab.com/advian-oss/python-libadvian.git", branch = "log_levels" } +``` + +The module includes a fallback that adds AUDIT level if libadvian doesn't have it yet. + +## Integration with Existing Stack + +``` +libadvian.logging ← MR #15 adds AUDIT level + ↓ +libpvarki.logging ← ECS formatting via ecs-logging + ↓ +libpvarki.auditlogging ← THIS MODULE + ↓ +rmapi / takrmapi / ocsprest / products +``` + +## Quick Start + +### 1. Initialize in FastAPI app + +```python +from fastapi import FastAPI +from libpvarki.auditlogging import init_audit, AuditMiddleware +import logging + +app = FastAPI() +app.add_middleware(AuditMiddleware) + +@app.on_event("startup") +async def startup(): + init_audit(logging.INFO) +``` + +### 2. Log audit events + +```python +import logging +from libpvarki.auditlogging import audit_log + +LOGGER = logging.getLogger(__name__) + +LOGGER.audit( + "Certificate issued for user", + extra=audit_log( + category="iam", + action="cert_issue", + outcome="success", + target_user="NORPPA11", + target_resource="DEADBEEF", # cert serial + ) +) +``` + +### 3. Propagate context to downstream services + +```python +from libpvarki.mtlshelp.session import get_session +from libpvarki.auditlogging import get_propagation_headers + +session = await get_session(client_cert, client_key, ca_cert) +headers = get_propagation_headers() # Includes X-Initiator-* headers +await session.post(url, json=data, headers=headers) +``` + +## Request Flow + +``` +User (NORPPA11) ─mTLS─► nginx ───► rmapi ───► takrmapi + │ │ │ + │ │ └── Sees X-Initiator-User: NORPPA11 + │ └── Extracts from X-ClientCert-DN + └── Sets X-ClientCert-DN: CN=NORPPA11 +``` + +## Header Conventions + +### nginx → service (direct mTLS) + +``` +X-Request-ID: +X-Real-IP: +X-ClientCert-DN: CN=,O=PVARKI,C=FI +X-ClientCert-Serial: +``` + +### service → service (propagation) + +``` +X-Request-ID: +X-Initiator-User: +X-Initiator-IP: +X-Initiator-Role: +X-Initiator-Cert-Serial: +``` + +## nginx Configuration + +```nginx +# In your nginx server block +proxy_set_header X-Request-ID $request_id; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-ClientCert-DN $ssl_client_s_dn; +proxy_set_header X-ClientCert-Serial $ssl_client_serial; +``` + +## Event Categories + +| Category | Use For | +|----------|---------| +| `authentication` | Login, logout, OTP exchange, JWT validation | +| `authorization` | Permission checks, access denied | +| `iam` | Enrollment, cert issuance, revocation | +| `configuration` | Settings changes, admin actions | +| `session` | JWT creation, refresh, expiry | +| `intrusion_detection` | Failed attempts, anomalies | + +## Convenience Functions + +```python +from libpvarki.auditlogging import ( + audit_authentication, # category="authentication" + audit_iam, # category="iam" + audit_authorization, # category="authorization" + audit_configuration, # category="configuration" + audit_session, # category="session" + audit_anomaly, # category="intrusion_detection", outcome="failure" +) + +# Examples +LOGGER.audit("Login successful", extra=audit_authentication("login", outcome="success")) +LOGGER.audit("Cert issued", extra=audit_iam("cert_issue", target_user="NORPPA11")) +LOGGER.audit("Brute force detected", extra=audit_anomaly("brute_force", error_message="5 failed attempts")) +``` + +## ECS Output Example + +With `LOG_CONSOLE_FORMATTER=ecs` (default), output is ECS-compliant JSON: + +```json +{ + "@timestamp": "2025-12-21T00:00:00.000Z", + "ecs.version": "1.6.0", + "log.level": "AUDIT", + "log.logger": "rasenmaeher_api.routes.token", + "message": "OTP exchange successful for NORPPA11", + "event.category": "authentication", + "event.action": "otp_exchange", + "event.outcome": "success", + "source.ip": "203.0.113.50", + "source.user.name": "NORPPA11", + "tls.client.x509.serial_number": "DEADBEEF", + "user.target.name": "NORPPA11", + "trace.id": "abc-123-def-456", + "service.name": "rmapi", + "service.version": "1.6.4" +} +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `LOG_CONSOLE_FORMATTER` | `ecs` | `ecs` for JSON, `local` for human-readable | +| `SERVICE_NAME` | hostname | Service name in logs | +| `RELEASE_TAG` | `unknown` | Service version in logs | + +## Testing + +```bash +cd python-libpvarki +pytest tests/test_auditlogging.py -v +``` + +## Migration from init_logging + +Replace `init_logging` with `init_audit` to enable AUDIT level: + +```python +# Before +from libpvarki.logging import init_logging +init_logging(logging.INFO) + +# After +from libpvarki.auditlogging import init_audit +init_audit(logging.INFO) +``` + +Or continue using `init_logging` - the AUDIT level is registered on module import. diff --git a/src/libpvarki/auditlogging/__init__.py b/src/libpvarki/auditlogging/__init__.py new file mode 100644 index 0000000..5063649 --- /dev/null +++ b/src/libpvarki/auditlogging/__init__.py @@ -0,0 +1,205 @@ +""" +PVARKI Audit Logging Module. + +Add-on to libpvarki.logging that provides structured audit logging with: + +- **AUDIT log level** - Above CRITICAL, always visible +- **Request context propagation** - via ContextVars (async-safe) +- **Service-to-service propagation** - via HTTP headers +- **ECS-compliant fields** - works with existing ecs-logging formatter + +This module builds on: + +- libadvian.logging (provides AUDIT level via add_trace_and_audit()) +- libpvarki.logging (ECS formatting via ecs-logging) + +Quick Start +----------- +1. Initialize logging in your FastAPI app:: + + from fastapi import FastAPI + from libpvarki.auditlogging import init_audit, AuditMiddleware + import logging + + app = FastAPI() + app.add_middleware(AuditMiddleware) + + @app.on_event("startup") + async def startup(): + init_audit(logging.INFO) + +2. Log audit events in your code:: + + import logging + from libpvarki.auditlogging import audit_log, AUDIT + + LOGGER = logging.getLogger(__name__) + + LOGGER.log( + AUDIT, + "Certificate issued for user", + extra=audit_log( + category="iam", + action="cert_issue", + outcome="success", + target_user="NORPPA11", + target_resource="DEADBEEF", + ) + ) + +3. Propagate context to downstream services:: + + from libpvarki.auditlogging import get_propagation_headers + from libpvarki.mtlshelp.session import get_session + + session = await get_session(...) + headers = get_propagation_headers() + await session.post(url, json=data, headers=headers) + + +Environment Variables +--------------------- +LOG_CONSOLE_FORMATTER : str + "ecs" (default) for JSON, "local" for human-readable. + (Inherited from libpvarki.logging) +SERVICE_NAME : str + Service identifier for logs (defaults to HOSTNAME). +RELEASE_TAG : str + Service version for logs. + +Header Conventions +------------------ +nginx -> service:: + + X-Request-ID: Trace correlation ID + X-Real-IP: Client IP + X-ClientCert-DN: mTLS certificate DN + X-ClientCert-Serial: mTLS certificate serial + +service -> service:: + + X-Request-ID: Trace correlation ID + X-Initiator-User: Original user/callsign + X-Initiator-IP: Original client IP + X-Initiator-Role: User role + X-Initiator-Cert-Serial: Original cert serial + X-Initiator-Session: Session ID +""" + +import logging + +# Import existing libpvarki logging (which builds on libadvian) +from libpvarki.logging import init_logging + +# Import AUDIT level from libadvian +# libadvian.logging.add_trace_and_audit() registers AUDIT = CRITICAL + 5 = 55 +AUDIT: int = logging.CRITICAL + 5 # 55 + +try: + from libadvian.logging import add_trace_and_audit + + # Register TRACE and AUDIT levels (side-effect: adds names into stdlib logging) + add_trace_and_audit() + +except ImportError: + # Fallback: if libadvian doesn't have add_trace_and_audit yet + logging.addLevelName(AUDIT, "AUDIT") + setattr(logging, "AUDIT", AUDIT) +else: + # In case libadvian registered it, still ensure stdlib has the attribute at runtime. + # (mypy won't care; this is runtime ergonomics only) + if not hasattr(logging, "AUDIT"): + setattr(logging, "AUDIT", AUDIT) + + +# Context management +from .context import ( + AuditContext, + get_audit_context, + set_audit_context, + clear_audit_context, +) + +# FastAPI middleware +from .middleware import ( + AuditMiddleware, + update_audit_user, +) + +# Logging helpers +from .helpers import ( + audit_log, + audit_extra, + audit_authentication, + audit_iam, + audit_authorization, + audit_configuration, + audit_session, + audit_anomaly, + code_fingerprint, +) + +# Service-to-service propagation +from .propagation import ( + get_propagation_headers, + inject_audit_context, + AuditContextClientMixin, + create_audit_trace_config, +) + + +def init_audit(level: int = logging.INFO) -> None: + """ + Initialize logging with AUDIT level support. + + Call this instead of ``init_logging()`` in services that need audit logging. + The AUDIT level (55, above CRITICAL) is always visible regardless of the + level parameter - it cannot be accidentally silenced. + + Args: + level: Minimum log level for non-audit messages. Default INFO (20). + Use logging.DEBUG (10) for verbose output. + AUDIT events (level 55) are always logged regardless of this setting. + + Example:: + + from libpvarki.auditlogging import init_audit + import logging + + # In your app startup: + init_audit(logging.INFO) + + # Or for debugging: + init_audit(logging.DEBUG) + """ + init_logging(level) + + +__all__ = [ + # Initialization + "init_audit", + "AUDIT", + # Context management + "AuditContext", + "get_audit_context", + "set_audit_context", + "clear_audit_context", + # Middleware + "AuditMiddleware", + "update_audit_user", + # Logging helpers + "audit_log", + "audit_extra", + "audit_authentication", + "audit_iam", + "audit_authorization", + "audit_configuration", + "audit_session", + "audit_anomaly", + "code_fingerprint", + # Propagation + "get_propagation_headers", + "inject_audit_context", + "AuditContextClientMixin", + "create_audit_trace_config", +] diff --git a/src/libpvarki/auditlogging/context.py b/src/libpvarki/auditlogging/context.py new file mode 100644 index 0000000..b001d3b --- /dev/null +++ b/src/libpvarki/auditlogging/context.py @@ -0,0 +1,161 @@ +""" +Request context management using ContextVars. + +ContextVars provide task-local storage that works correctly with asyncio. +Each concurrent request gets its own isolated context automatically. + +Usage: + The AuditMiddleware sets context at request start. + The audit_log() helper reads it when logging. + Context is cleared at request end to prevent leakage. +""" + +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import Optional, Dict, Any +import uuid + + +@dataclass +class AuditContext: # pylint: disable=too-many-instance-attributes + """ + Container for request-scoped audit context. + + Holds initiator information extracted from incoming requests, + either from nginx headers (direct mTLS) or propagation headers + (service-to-service calls). + + Attributes: + trace_id: Correlation ID for the entire request chain. + initiator_ip: Source IP address of the original requester. + initiator_user: Username/callsign of the initiator. + initiator_role: Role of the initiator (admin, user, etc.). + initiator_cert_serial: mTLS certificate serial number. + initiator_cert_cn: mTLS certificate Common Name. + initiator_session: Session ID if applicable. + is_propagated: True if context came from upstream service headers. + """ + + # Correlation ID for request chain tracing + trace_id: str = field(default_factory=lambda: str(uuid.uuid4())) + + # Initiator information (who caused this action) + initiator_ip: str = "" + initiator_user: str = "" + initiator_role: str = "" + initiator_cert_serial: str = "" + initiator_cert_cn: str = "" + initiator_session: str = "" + + # Metadata + is_propagated: bool = False + + def to_ecs_fields(self) -> Dict[str, Any]: + """ + Convert context to ECS-compliant field dictionary. + + Returns: + Dict with ECS field names. Empty values are excluded. + """ + result: Dict[str, Any] = { + "trace.id": self.trace_id, + } + + if self.initiator_ip: + result["source.ip"] = self.initiator_ip + if self.initiator_user: + result["source.user.name"] = self.initiator_user + if self.initiator_role: + result["source.user.roles"] = [self.initiator_role] + if self.initiator_cert_serial: + result["tls.client.x509.serial_number"] = self.initiator_cert_serial + if self.initiator_cert_cn: + result["tls.client.x509.subject.common_name"] = self.initiator_cert_cn + if self.initiator_session: + result["session.id"] = self.initiator_session + + return result + + +# Module-level ContextVar instance +# Each async task automatically gets isolated storage +_audit_context: ContextVar[AuditContext] = ContextVar("audit_context", default=AuditContext()) + + +def get_audit_context() -> AuditContext: + """ + Get the current request's audit context. + + Safe to call from anywhere. Returns empty context if called + outside of a request scope (e.g., during startup). + + Returns: + Current AuditContext for this async task. + """ + return _audit_context.get() + + +# pylint: disable=too-many-arguments, too-many-positional-arguments +def set_audit_context( + trace_id: Optional[str] = None, + initiator_ip: Optional[str] = None, + initiator_user: Optional[str] = None, + initiator_role: Optional[str] = None, + initiator_cert_serial: Optional[str] = None, + initiator_cert_cn: Optional[str] = None, + initiator_session: Optional[str] = None, + is_propagated: Optional[bool] = None, +) -> AuditContext: # pylint: disable=too-many-arguments + """ + Set or update the audit context for the current request. + + Only provided (non-None) fields are updated. Other fields retain + their current values. This allows incremental updates, e.g., + setting user info after JWT validation. + + Typically called by: + - AuditMiddleware at request start + - Auth dependencies after JWT validation + - Service code when additional context is available + + Args: + trace_id: Correlation ID (from X-Request-ID or generated). + initiator_ip: Source IP address. + initiator_user: Username/callsign. + initiator_role: User role (admin, user, service, etc.). + initiator_cert_serial: mTLS certificate serial number. + initiator_cert_cn: mTLS certificate Common Name. + initiator_session: Session identifier. + is_propagated: True if context came from upstream service. + + Returns: + The updated AuditContext. + """ + current = _audit_context.get() + + new_context = AuditContext( + trace_id=trace_id if trace_id is not None else current.trace_id, + initiator_ip=initiator_ip if initiator_ip is not None else current.initiator_ip, + initiator_user=initiator_user if initiator_user is not None else current.initiator_user, + initiator_role=initiator_role if initiator_role is not None else current.initiator_role, + initiator_cert_serial=( + initiator_cert_serial if initiator_cert_serial is not None else current.initiator_cert_serial + ), + initiator_cert_cn=initiator_cert_cn if initiator_cert_cn is not None else current.initiator_cert_cn, + initiator_session=initiator_session if initiator_session is not None else current.initiator_session, + is_propagated=is_propagated if is_propagated is not None else current.is_propagated, + ) + + _audit_context.set(new_context) + return new_context + + +def clear_audit_context() -> None: + """ + Reset context to empty defaults. + + Must be called at request end to prevent context leakage between + requests. The AuditMiddleware handles this automatically in its + finally block. + """ + _audit_context.set(AuditContext()) diff --git a/src/libpvarki/auditlogging/helpers.py b/src/libpvarki/auditlogging/helpers.py new file mode 100644 index 0000000..84bee0f --- /dev/null +++ b/src/libpvarki/auditlogging/helpers.py @@ -0,0 +1,323 @@ +""" +Convenience functions for audit logging. + +These helpers format the 'extra' dict for LOGGER.audit() calls with +proper ECS field mapping and automatic context injection from ContextVars. + +The extra fields will be properly formatted by ecs-logging inlibpvarki.logging into ECS-compliant JSON output. + +Usage:: + + import logging + from libpvarki.auditlogging import audit_log + + LOGGER = logging.getLogger(__name__) + + LOGGER.audit( + "Certificate issued for user", + extra=audit_log( + category="iam", + action="cert_issue", + outcome="success", + target_user="NORPPA11", + target_resource="DEADBEEF", + target_resource_type="certificate", + ) + ) +""" + +import os +from typing import Optional, Dict, Any +import hashlib + +from .context import get_audit_context + + +# Service identification from environment +# These match what's typically set in PVARKI docker-compose files +SERVICE_NAME = os.getenv("SERVICE_NAME", os.getenv("HOSTNAME", "pvarki")) +SERVICE_VERSION = os.getenv("RELEASE_TAG", os.getenv("SERVICE_VERSION", "unknown")) + + +def audit_log( # pylint: disable=too-many-arguments, too-many-branches, too-many-locals + category: str, + action: str, + *, + outcome: str = "success", + # Initiator overrides + initiator_user: Optional[str] = None, + initiator_role: Optional[str] = None, + initiator_ip: Optional[str] = None, + initiator_cert_serial: Optional[str] = None, + # Target fields + target_user: Optional[str] = None, + target_resource: Optional[str] = None, + target_resource_type: Optional[str] = None, + # Error information + error_message: Optional[str] = None, + error_code: Optional[str] = None, + # Additional fields + **extra_fields: Any, +) -> Dict[str, Any]: + """ + Build an ECS-compliant extra dict for audit logging. + + Automatically injects initiator context from AuditMiddleware. + Use with ``LOGGER.audit("message", extra=audit_log(...))``. + + Args: + category: Event category per ECS. Common values: + + - ``authentication``: Login, logout, token exchange + - ``authorization``: Permission checks + - ``iam``: Identity management, cert issuance + - ``configuration``: Settings changes + - ``session``: Session lifecycle + - ``network``: Connection events + - ``intrusion_detection``: Security anomalies + + action: Specific action identifier. Examples: + + - ``otp_exchange``, ``jwt_validate``, ``mtls_auth`` + - ``cert_issue``, ``cert_revoke``, ``user_enroll`` + - ``config_update``, ``permission_grant`` + + outcome: Result of the action: + + - ``success``: Action completed successfully + - ``failure``: Action failed + - ``unknown``: Outcome not determined + + initiator_user: Override context initiator user. + initiator_role: Override context initiator role. + initiator_ip: Override context initiator IP. + initiator_cert_serial: Override context cert serial. + target_user: User affected by the action. + target_resource: Resource identifier (cert serial, endpoint, etc.). + target_resource_type: Type of resource (certificate, user, endpoint). + error_message: Human-readable error description for failures. + error_code: Machine-readable error code for failures. + **extra_fields: Additional fields added under ``pvarki.*`` namespace. + + Returns: + Dict suitable for logging extra parameter. + + Example:: + + LOGGER.audit( + "OTP exchange successful", + extra=audit_log( + category="authentication", + action="otp_exchange", + outcome="success", + target_user="NORPPA11", + ) + ) + """ + ctx = get_audit_context() + + # Build ECS-compliant extra dict + result: Dict[str, Any] = { + # Event classification (ECS) + "event.category": category, + "event.action": action, + "event.outcome": outcome, + # Service identification + "service.name": SERVICE_NAME, + "service.version": SERVICE_VERSION, + # Correlation + "trace.id": ctx.trace_id, + } + + # Initiator fields (explicit params override context) + _initiator_user = initiator_user or ctx.initiator_user + _initiator_role = initiator_role or ctx.initiator_role + _initiator_ip = initiator_ip or ctx.initiator_ip + _initiator_cert_serial = initiator_cert_serial or ctx.initiator_cert_serial + + if _initiator_ip: + result["source.ip"] = _initiator_ip + if _initiator_user: + result["source.user.name"] = _initiator_user + if _initiator_role: + result["source.user.roles"] = [_initiator_role] + if _initiator_cert_serial: + result["tls.client.x509.serial_number"] = _initiator_cert_serial + if ctx.initiator_cert_cn: + result["tls.client.x509.subject.common_name"] = ctx.initiator_cert_cn + if ctx.initiator_session: + result["session.id"] = ctx.initiator_session + + # Target fields (ECS user.target.* for affected user) + if target_user: + result["user.target.name"] = target_user + if target_resource: + result["pvarki.target.resource"] = target_resource + if target_resource_type: + result["pvarki.target.resource_type"] = target_resource_type + + # Error information (ECS error.*) + if error_message: + result["error.message"] = error_message + if error_code: + result["error.code"] = error_code + + # Additional fields under pvarki.* namespace + for key, value in extra_fields.items(): + if value is not None: + result[f"pvarki.{key}"] = value + + return result + + +def audit_extra(**fields: Any) -> Dict[str, Any]: + """ + Simple wrapper to add trace context to any log call. + + For non-audit logs that still need trace correlation. + Less structured than audit_log(), just adds trace.id and extra fields. + + Args: + **fields: Fields to include in the extra dict. + + Returns: + Dict with trace.id and provided fields. + + Example:: + + LOGGER.info("Processing request", extra=audit_extra( + endpoint="/api/v1/users", + method="POST", + )) + """ + ctx = get_audit_context() + result: Dict[str, Any] = { + "trace.id": ctx.trace_id, + "service.name": SERVICE_NAME, + } + result.update(fields) + return result + + +# ============================================================================= +# Convenience wrappers for common event categories +# ============================================================================= + + +def audit_authentication( + action: str, + outcome: str = "success", + target_user: Optional[str] = None, + **kwargs: Any, +) -> Dict[str, Any]: + """Build audit log extra for authentication events.""" + return audit_log( + category="authentication", + action=action, + outcome=outcome, + target_user=target_user, + **kwargs, + ) + + +def audit_iam( + action: str, + outcome: str = "success", + target_user: Optional[str] = None, + target_resource: Optional[str] = None, + **kwargs: Any, +) -> Dict[str, Any]: + """Build audit log extra for identity/access management events.""" + return audit_log( + category="iam", + action=action, + outcome=outcome, + target_user=target_user, + target_resource=target_resource, + **kwargs, + ) + + +def audit_authorization( + action: str, + outcome: str = "success", + target_user: Optional[str] = None, + **kwargs: Any, +) -> Dict[str, Any]: + """Build audit log extra for authorization events.""" + return audit_log( + category="authorization", + action=action, + outcome=outcome, + target_user=target_user, + **kwargs, + ) + + +def audit_configuration( + action: str, + outcome: str = "success", + target_resource: Optional[str] = None, + **kwargs: Any, +) -> Dict[str, Any]: + """Build audit log extra for configuration change events.""" + return audit_log( + category="configuration", + action=action, + outcome=outcome, + target_resource=target_resource, + **kwargs, + ) + + +def audit_session( + action: str, + outcome: str = "success", + target_user: Optional[str] = None, + **kwargs: Any, +) -> Dict[str, Any]: + """Build audit log extra for session lifecycle events.""" + return audit_log( + category="session", + action=action, + outcome=outcome, + target_user=target_user, + **kwargs, + ) + + +def audit_anomaly( + action: str, + error_message: Optional[str] = None, + **kwargs: Any, +) -> Dict[str, Any]: + """Build audit log extra for security anomalies (always failure).""" + return audit_log( + category="intrusion_detection", + action=action, + outcome="failure", + error_message=error_message, + **kwargs, + ) + + +# ============================================================================= +# Fingerprinting function for when we want to log secrets +# ============================================================================= + + +def code_fingerprint(code: str, *, context: str = "generic") -> str: + """ + Generate a non-reversible fingerprint of a code for audit logging. + + Uses SHA256 for correlation only. Never log raw secrets. + + Args: + code: Secret value (invite code, OTP, token). + context: Domain separation label (e.g. "invitecode", "otp"). + + Returns: + Short hex fingerprint suitable for logs. + """ + data = f"{context}:{code}".encode("utf-8") + return hashlib.sha256(data).hexdigest()[:12] diff --git a/src/libpvarki/auditlogging/middleware.py b/src/libpvarki/auditlogging/middleware.py new file mode 100644 index 0000000..20c791f --- /dev/null +++ b/src/libpvarki/auditlogging/middleware.py @@ -0,0 +1,217 @@ +""" +FastAPI/Starlette middleware for automatic audit context setup. + +Extracts initiator information from incoming requests: +1. nginx headers (X-ClientCert-*, X-Real-IP) for direct mTLS requests +2. Propagation headers (X-Initiator-*) for service-to-service calls + +Sets ContextVars that are automatically read by audit_log() helper. +""" + +import logging +import uuid +from collections.abc import Awaitable, Callable + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from .context import set_audit_context, clear_audit_context + +LOGGER = logging.getLogger(__name__) + + +# Header names for nginx mTLS info +HEADER_REQUEST_ID = "X-Request-ID" +HEADER_REAL_IP = "X-Real-IP" +HEADER_FORWARDED_FOR = "X-Forwarded-For" +HEADER_CLIENT_CERT_DN = "X-ClientCert-DN" +HEADER_CLIENT_CERT_SERIAL = "X-ClientCert-Serial" + +# Header names for service-to-service propagation +HEADER_INITIATOR_USER = "X-Initiator-User" +HEADER_INITIATOR_IP = "X-Initiator-IP" +HEADER_INITIATOR_ROLE = "X-Initiator-Role" +HEADER_INITIATOR_CERT_SERIAL = "X-Initiator-Cert-Serial" +HEADER_INITIATOR_SESSION = "X-Initiator-Session" + + +def _parse_cn_from_dn(distinguished_name: str) -> str: + """ + Extract Common Name from Distinguished Name string. + + Args: + distinguished_name: Distinguished Name, e.g., "CN=NORPPA11,O=PVARKI,C=FI" + + Returns: + The CN value, or empty string if not found. + """ + if not distinguished_name: + return "" + + for part in distinguished_name.split(","): + part = part.strip() + if part.upper().startswith("CN="): + return part[3:] + return "" + + +class AuditMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-public-methods + """ + Middleware to extract and set audit context for each request. + + Handles two scenarios: + + 1. Direct requests via nginx with mTLS: + - Reads X-ClientCert-DN, X-ClientCert-Serial from nginx + - Reads X-Real-IP or X-Forwarded-For for source IP + + 2. Service-to-service calls with propagated context: + - Reads X-Initiator-* headers set by upstream service + - Preserves original initiator identity through the chain + + Priority: Direct mTLS headers take precedence over propagated headers, + as they represent verified identity from nginx. + + nginx configuration example:: + + proxy_set_header X-Request-ID $request_id; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-ClientCert-DN $ssl_client_s_dn; + proxy_set_header X-ClientCert-Serial $ssl_client_serial; + """ + + async def dispatch( # pylint: disable=too-many-locals + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + """Extract context from headers and process request.""" + + # === Trace ID (correlation) === + trace_id = request.headers.get(HEADER_REQUEST_ID, "") + if not trace_id: + trace_id = str(uuid.uuid4()) + + # === Source IP === + source_ip = self._extract_source_ip(request) + + # === Initiator Identity === + # Try direct mTLS first (nginx headers) + cert_dn = request.headers.get(HEADER_CLIENT_CERT_DN, "") + cert_serial = request.headers.get(HEADER_CLIENT_CERT_SERIAL, "") + cert_cn = _parse_cn_from_dn(cert_dn) + + # Check for propagated context (service-to-service) + prop_user = request.headers.get(HEADER_INITIATOR_USER, "") + prop_ip = request.headers.get(HEADER_INITIATOR_IP, "") + prop_role = request.headers.get(HEADER_INITIATOR_ROLE, "") + prop_cert_serial = request.headers.get(HEADER_INITIATOR_CERT_SERIAL, "") + prop_session = request.headers.get(HEADER_INITIATOR_SESSION, "") + + # Determine final values (direct mTLS takes precedence) + is_propagated = False + if cert_cn: + # Direct mTLS request - use cert info + initiator_user = cert_cn + initiator_cert_serial = cert_serial + initiator_ip = source_ip + initiator_role = "" + initiator_session = "" + elif prop_user: + # Service-to-service with propagated context + is_propagated = True + initiator_user = prop_user + initiator_ip = prop_ip or source_ip + initiator_role = prop_role + initiator_cert_serial = prop_cert_serial + initiator_session = prop_session + else: + # No identity info - just IP + initiator_user = "" + initiator_ip = source_ip + initiator_role = "" + initiator_cert_serial = "" + initiator_session = "" + + # Set context for this request + set_audit_context( + trace_id=trace_id, + initiator_ip=initiator_ip, + initiator_user=initiator_user, + initiator_role=initiator_role, + initiator_cert_serial=initiator_cert_serial, + initiator_cert_cn=cert_cn, + initiator_session=initiator_session, + is_propagated=is_propagated, + ) + + try: + response = await call_next(request) + # Add trace ID to response for debugging/correlation + response.headers[HEADER_REQUEST_ID] = trace_id + return response + finally: + # Always clear context to prevent leakage + clear_audit_context() + + def _extract_source_ip(self, request: Request) -> str: + """ + Extract client IP from request headers. + + Priority: + 1. X-Real-IP (set by nginx) + 2. X-Forwarded-For (first IP in chain) + 3. Direct client IP from connection + + Args: + request: The incoming Starlette request. + + Returns: + Client IP address, or empty string if not available. + """ + # Prefer X-Real-IP (typically set by nginx to actual client) + real_ip = request.headers.get(HEADER_REAL_IP, "") + if real_ip: + return real_ip.strip() + + # Fall back to X-Forwarded-For (take first/leftmost = original client) + forwarded_for = request.headers.get(HEADER_FORWARDED_FOR, "") + if forwarded_for: + return forwarded_for.split(",")[0].strip() + + # Last resort: direct connection IP + if request.client: + return request.client.host + + return "" + + +def update_audit_user(user: str, role: str = "", session: str = "") -> None: + """ + Update audit context with user info after authentication. + + Call this after JWT validation or other auth mechanism in your + FastAPI dependency to enrich the audit context with user identity. + + Args: + user: Username or callsign. + role: User role (admin, user, operator, etc.). + session: Session identifier if applicable. + + Example:: + + async def get_current_user(token: str = Depends(oauth2_scheme)): + payload = decode_jwt(token) + update_audit_user( + user=payload["sub"], + role=payload.get("role", ""), + ) + return payload + """ + set_audit_context( + initiator_user=user, + initiator_role=role, + initiator_session=session, + ) diff --git a/src/libpvarki/auditlogging/propagation.py b/src/libpvarki/auditlogging/propagation.py new file mode 100644 index 0000000..6fd91e2 --- /dev/null +++ b/src/libpvarki/auditlogging/propagation.py @@ -0,0 +1,194 @@ +""" +Service-to-service audit context propagation. + +When one PVARKI service calls another (e.g., rmapi -> takrmapi), +the original initiator information must be passed along so audit +logs in downstream services correctly attribute actions. + +This module provides helpers to: +1. Get headers to include in outgoing HTTP requests +2. Inject context into aiohttp client sessions + +Example usage with aiohttp (already a libpvarki dependency):: + + from libpvarki.auditlogging import get_propagation_headers + import aiohttp + + async def call_product_api(url: str, data: dict): + headers = get_propagation_headers() + async with aiohttp.ClientSession() as session: + await session.post(url, json=data, headers=headers) +""" + +from __future__ import annotations + +from typing import Dict, Optional, TYPE_CHECKING + +from .context import get_audit_context + +if TYPE_CHECKING: + import aiohttp + +# Header names for propagation (must match middleware.py) +HEADER_REQUEST_ID = "X-Request-ID" +HEADER_INITIATOR_USER = "X-Initiator-User" +HEADER_INITIATOR_IP = "X-Initiator-IP" +HEADER_INITIATOR_ROLE = "X-Initiator-Role" +HEADER_INITIATOR_CERT_SERIAL = "X-Initiator-Cert-Serial" +HEADER_INITIATOR_SESSION = "X-Initiator-Session" + + +def get_propagation_headers() -> Dict[str, str]: + """ + Get HTTP headers to propagate audit context to downstream services. + + Include these headers when making HTTP requests to other PVARKI + services to preserve the initiator chain for audit logging. + + Returns: + Dict of header name -> value. Only non-empty values included. + + Example with aiohttp:: + + from libpvarki.auditlogging import get_propagation_headers + import aiohttp + + async with aiohttp.ClientSession() as session: + headers = get_propagation_headers() + await session.post(url, json=data, headers=headers) + + Example with libpvarki.mtlshelp.session:: + + from libpvarki.mtlshelp.session import get_session + from libpvarki.auditlogging import get_propagation_headers + + session = await get_session(client_cert, client_key, ca_cert) + headers = get_propagation_headers() + async with session.post(url, json=data, headers=headers) as resp: + ... + """ + ctx = get_audit_context() + headers: Dict[str, str] = {} + + # Always include trace ID for correlation + if ctx.trace_id: + headers[HEADER_REQUEST_ID] = ctx.trace_id + + # Include initiator info if available + if ctx.initiator_user: + headers[HEADER_INITIATOR_USER] = ctx.initiator_user + if ctx.initiator_ip: + headers[HEADER_INITIATOR_IP] = ctx.initiator_ip + if ctx.initiator_role: + headers[HEADER_INITIATOR_ROLE] = ctx.initiator_role + if ctx.initiator_cert_serial: + headers[HEADER_INITIATOR_CERT_SERIAL] = ctx.initiator_cert_serial + if ctx.initiator_session: + headers[HEADER_INITIATOR_SESSION] = ctx.initiator_session + + return headers + + +def inject_audit_context(headers: Optional[Dict[str, str]] = None) -> Dict[str, str]: + """ + Inject audit context into an existing headers dict. + + Convenience function that merges propagation headers with + existing headers. Existing headers are NOT overwritten. + + Args: + headers: Existing headers dict, or None to create new one. + + Returns: + Headers dict with audit context added. + + Example:: + + headers = {"Content-Type": "application/json"} + headers = inject_audit_context(headers) + await session.post(url, headers=headers, json=data) + """ + result = dict(headers) if headers else {} + propagation = get_propagation_headers() + + # Add propagation headers, don't overwrite existing + for key, value in propagation.items(): + if key not in result: + result[key] = value + + return result + + +class AuditContextClientMixin: + """ + Mixin for HTTP clients that automatically propagates audit context. + + Can be used as a mixin for custom client classes. + + Example:: + + class ProductClient(AuditContextClientMixin): + def __init__(self, base_url: str): + self.base_url = base_url + + async def notify_enrollment(self, callsign: str): + headers = self.get_audit_headers() + async with aiohttp.ClientSession() as session: + await session.post( + f"{self.base_url}/api/v1/enrolled", + json={"callsign": callsign}, + headers=headers, + ) + """ + + def get_audit_headers(self) -> Dict[str, str]: + """Get headers with audit context for HTTP requests.""" + return get_propagation_headers() + + def merge_audit_headers(self, headers: Optional[Dict[str, str]] = None) -> Dict[str, str]: + """Merge audit headers with existing headers.""" + return inject_audit_context(headers) + + +def create_audit_trace_config() -> "aiohttp.TraceConfig": + """ + Create aiohttp TraceConfig that adds audit headers to all requests. + + This automatically injects propagation headers into every request + made by the ClientSession. + + Returns: + aiohttp.TraceConfig instance. + + Example:: + + import aiohttp + from libpvarki.auditlogging import create_audit_trace_config + + trace_config = create_audit_trace_config() + async with aiohttp.ClientSession(trace_configs=[trace_config]) as session: + # All requests automatically include audit headers + await session.get("http://other-service/api/v1/status") + """ + try: + import aiohttp # pylint: disable=import-outside-toplevel + + async def on_request_start( + _session: aiohttp.ClientSession, + _trace_config_ctx: object, + params: aiohttp.TraceRequestStartParams, + ) -> None: + headers = get_propagation_headers() + for key, value in headers.items(): + if key not in params.headers: + params.headers[key] = value + + trace_config = aiohttp.TraceConfig() + trace_config.on_request_start.append(on_request_start) + return trace_config + + except ImportError as exc: + raise ImportError( + "aiohttp is required for create_audit_trace_config(). " + "This should already be installed as a libpvarki dependency." + ) from exc diff --git a/src/libpvarki/logging.py b/src/libpvarki/logging.py index 1a1c187..f0a0203 100644 --- a/src/libpvarki/logging.py +++ b/src/libpvarki/logging.py @@ -1,5 +1,5 @@ """Things common for all handlers""" -from typing import Optional, Mapping, Any, Dict, cast +from typing import Optional, Mapping, Any, Dict, Callable, cast import os import json import logging @@ -15,7 +15,7 @@ class UTCISOFormatter(logging.Formatter): """Output timestamps in UTC ISO timestamps""" - converter = time.gmtime + converter: Callable[[Optional[float]], time.struct_time] = time.gmtime def formatTime(self, record: logging.LogRecord, datefmt: Optional[str] = None) -> str: converted = datetime.datetime.fromtimestamp(record.created, tz=datetime.timezone.utc) @@ -73,9 +73,11 @@ def __init__(self, extras: Mapping[str, Any], name: str = "") -> None: def filter(self, record: logging.LogRecord) -> bool: """Add the extras then call parent filter""" - for key in self.add_extras: - setattr(record, key, self.add_extras[key]) - return super().filter(record) + for key, value in self.add_extras.items(): + setattr(record, key, value) + + result = super().filter(record) + return bool(result) def init_logging(level: int = logging.INFO) -> None: diff --git a/src/libpvarki/mtlshelp/csr.py b/src/libpvarki/mtlshelp/csr.py index c128626..f296ac6 100644 --- a/src/libpvarki/mtlshelp/csr.py +++ b/src/libpvarki/mtlshelp/csr.py @@ -5,7 +5,9 @@ import stat import asyncio -from OpenSSL import crypto # FIXME: use cryptography instead of pyOpenSSL +from OpenSSL import crypto # pylint: disable=import-error + +# FIXME: use cryptography instead of pyOpenSSL LOGGER = logging.getLogger(__name__) diff --git a/tests/test_auditlogging.py b/tests/test_auditlogging.py new file mode 100644 index 0000000..214bc59 --- /dev/null +++ b/tests/test_auditlogging.py @@ -0,0 +1,478 @@ +""" +Tests for libpvarki.auditlogging module. + +Run with: pytest tests/test_auditlogging.py -v + +These tests are designed to work within the libpvarki test infrastructure. +""" +# pylint: disable=redefined-outer-name + +import logging +from typing import Generator, Dict, Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from libpvarki.auditlogging import ( + AUDIT, + AuditMiddleware, + audit_log, + audit_authentication, + audit_iam, + get_audit_context, + set_audit_context, + clear_audit_context, + get_propagation_headers, + inject_audit_context, + update_audit_user, +) + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture(autouse=True) +def reset_context() -> Generator[None, None, None]: + """Clear audit context before and after each test.""" + clear_audit_context() + yield + clear_audit_context() + + +@pytest.fixture +def app() -> FastAPI: + """Create a test FastAPI app with AuditMiddleware.""" + app = FastAPI() + app.add_middleware(AuditMiddleware) + + @app.get("/test") + async def test_endpoint() -> Dict[str, Any]: + ctx = get_audit_context() + return { + "trace_id": ctx.trace_id, + "initiator_user": ctx.initiator_user, + "initiator_ip": ctx.initiator_ip, + "is_propagated": ctx.is_propagated, + } + + @app.post("/enroll") + async def enroll_endpoint() -> Dict[str, str]: + ctx = get_audit_context() + return {"enrolled": ctx.initiator_user} + + return app + + +@pytest.fixture +def client(app: FastAPI) -> TestClient: + """Test client for the app.""" + return TestClient(app) + + +# ============================================================================= +# AUDIT Level Tests +# ============================================================================= + + +class TestAuditLevel: + """Tests for AUDIT log level setup.""" + + def test_audit_level_constant(self) -> None: + """AUDIT level should be CRITICAL + 5 (55).""" + assert AUDIT == logging.CRITICAL + 5 + + def test_audit_level_registered(self) -> None: + """AUDIT level should be registered with logging module.""" + assert hasattr(logging, "AUDIT") + audit_val = getattr(logging, "AUDIT") + assert isinstance(audit_val, int) + assert audit_val == AUDIT + + def test_logger_has_audit_method(self) -> None: + """Logger instances should have audit() method.""" + logger = logging.getLogger("test.audit_level") + assert hasattr(logger, "audit") + assert callable(logger.audit) + + def test_audit_level_name(self) -> None: + """AUDIT level should have correct name.""" + assert logging.getLevelName(25) == "AUDIT" + assert logging.getLevelName("AUDIT") == 25 + + +# ============================================================================= +# Context Tests +# ============================================================================= + + +class TestAuditContext: + """Tests for ContextVar-based audit context.""" + + def test_default_context(self) -> None: + """Default context should have generated trace_id.""" + ctx = get_audit_context() + assert ctx.trace_id # Should be non-empty UUID + assert ctx.initiator_user == "" + assert ctx.initiator_ip == "" + assert ctx.is_propagated is False + + def test_set_context(self) -> None: + """set_audit_context should update fields.""" + set_audit_context( + trace_id="test-trace-123", + initiator_user="NORPPA11", + initiator_ip="192.168.1.100", + ) + ctx = get_audit_context() + assert ctx.trace_id == "test-trace-123" + assert ctx.initiator_user == "NORPPA11" + assert ctx.initiator_ip == "192.168.1.100" + + def test_partial_update(self) -> None: + """set_audit_context should only update provided fields.""" + set_audit_context(initiator_user="KOTKA1") + set_audit_context(initiator_role="admin") + + ctx = get_audit_context() + assert ctx.initiator_user == "KOTKA1" + assert ctx.initiator_role == "admin" + + def test_clear_context(self) -> None: + """clear_audit_context should reset to defaults.""" + set_audit_context(initiator_user="NORPPA11") + clear_audit_context() + + ctx = get_audit_context() + assert ctx.initiator_user == "" + + def test_to_ecs_fields(self) -> None: + """Context should convert to ECS field dict.""" + set_audit_context( + trace_id="abc-123", + initiator_user="NORPPA11", + initiator_ip="10.0.0.1", + initiator_role="operator", + initiator_cert_serial="DEADBEEF", + ) + ctx = get_audit_context() + fields = ctx.to_ecs_fields() + + assert fields["trace.id"] == "abc-123" + assert fields["source.user.name"] == "NORPPA11" + assert fields["source.ip"] == "10.0.0.1" + assert fields["source.user.roles"] == ["operator"] + assert fields["tls.client.x509.serial_number"] == "DEADBEEF" + + +# ============================================================================= +# Middleware Tests +# ============================================================================= + + +class TestAuditMiddleware: + """Tests for FastAPI middleware.""" + + def test_extracts_request_id(self, client: TestClient) -> None: + """Middleware should extract X-Request-ID.""" + response = client.get("/test", headers={"X-Request-ID": "my-trace-id"}) + assert response.status_code == 200 + assert response.json()["trace_id"] == "my-trace-id" + + def test_generates_request_id(self, client: TestClient) -> None: + """Middleware should generate trace ID if not provided.""" + response = client.get("/test") + assert response.status_code == 200 + assert response.json()["trace_id"] # Non-empty + + def test_returns_request_id_header(self, client: TestClient) -> None: + """Response should include X-Request-ID header.""" + response = client.get("/test", headers={"X-Request-ID": "echo-me"}) + assert response.headers["X-Request-ID"] == "echo-me" + + def test_extracts_real_ip(self, client: TestClient) -> None: + """Middleware should extract X-Real-IP.""" + response = client.get("/test", headers={"X-Real-IP": "203.0.113.50"}) + assert response.json()["initiator_ip"] == "203.0.113.50" + + def test_extracts_forwarded_for(self, client: TestClient) -> None: + """Middleware should extract X-Forwarded-For if no X-Real-IP.""" + response = client.get("/test", headers={"X-Forwarded-For": "203.0.113.50, 10.0.0.1"}) + assert response.json()["initiator_ip"] == "203.0.113.50" + + def test_extracts_cert_dn(self, client: TestClient) -> None: + """Middleware should extract CN from X-ClientCert-DN.""" + response = client.get( + "/test", + headers={"X-ClientCert-DN": "CN=NORPPA11,O=PVARKI,C=FI"}, + ) + assert response.json()["initiator_user"] == "NORPPA11" + + def test_propagated_headers(self, client: TestClient) -> None: + """Middleware should extract X-Initiator-* headers.""" + response = client.get( + "/test", + headers={ + "X-Initiator-User": "KOTKA1", + "X-Initiator-IP": "192.168.1.50", + }, + ) + data = response.json() + assert data["initiator_user"] == "KOTKA1" + assert data["initiator_ip"] == "192.168.1.50" + assert data["is_propagated"] is True + + def test_direct_mtls_takes_precedence(self, client: TestClient) -> None: + """Direct mTLS cert should override propagated headers.""" + response = client.get( + "/test", + headers={ + "X-ClientCert-DN": "CN=DIRECT_USER,O=TEST", + "X-Initiator-User": "PROPAGATED_USER", + }, + ) + # Direct mTLS wins + assert response.json()["initiator_user"] == "DIRECT_USER" + assert response.json()["is_propagated"] is False + + +# ============================================================================= +# Helper Tests +# ============================================================================= + + +class TestAuditLogHelper: + """Tests for audit_log() helper function.""" + + def test_basic_audit_log(self) -> None: + """audit_log should create ECS-compliant dict.""" + set_audit_context(trace_id="test-123") + + extra = audit_log( + category="authentication", + action="otp_exchange", + outcome="success", + ) + + assert extra["event.category"] == "authentication" + assert extra["event.action"] == "otp_exchange" + assert extra["event.outcome"] == "success" + assert extra["trace.id"] == "test-123" + assert "service.name" in extra + + def test_audit_log_with_target(self) -> None: + """audit_log should include target fields.""" + extra = audit_log( + category="iam", + action="cert_issue", + outcome="success", + target_user="NORPPA11", + target_resource="DEADBEEF", + target_resource_type="certificate", + ) + + assert extra["user.target.name"] == "NORPPA11" + assert extra["pvarki.target.resource"] == "DEADBEEF" + assert extra["pvarki.target.resource_type"] == "certificate" + + def test_audit_log_with_error(self) -> None: + """audit_log should include error fields.""" + extra = audit_log( + category="authentication", + action="jwt_validate", + outcome="failure", + error_message="Token expired", + error_code="TOKEN_EXPIRED", + ) + + assert extra["event.outcome"] == "failure" + assert extra["error.message"] == "Token expired" + assert extra["error.code"] == "TOKEN_EXPIRED" + + def test_audit_log_uses_context(self) -> None: + """audit_log should include context initiator.""" + set_audit_context( + initiator_user="CONTEXT_USER", + initiator_ip="10.0.0.1", + ) + + extra = audit_log(category="test", action="test") + + assert extra["source.user.name"] == "CONTEXT_USER" + assert extra["source.ip"] == "10.0.0.1" + + def test_audit_log_override_context(self) -> None: + """Explicit params should override context.""" + set_audit_context(initiator_user="CONTEXT_USER") + + extra = audit_log( + category="test", + action="test", + initiator_user="OVERRIDE_USER", + ) + + assert extra["source.user.name"] == "OVERRIDE_USER" + + def test_audit_log_extra_fields(self) -> None: + """Extra fields should go under pvarki namespace.""" + extra = audit_log( + category="test", + action="test", + custom_field="custom_value", + another_field=123, + ) + + assert extra["pvarki.custom_field"] == "custom_value" + assert extra["pvarki.another_field"] == 123 + + def test_convenience_wrappers(self) -> None: + """Category convenience functions should work.""" + auth = audit_authentication("login", outcome="success") + assert auth["event.category"] == "authentication" + + iam = audit_iam("cert_issue", target_user="NORPPA11") + assert iam["event.category"] == "iam" + assert iam["user.target.name"] == "NORPPA11" + + +# ============================================================================= +# Propagation Tests +# ============================================================================= + + +class TestPropagation: + """Tests for service-to-service propagation.""" + + def test_get_propagation_headers(self) -> None: + """get_propagation_headers should return context as headers.""" + set_audit_context( + trace_id="prop-trace-123", + initiator_user="NORPPA11", + initiator_ip="192.168.1.100", + initiator_role="admin", + initiator_cert_serial="DEADBEEF", + ) + + headers = get_propagation_headers() + + assert headers["X-Request-ID"] == "prop-trace-123" + assert headers["X-Initiator-User"] == "NORPPA11" + assert headers["X-Initiator-IP"] == "192.168.1.100" + assert headers["X-Initiator-Role"] == "admin" + assert headers["X-Initiator-Cert-Serial"] == "DEADBEEF" + + def test_propagation_empty_context(self) -> None: + """Propagation headers should exclude empty values.""" + clear_audit_context() + + headers = get_propagation_headers() + + assert "X-Request-ID" in headers + assert "X-Initiator-User" not in headers + assert "X-Initiator-IP" not in headers + + def test_inject_audit_context(self) -> None: + """inject_audit_context should merge with existing headers.""" + set_audit_context(trace_id="inject-test") + + existing = {"Content-Type": "application/json"} + result = inject_audit_context(existing) + + assert result["Content-Type"] == "application/json" + assert result["X-Request-ID"] == "inject-test" + + def test_inject_no_overwrite(self) -> None: + """inject_audit_context should not overwrite existing headers.""" + set_audit_context(trace_id="new-value") + + existing = {"X-Request-ID": "existing-value"} + result = inject_audit_context(existing) + + assert result["X-Request-ID"] == "existing-value" + + +# ============================================================================= +# Update User Tests +# ============================================================================= + + +class TestUpdateAuditUser: + """Tests for update_audit_user helper.""" + + def test_update_user(self) -> None: + """update_audit_user should update context.""" + update_audit_user(user="NORPPA11", role="operator") + + ctx = get_audit_context() + assert ctx.initiator_user == "NORPPA11" + assert ctx.initiator_role == "operator" + + def test_update_preserves_other_fields(self) -> None: + """update_audit_user should preserve other context fields.""" + set_audit_context(trace_id="keep-me", initiator_ip="10.0.0.1") + update_audit_user(user="NORPPA11") + + ctx = get_audit_context() + assert ctx.trace_id == "keep-me" + assert ctx.initiator_ip == "10.0.0.1" + assert ctx.initiator_user == "NORPPA11" + + +# ============================================================================= +# Integration Test +# ============================================================================= + + +class TestIntegration: + """Integration test simulating real usage.""" + + def test_full_flow(self, client: TestClient) -> None: + """Test complete audit logging flow.""" + # Simulate mTLS request through nginx + response = client.post( + "/enroll", + headers={ + "X-Request-ID": "integration-test-123", + "X-ClientCert-DN": "CN=NORPPA11,O=PVARKI,C=FI", + "X-ClientCert-Serial": "DEADBEEF", + "X-Real-IP": "203.0.113.50", + }, + ) + + assert response.status_code == 200 + assert response.json()["enrolled"] == "NORPPA11" + assert response.headers["X-Request-ID"] == "integration-test-123" + + def test_service_chain(self, client: TestClient) -> None: + """Test context propagation through service chain.""" + # First service receives from nginx + response1 = client.get( + "/test", + headers={ + "X-Request-ID": "chain-trace-456", + "X-ClientCert-DN": "CN=ORIGINAL_USER,O=TEST", + }, + ) + assert response1.json()["initiator_user"] == "ORIGINAL_USER" + + # Second service receives propagated context + response2 = client.get( + "/test", + headers={ + "X-Request-ID": "chain-trace-456", + "X-Initiator-User": "ORIGINAL_USER", + "X-Initiator-IP": "10.0.0.1", + }, + ) + assert response2.json()["initiator_user"] == "ORIGINAL_USER" + assert response2.json()["is_propagated"] is True + + +# ============================================================================= +# Run tests +# ============================================================================= + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_libpvarki.py b/tests/test_libpvarki.py index 2db230f..2db4b29 100644 --- a/tests/test_libpvarki.py +++ b/tests/test_libpvarki.py @@ -4,4 +4,4 @@ def test_version() -> None: """Make sure version matches expected""" - assert __version__ == "1.9.1" + assert __version__ == "1.9.2"