diff --git a/.fern/metadata.json b/.fern/metadata.json index eebfd26..532f6c1 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -24,5 +24,5 @@ ] }, "originGitCommit": "efe71642022d9d3303fd78c648e5b2539192230e", - "sdkVersion": "1.2.1" + "sdkVersion": "1.2.2" } \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 111a255..921b844 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -6,6 +6,8 @@ version = "2.6.1" description = "Happy Eyeballs for asyncio" optional = false python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version >= \"3.9\"" files = [ {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, @@ -17,6 +19,8 @@ version = "3.13.5" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version >= \"3.9\"" files = [ {file = "aiohttp-3.13.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:02222e7e233295f40e011c1b00e3b0bd451f22cf853a0304c3595633ee47da4b"}, {file = "aiohttp-3.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bace460460ed20614fa6bc8cb09966c0b8517b8c58ad8046828c6078d25333b5"}, @@ -151,7 +155,7 @@ propcache = ">=0.2.0" yarl = ">=1.17.0,<2.0" [package.extras] -speedups = ["Brotli (>=1.2)", "aiodns (>=3.3.0)", "backports.zstd", "brotlicffi (>=1.2)"] +speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""] [[package]] name = "aiosignal" @@ -159,6 +163,8 @@ version = "1.4.0" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version >= \"3.9\"" files = [ {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, @@ -174,6 +180,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -188,6 +195,7 @@ version = "4.5.2" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f"}, {file = "anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b"}, @@ -201,7 +209,7 @@ typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "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", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] +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", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21.0b1) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -210,6 +218,8 @@ version = "5.0.1" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version >= \"3.9\" and python_full_version < \"3.11.3\"" files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, @@ -221,6 +231,8 @@ version = "26.1.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version >= \"3.9\"" files = [ {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, @@ -232,6 +244,7 @@ version = "2026.4.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"}, {file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"}, @@ -243,6 +256,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\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -254,6 +269,8 @@ version = "1.3.1" 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.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, @@ -271,6 +288,7 @@ version = "2.1.2" description = "execnet: rapid multi-Python deployment" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"}, {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"}, @@ -285,6 +303,8 @@ version = "1.8.0" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version >= \"3.9\"" files = [ {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, @@ -424,6 +444,7 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -435,6 +456,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -456,6 +478,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -468,7 +491,7 @@ httpcore = "==1.*" idna = "*" [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.*)"] @@ -480,10 +503,12 @@ version = "3.15" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"}, {file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"}, ] +markers = {dev = "python_version >= \"3.9\""} [package.extras] all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] @@ -494,6 +519,8 @@ version = "6.4.5" description = "Read resources from Python packages" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"datastream\" or extra == \"rulesengine\"" files = [ {file = "importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717"}, {file = "importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065"}, @@ -503,7 +530,7 @@ files = [ zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] @@ -516,6 +543,7 @@ version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, @@ -527,6 +555,8 @@ version = "6.7.1" description = "multidict implementation" optional = false python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version >= \"3.9\"" files = [ {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"}, {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"}, @@ -685,6 +715,7 @@ version = "1.13.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, @@ -738,6 +769,7 @@ version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, @@ -749,6 +781,7 @@ version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, @@ -760,6 +793,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"}, @@ -775,6 +809,8 @@ version = "0.4.1" description = "Accelerated property cache" optional = false python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version >= \"3.9\"" files = [ {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db"}, {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8"}, @@ -906,6 +942,7 @@ version = "2.10.6" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584"}, {file = "pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236"}, @@ -918,7 +955,7 @@ typing-extensions = ">=4.12.2" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" @@ -926,6 +963,7 @@ version = "2.27.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"}, {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"}, @@ -1038,6 +1076,8 @@ version = "2.12.1" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version >= \"3.10\"" files = [ {file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"}, {file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"}, @@ -1058,6 +1098,7 @@ 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"}, @@ -1080,6 +1121,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"}, @@ -1098,6 +1140,7 @@ version = "3.6.1" description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"}, {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"}, @@ -1118,6 +1161,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -1132,6 +1176,8 @@ version = "5.3.1" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version >= \"3.10\"" files = [ {file = "redis-5.3.1-py3-none-any.whl", hash = "sha256:dc1909bd24669cc31b5f67a039700b16ec30571096c5f1f0d9d2324bff31af97"}, {file = "redis-5.3.1.tar.gz", hash = "sha256:ca49577a531ea64039b5a36db3d6cd1a0c7a60c34124d46924a45b956e8cf14c"}, @@ -1151,6 +1197,7 @@ version = "0.11.5" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "ruff-0.11.5-py3-none-linux_armv6l.whl", hash = "sha256:2561294e108eb648e50f210671cc56aee590fb6167b594144401532138c66c7b"}, {file = "ruff-0.11.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac12884b9e005c12d0bd121f56ccf8033e1614f736f766c118ad60780882a077"}, @@ -1178,6 +1225,7 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -1189,6 +1237,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1200,6 +1249,8 @@ version = "2.4.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, @@ -1256,6 +1307,7 @@ version = "2.9.0.20241206" description = "Typing stubs for python-dateutil" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "types_python_dateutil-2.9.0.20241206-py3-none-any.whl", hash = "sha256:e248a4bc70a486d3e3ec84d0dc30eec3a5f979d6e7ee4123ae043eedbb987f53"}, {file = "types_python_dateutil-2.9.0.20241206.tar.gz", hash = "sha256:18f493414c26ffba692a72369fea7a154c502646301ebfe3d56a04b3767284cb"}, @@ -1267,6 +1319,7 @@ version = "4.13.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.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, @@ -1278,6 +1331,8 @@ version = "25.0.0" description = "A WebAssembly runtime powered by Wasmtime" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"datastream\" or extra == \"rulesengine\"" files = [ {file = "wasmtime-25.0.0-py3-none-any.whl", hash = "sha256:22aa59fc6e01deec8a6703046f82466090d5811096a3bb5c169907e36c842af1"}, {file = "wasmtime-25.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:13e9a718e9d580c1738782cc19f4dcb9fb068f7e51778ea621fd664f4433525b"}, @@ -1300,6 +1355,8 @@ version = "13.1" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"datastream\"" files = [ {file = "websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee"}, {file = "websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7"}, @@ -1395,6 +1452,8 @@ version = "1.22.0" description = "Yet another URL library" optional = false python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version >= \"3.9\"" files = [ {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e"}, {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f"}, @@ -1539,17 +1598,19 @@ version = "3.20.2" description = "Backport of pathlib-compatible object wrapper for zip files" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "(extra == \"datastream\" or extra == \"rulesengine\") and python_version < \"3.10\"" files = [ {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [extras] @@ -1557,6 +1618,6 @@ datastream = ["wasmtime", "websockets"] rulesengine = ["wasmtime"] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = "^3.8" content-hash = "7f8cd32f6180e0e5180d40c7376b4ba77af39b5647c1c2e9b93f49aea5eb3ac3" diff --git a/pyproject.toml b/pyproject.toml index add73e5..931bbaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ dynamic = ["version"] [tool.poetry] name = "schematichq" -version = "1.2.1" +version = "1.2.2" description = "" readme = "README.md" authors = [] diff --git a/src/schematic/core/client_wrapper.py b/src/schematic/core/client_wrapper.py index faf28e2..a9df43a 100644 --- a/src/schematic/core/client_wrapper.py +++ b/src/schematic/core/client_wrapper.py @@ -27,12 +27,12 @@ def get_headers(self) -> typing.Dict[str, str]: import platform headers: typing.Dict[str, str] = { - "User-Agent": "schematichq/1.2.1", + "User-Agent": "schematichq/1.2.2", "X-Fern-Language": "Python", "X-Fern-Runtime": f"python/{platform.python_version()}", "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", "X-Fern-SDK-Name": "schematichq", - "X-Fern-SDK-Version": "1.2.1", + "X-Fern-SDK-Version": "1.2.2", **(self.get_custom_headers() or {}), } headers["X-Schematic-Api-Key"] = self.api_key diff --git a/src/schematic/datastream/datastream_client.py b/src/schematic/datastream/datastream_client.py index e4a66c5..9ed1a72 100644 --- a/src/schematic/datastream/datastream_client.py +++ b/src/schematic/datastream/datastream_client.py @@ -197,6 +197,12 @@ def __init__(self, options: DataStreamClientOptions) -> None: self._pending_user: Dict[str, List[asyncio.Future[RulesengineUser]]] = {} self._pending_flags: Optional[asyncio.Future[bool]] = None + # Per-entity locks serialize read-modify-write on the cache so that the + # WS handler and external callers (e.g. update_company_metrics) can't + # lose each other's updates when they interleave at await points. + self._company_locks: Dict[str, asyncio.Lock] = {} + self._user_locks: Dict[str, asyncio.Lock] = {} + # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @@ -434,16 +440,23 @@ async def update_company_metrics(self, keys: Dict[str, str], event: str, quantit if company is None: return - updated = company.model_copy(deep=True) - if updated.metrics: - new_metrics = [ - metric.model_copy(update={"value": (metric.value or 0) + quantity}) - if metric.event_subtype == event else metric - for metric in updated.metrics - ] - updated = updated.model_copy(update={"metrics": new_metrics}) + async with self._get_company_lock(company.id): + # Re-fetch under lock — a concurrent partial may have changed state + # between the unlocked lookup above and lock acquisition. + company = await self._get_company_from_cache(keys) + if company is None: + return + + updated = company.model_copy(deep=True) + if updated.metrics: + new_metrics = [ + metric.model_copy(update={"value": (metric.value or 0) + quantity}) + if metric.event_subtype == event else metric + for metric in updated.metrics + ] + updated = updated.model_copy(update={"metrics": new_metrics}) - await self._cache_company(updated) + await self._cache_company(updated) async def close(self) -> None: """Gracefully close the datastream client.""" @@ -518,75 +531,88 @@ async def _handle_company_message(self, message: DataStreamResp) -> None: return # For partial updates, look up the cached entity by envelope entity_id - # and merge the wrapped data payload into it. + # and merge the wrapped data payload into it. The read-merge-write must + # run under a per-id lock so concurrent calls (e.g. update_company_metrics) + # can't read stale state and overwrite our merge. if message.message_type == MessageType.PARTIAL.value: entity_id = message.entity_id if not entity_id: self._logger.warning("Partial company message missing entity_id") return - rk = self._resource_id_cache_key(_PREFIX_COMPANY, entity_id) - raw_existing = await self._company_cache.get(rk) - if raw_existing is None: - self._logger.warning("Partial company update for unknown entity: %s", entity_id) - return + async with self._get_company_lock(entity_id): + rk = self._resource_id_cache_key(_PREFIX_COMPANY, entity_id) + raw_existing = await self._company_cache.get(rk) + if raw_existing is None: + self._logger.warning("Partial company update for unknown entity: %s", entity_id) + return - existing = _validate(RulesengineCompany, raw_existing) - partial_data = raw if isinstance(raw, dict) else raw.model_dump() - try: - company = partial_company(existing, partial_data) - except Exception as exc: - self._logger.error("Failed to merge partial company: %s", exc) - return - else: - company = _validate(RulesengineCompany, raw) + existing = _validate(RulesengineCompany, raw_existing) + partial_data = raw if isinstance(raw, dict) else raw.model_dump() + try: + company = partial_company(existing, partial_data) + except Exception as exc: + self._logger.error("Failed to merge partial company: %s", exc) + return - if message.message_type == MessageType.DELETE.value: - await self._delete_entity( - company.id, company.keys, _PREFIX_COMPANY, self._company_cache, self._company_key_cache, - ) + await self._cache_company(company) + self._notify_pending_company(company.keys or {}, company) return - await self._cache_company(company) - self._notify_pending_company(company.keys or {}, company) + company = _validate(RulesengineCompany, raw) + + async with self._get_company_lock(company.id): + if message.message_type == MessageType.DELETE.value: + await self._delete_entity( + company.id, company.keys, _PREFIX_COMPANY, self._company_cache, self._company_key_cache, + ) + return + + await self._cache_company(company) + self._notify_pending_company(company.keys or {}, company) async def _handle_user_message(self, message: DataStreamResp) -> None: raw = message.data if not raw: return - # For partial updates, look up the cached entity by envelope entity_id - # and merge the wrapped data payload into it. + # See _handle_company_message — same per-id locking rationale. if message.message_type == MessageType.PARTIAL.value: entity_id = message.entity_id if not entity_id: self._logger.warning("Partial user message missing entity_id") return - rk = self._resource_id_cache_key(_PREFIX_USER, entity_id) - raw_existing = await self._user_cache.get(rk) - if raw_existing is None: - self._logger.warning("Partial user update for unknown entity: %s", entity_id) - return + async with self._get_user_lock(entity_id): + rk = self._resource_id_cache_key(_PREFIX_USER, entity_id) + raw_existing = await self._user_cache.get(rk) + if raw_existing is None: + self._logger.warning("Partial user update for unknown entity: %s", entity_id) + return - existing = _validate(RulesengineUser, raw_existing) - partial_data = raw if isinstance(raw, dict) else raw.model_dump() - try: - user = partial_user(existing, partial_data) - except Exception as exc: - self._logger.error("Failed to merge partial user: %s", exc) - return - else: - user = _validate(RulesengineUser, raw) + existing = _validate(RulesengineUser, raw_existing) + partial_data = raw if isinstance(raw, dict) else raw.model_dump() + try: + user = partial_user(existing, partial_data) + except Exception as exc: + self._logger.error("Failed to merge partial user: %s", exc) + return - if message.message_type == MessageType.DELETE.value: - await self._delete_entity( - user.id, user.keys, _PREFIX_USER, self._user_cache, self._user_key_cache, - ) + await self._cache_user(user) + self._notify_pending_user(user.keys or {}, user) return - await self._cache_user(user) - self._notify_pending_user(user.keys or {}, user) + user = _validate(RulesengineUser, raw) + + async with self._get_user_lock(user.id): + if message.message_type == MessageType.DELETE.value: + await self._delete_entity( + user.id, user.keys, _PREFIX_USER, self._user_cache, self._user_key_cache, + ) + return + + await self._cache_user(user) + self._notify_pending_user(user.keys or {}, user) async def _handle_flags_message(self, message: DataStreamResp) -> None: raw_flags = message.data @@ -936,6 +962,20 @@ def _cleanup_pending_user(self, cache_keys: List[str], future: asyncio.Future[An if not futures: del self._pending_user[ck] + def _get_company_lock(self, company_id: str) -> asyncio.Lock: + lock = self._company_locks.get(company_id) + if lock is None: + lock = asyncio.Lock() + self._company_locks[company_id] = lock + return lock + + def _get_user_lock(self, user_id: str) -> asyncio.Lock: + lock = self._user_locks.get(user_id) + if lock is None: + lock = asyncio.Lock() + self._user_locks[user_id] = lock + return lock + def _clear_pending_requests(self) -> None: for futures in self._pending_company.values(): for fut in futures: diff --git a/src/schematic/datastream/merge.py b/src/schematic/datastream/merge.py index 667dfad..2717675 100644 --- a/src/schematic/datastream/merge.py +++ b/src/schematic/datastream/merge.py @@ -12,8 +12,17 @@ def partial_company(existing: RulesengineCompany, partial: Dict[str, Any]) -> Ru Only fields present in `partial` are applied. Maps (keys, credit_balances) merge additively. Metrics are upserted by (event_subtype, period, month_reset). All other fields replace the existing value. The original is not mutated. + + Partials don't carry refreshed entitlements, so when their derived fields + change in another part of the company we sync them here to match server + behavior: + - credit_remaining ← credit_balances[credit_id] + - usage ← metric value matching (event_name, metric_period, month_reset) + Both are skipped when the partial also sends entitlements wholesale. """ updates: Dict[str, Any] = {} + updated_balances: Optional[Dict[str, float]] = None + metrics_updated = False for key, value in partial.items(): if key == "keys": @@ -24,13 +33,42 @@ def partial_company(existing: RulesengineCompany, partial: Dict[str, Any]) -> Ru merged_cb = dict(existing.credit_balances) if existing.credit_balances else {} merged_cb.update(value or {}) updates["credit_balances"] = merged_cb + updated_balances = value or {} elif key == "metrics": incoming = _parse_metrics(value) existing_metrics = [m.model_dump() for m in (existing.metrics or [])] updates["metrics"] = _upsert_metrics(existing_metrics, incoming) + metrics_updated = True else: updates[key] = value + if (updated_balances or metrics_updated) and "entitlements" not in updates: + existing_ents = existing.entitlements or [] + if existing_ents: + metrics_lookup: Dict[Tuple[str, str, str], int] = {} + if metrics_updated: + for m in updates["metrics"]: + if isinstance(m, dict): + metrics_lookup[( + m.get("event_subtype", ""), + m.get("period", "") or "", + m.get("month_reset", "") or "", + )] = m.get("value", 0) + + new_ents = [] + for ent in existing_ents: + ent_dict = ent.model_dump() + if updated_balances and ent.credit_id and ent.credit_id in updated_balances: + ent_dict["credit_remaining"] = updated_balances[ent.credit_id] + if metrics_lookup and ent.event_name: + period = ent.metric_period or "all_time" + month_reset = ent.month_reset or "first_of_month" + matched = metrics_lookup.get((ent.event_name, period, month_reset)) + if matched is not None: + ent_dict["usage"] = matched + new_ents.append(ent_dict) + updates["entitlements"] = new_ents + base = existing.model_dump() base.update(updates) return RulesengineCompany.model_validate(base) diff --git a/tests/datastream/test_datastream_client.py b/tests/datastream/test_datastream_client.py index 1533798..1b14e6d 100644 --- a/tests/datastream/test_datastream_client.py +++ b/tests/datastream/test_datastream_client.py @@ -545,6 +545,103 @@ async def test_partial_user_merges_keys( assert user.keys == {"email": "orig@test.com", "slack_id": "U123"} +class TestDataStreamClientConcurrentUpdates: + """Per-company locks must keep concurrent RMW callers from losing each + other's writes. The classic scenario: a host app calls + update_company_metrics in response to a track event, and the server + sends a partial with new credit_balances at the same moment.""" + + @pytest.fixture + def cache_with_delay(self) -> "_DelayedCache": + return _DelayedCache(get_delay=0.01) + + async def _seed_company(self, client: DataStreamClient, credit: float = 100.0) -> None: + await client._handle_message(DataStreamResp( + data={ + "id": "co_race", + "keys": {"slug": "race"}, + "account_id": "acc_1", + "environment_id": "env_1", + "billing_product_ids": [], + "credit_balances": {"credit-1": credit}, + "metrics": [ + {"event_subtype": "credits_used", "period": "all_time", "month_reset": "first_of_month", + "value": 0, "account_id": "acc_1", "company_id": "co_race", "environment_id": "env_1", + "created_at": "2026-01-01T00:00:00Z"}, + ], + "plan_ids": [], + "plan_version_ids": [], + "rules": [], + "traits": [], + }, + entity_type=EntityType.COMPANY.value, + message_type=MessageType.FULL.value, + )) + + async def test_concurrent_partial_and_metric_update_both_applied( + self, logger: logging.Logger, cache_with_delay: "_DelayedCache", + ) -> None: + client = DataStreamClient(DataStreamClientOptions( + api_key="test-key", + logger=logger, + replicator_mode=True, + company_cache=cache_with_delay, + company_lookup_cache=cache_with_delay, + user_cache=cache_with_delay, + user_lookup_cache=cache_with_delay, + flag_cache=cache_with_delay, + )) + + await self._seed_company(client, credit=100.0) + + # Concurrent: server-side partial updates credit_balances, host-side + # track event updates metrics. Both run interleaved at await points. + partial_msg = client._handle_message(DataStreamResp( + data={"credit_balances": {"credit-1": 25.0}}, + entity_id="co_race", + entity_type=EntityType.COMPANY.value, + message_type=MessageType.PARTIAL.value, + )) + metric_update = client.update_company_metrics( + {"slug": "race"}, "credits_used", 5, + ) + + await asyncio.gather(partial_msg, metric_update) + + company = await client._get_company_from_cache({"slug": "race"}) + assert company is not None + # Without per-id locks, one of these would be lost. + assert company.credit_balances == {"credit-1": 25.0} + assert company.metrics[0].value == 5 + + def test_lock_object_is_reused_across_calls(self, logger: logging.Logger) -> None: + client = DataStreamClient(DataStreamClientOptions( + api_key="test-key", + base_url="https://api.schematichq.com", + logger=logger, + )) + a = client._get_company_lock("co_1") + b = client._get_company_lock("co_1") + c = client._get_company_lock("co_2") + assert a is b + assert a is not c + + +class _DelayedCache(MockCacheProvider): + """Cache that sleeps inside get() so the asyncio scheduler interleaves + concurrent callers — needed to actually exercise read-modify-write races.""" + + def __init__(self, get_delay: float = 0.0) -> None: + super().__init__() + self._get_delay = get_delay + + async def get(self, key: str) -> Optional[Any]: + value = self._store.get(key) + if self._get_delay: + await asyncio.sleep(self._get_delay) + return value + + class TestDataStreamClientDeepCopy: """Spec test #12: Deep copy prevents mutation of cached entities.""" diff --git a/tests/datastream/test_merge.py b/tests/datastream/test_merge.py index 0c6197d..12ea1d4 100644 --- a/tests/datastream/test_merge.py +++ b/tests/datastream/test_merge.py @@ -45,11 +45,26 @@ def _make_rule(rule_id: str) -> RulesengineRule: ) -def _make_entitlement(feature_id: str, feature_key: str) -> RulesengineFeatureEntitlement: +def _make_entitlement( + feature_id: str, + feature_key: str, + credit_id: str | None = None, + credit_remaining: float | None = None, + event_name: str | None = None, + metric_period: str | None = None, + month_reset: str | None = None, + usage: int | None = None, +) -> RulesengineFeatureEntitlement: return RulesengineFeatureEntitlement( feature_id=feature_id, feature_key=feature_key, value_type="boolean", + credit_id=credit_id, + credit_remaining=credit_remaining, + event_name=event_name, + metric_period=metric_period, + month_reset=month_reset, + usage=usage, ) @@ -154,6 +169,302 @@ def test_overwrites_existing_balance(self) -> None: assert merged.credit_balances == {"credit-1": 50.0} +class TestPartialCompanySyncsCreditRemaining: + """Credit-balance partials don't include refreshed entitlements, so the + SDK syncs credit_remaining locally to keep the two in step for consumers + who read it. Mirrors the server's partial-message handling.""" + + def test_credit_remaining_updated_for_matching_credit_id(self) -> None: + existing = base_company().model_copy( + update={ + "credit_balances": {"credit-1": 100.0}, + "entitlements": [ + _make_entitlement("feat-1", "f1", credit_id="credit-1", credit_remaining=100.0), + _make_entitlement("feat-2", "f2"), # no credit_id — must stay untouched + ], + } + ) + partial = {"credit_balances": {"credit-1": 25.0}} + + merged = partial_company(existing, partial) + + assert merged.credit_balances == {"credit-1": 25.0} + assert merged.entitlements is not None + assert merged.entitlements[0].credit_remaining == 25.0 + assert merged.entitlements[1].credit_remaining is None + + def test_credit_remaining_synced_across_multiple_credit_ids(self) -> None: + existing = base_company().model_copy( + update={ + "credit_balances": {"credit-1": 100.0, "credit-2": 50.0}, + "entitlements": [ + _make_entitlement("feat-1", "f1", credit_id="credit-1", credit_remaining=100.0), + _make_entitlement("feat-2", "f2", credit_id="credit-2", credit_remaining=50.0), + ], + } + ) + partial = {"credit_balances": {"credit-1": 75.0, "credit-2": 10.0}} + + merged = partial_company(existing, partial) + + assert merged.entitlements is not None + assert merged.entitlements[0].credit_remaining == 75.0 + assert merged.entitlements[1].credit_remaining == 10.0 + + def test_unmatched_entitlement_credit_id_untouched(self) -> None: + existing = base_company().model_copy( + update={ + "credit_balances": {"credit-1": 100.0, "credit-other": 999.0}, + "entitlements": [ + _make_entitlement("feat-1", "f1", credit_id="credit-other", credit_remaining=999.0), + ], + } + ) + # Partial only updates credit-1; entitlement points at credit-other and + # must keep its existing credit_remaining. + partial = {"credit_balances": {"credit-1": 25.0}} + + merged = partial_company(existing, partial) + + assert merged.entitlements is not None + assert merged.entitlements[0].credit_remaining == 999.0 + + def test_single_credit_fans_out_to_multiple_entitlements(self) -> None: + """One credit type can fund multiple features — each feature gets its + own entitlement sharing the same credit_id. A balance update for that + credit must sync credit_remaining on every entitlement that points at + it, matching the server's behavior.""" + existing = base_company().model_copy( + update={ + "credit_balances": {"credit-shared": 500.0}, + "entitlements": [ + _make_entitlement("feat-a", "feature-a", credit_id="credit-shared", credit_remaining=500.0), + _make_entitlement("feat-b", "feature-b", credit_id="credit-shared", credit_remaining=500.0), + _make_entitlement("feat-c", "feature-c", credit_id="credit-shared", credit_remaining=500.0), + _make_entitlement("feat-d", "feature-d"), # unrelated, no credit + ], + } + ) + partial = {"credit_balances": {"credit-shared": 120.0}} + + merged = partial_company(existing, partial) + + assert merged.entitlements is not None + # All three entitlements pointing at credit-shared get the new balance. + assert merged.entitlements[0].credit_remaining == 120.0 + assert merged.entitlements[1].credit_remaining == 120.0 + assert merged.entitlements[2].credit_remaining == 120.0 + # Unrelated entitlement untouched. + assert merged.entitlements[3].credit_remaining is None + + def test_skipped_when_partial_also_sends_entitlements(self) -> None: + """If the partial carries entitlements, we trust those wholesale and + don't re-derive credit_remaining from credit_balances.""" + existing = base_company().model_copy( + update={ + "credit_balances": {"credit-1": 100.0}, + "entitlements": [ + _make_entitlement("feat-1", "f1", credit_id="credit-1", credit_remaining=100.0), + ], + } + ) + partial = { + "credit_balances": {"credit-1": 25.0}, + "entitlements": [ + {"feature_id": "feat-1", "feature_key": "f1", "value_type": "boolean", + "credit_id": "credit-1", "credit_remaining": 17.0}, + ], + } + + merged = partial_company(existing, partial) + + assert merged.entitlements is not None + assert merged.entitlements[0].credit_remaining == 17.0 + + def test_no_op_when_no_entitlements_exist(self) -> None: + existing = base_company().model_copy(update={"entitlements": None}) + partial = {"credit_balances": {"credit-1": 25.0}} + + merged = partial_company(existing, partial) + + assert merged.credit_balances == {"credit-1": 25.0} + assert merged.entitlements is None + + +class TestPartialCompanySyncsEntitlementUsage: + """A partial that updates metrics doesn't carry refreshed entitlements, + so the SDK syncs entitlement.usage from the matching metric (matched on + event_subtype + period + month_reset). Mirrors the server's metric + lookup for effective entitlements.""" + + def test_usage_updated_for_event_based_entitlement(self) -> None: + existing = base_company().model_copy( + update={ + "metrics": [ + _make_metric("credits_used", "current_month", "first_of_month", 10), + ], + "entitlements": [ + _make_entitlement( + "feat-1", "f1", + event_name="credits_used", + metric_period="current_month", + month_reset="first_of_month", + usage=10, + ), + ], + } + ) + partial = { + "metrics": [ + {"event_subtype": "credits_used", "period": "current_month", "month_reset": "first_of_month", + "value": 42, "account_id": "acc-1", "company_id": "co-1", "environment_id": "env-1", + "created_at": "2026-01-01T00:00:00Z"}, + ], + } + + merged = partial_company(existing, partial) + + assert merged.entitlements is not None + assert merged.entitlements[0].usage == 42 + + def test_usage_match_requires_period_and_month_reset(self) -> None: + """The server matches metrics to entitlements on the full triple + (event_subtype, period, month_reset). A metric with a different + period must not satisfy an entitlement's lookup.""" + existing = base_company().model_copy( + update={ + "metrics": [ + _make_metric("api_calls", "all_time", "first_of_month", 100), + ], + "entitlements": [ + _make_entitlement( + "feat-1", "f1", + event_name="api_calls", + metric_period="current_month", # ← different from metric's period + month_reset="first_of_month", + usage=5, + ), + ], + } + ) + # Partial updates the all_time metric. Entitlement points at current_month + # so its usage must NOT change. + partial = { + "metrics": [ + {"event_subtype": "api_calls", "period": "all_time", "month_reset": "first_of_month", + "value": 999, "account_id": "acc-1", "company_id": "co-1", "environment_id": "env-1", + "created_at": "2026-01-01T00:00:00Z"}, + ], + } + + merged = partial_company(existing, partial) + + assert merged.entitlements is not None + assert merged.entitlements[0].usage == 5 + + def test_usage_defaults_assume_all_time_first_of_month(self) -> None: + """When entitlement period/month_reset are None, the server's metric + lookup defaults to "all_time" / "first_of_month". Our sync must + apply the same defaults so the metric is found.""" + existing = base_company().model_copy( + update={ + "metrics": [ + _make_metric("api_calls", "all_time", "first_of_month", 0), + ], + "entitlements": [ + _make_entitlement( + "feat-1", "f1", + event_name="api_calls", + # metric_period and month_reset intentionally left as None + ), + ], + } + ) + partial = { + "metrics": [ + {"event_subtype": "api_calls", "period": "all_time", "month_reset": "first_of_month", + "value": 7, "account_id": "acc-1", "company_id": "co-1", "environment_id": "env-1", + "created_at": "2026-01-01T00:00:00Z"}, + ], + } + + merged = partial_company(existing, partial) + + assert merged.entitlements is not None + assert merged.entitlements[0].usage == 7 + + def test_usage_unchanged_when_no_matching_metric_in_partial(self) -> None: + """If the partial's metric upsert leaves the entitlement's metric + untouched (different event_subtype), keep the existing usage.""" + existing = base_company().model_copy( + update={ + "metrics": [ + _make_metric("event-a", "all_time", "first_of_month", 50), + ], + "entitlements": [ + _make_entitlement( + "feat-1", "f1", + event_name="event-a", + metric_period="all_time", + month_reset="first_of_month", + usage=50, + ), + ], + } + ) + # Partial updates a different event; merged metrics still contain + # event-a unchanged so usage stays at 50. + partial = { + "metrics": [ + {"event_subtype": "event-b", "period": "all_time", "month_reset": "first_of_month", + "value": 999, "account_id": "acc-1", "company_id": "co-1", "environment_id": "env-1", + "created_at": "2026-01-01T00:00:00Z"}, + ], + } + + merged = partial_company(existing, partial) + + assert merged.entitlements is not None + # event-a is still in the merged metrics list at value 50, so the + # entitlement's usage is re-derived from there and stays 50. + assert merged.entitlements[0].usage == 50 + + def test_usage_and_credit_remaining_synced_in_one_partial(self) -> None: + """A partial can carry both credit_balances and metrics changes; both + derived fields must be applied in the same entitlements rebuild.""" + existing = base_company().model_copy( + update={ + "credit_balances": {"credit-1": 100.0}, + "metrics": [ + _make_metric("event-a", "all_time", "first_of_month", 5), + ], + "entitlements": [ + _make_entitlement( + "feat-1", "f1", + credit_id="credit-1", credit_remaining=100.0, + event_name="event-a", + metric_period="all_time", month_reset="first_of_month", + usage=5, + ), + ], + } + ) + partial = { + "credit_balances": {"credit-1": 25.0}, + "metrics": [ + {"event_subtype": "event-a", "period": "all_time", "month_reset": "first_of_month", + "value": 80, "account_id": "acc-1", "company_id": "co-1", "environment_id": "env-1", + "created_at": "2026-01-01T00:00:00Z"}, + ], + } + + merged = partial_company(existing, partial) + + assert merged.entitlements is not None + assert merged.entitlements[0].credit_remaining == 25.0 + assert merged.entitlements[0].usage == 80 + + class TestPartialCompanyUpsertsMetrics: def test_updates_matching_appends_new(self) -> None: existing = base_company().model_copy(