diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index ddd0c71..2acd366 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -26,6 +26,9 @@ jobs: run: | pip install pipenv pipenv install --dev + + - name: Lint + run: pipenv run python -m pylint switcher_client - name: Test run: pipenv run pytest -v --cov=./switcher_client --cov-report xml --cov-config=.coveragerc @@ -58,6 +61,9 @@ jobs: run: | pip install pipenv pipenv install --dev + + - name: Lint + run: pipenv run python -m pylint switcher_client - name: Test run: pipenv run pytest -v \ No newline at end of file diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 1a02ca3..ac75eda 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -33,6 +33,9 @@ jobs: run: | pip install pipenv pipenv install --dev + + - name: Lint + run: pipenv run python -m pylint switcher_client - name: Test run: pipenv run pytest -v \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index c242809..25cfc24 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,7 @@ "tests", "-s", "-v" ], "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "editor.renderWhitespace": "trailing", + "files.trimTrailingWhitespace": true } \ No newline at end of file diff --git a/Makefile b/Makefile index 0914e1f..db424ff 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,11 @@ -.PHONY: install test cover +.PHONY: install lint test cover install: pipenv install --dev +lint: + pipenv run python -m pylint switcher_client + test: pytest -v --cov=./switcher_client --cov-report xml --cov-config=.coveragerc diff --git a/Pipfile b/Pipfile index fb84f27..a699e3a 100644 --- a/Pipfile +++ b/Pipfile @@ -7,6 +7,7 @@ name = "pypi" httpx = {extras = ["http2"], version = "==0.28.1"} [dev-packages] +pylint = "==4.0.5" pytest = "==9.0.2" pytest-cov = "==7.0.0" pytest-httpx = "==0.36.0" diff --git a/Pipfile.lock b/Pipfile.lock index 6bfed54..6ed150f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "e7dea934d60f51b85aed255315884cae8252c2778e8879c426cc2d068bee1e74" + "sha256": "f3c42651763865a6a4e38b299fd1215fbbbd2d8b17c61fdae8752ecb5b59452c" }, "pipfile-spec": 6, "requires": {}, @@ -16,19 +16,19 @@ "default": { "anyio": { "hashes": [ - "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", - "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb" + "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", + "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c" ], "markers": "python_version >= '3.9'", - "version": "==4.12.0" + "version": "==4.12.1" }, "certifi": { "hashes": [ - "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", - "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316" + "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", + "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7" ], "markers": "python_version >= '3.7'", - "version": "==2025.11.12" + "version": "==2026.2.25" }, "h11": { "hashes": [ @@ -93,19 +93,27 @@ "develop": { "anyio": { "hashes": [ - "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", - "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb" + "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", + "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c" ], "markers": "python_version >= '3.9'", - "version": "==4.12.0" + "version": "==4.12.1" + }, + "astroid": { + "hashes": [ + "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", + "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0" + ], + "markers": "python_full_version >= '3.10.0'", + "version": "==4.0.4" }, "certifi": { "hashes": [ - "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", - "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316" + "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", + "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7" ], "markers": "python_version >= '3.7'", - "version": "==2025.11.12" + "version": "==2026.2.25" }, "colorama": { "hashes": [ @@ -120,101 +128,123 @@ "toml" ], "hashes": [ - "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384", - "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6", - "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60", - "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d", - "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7", - "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", - "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d", - "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b", - "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c", - "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647", - "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c", - "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a", - "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6", - "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742", - "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b", - "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f", - "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64", - "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2", - "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b", - "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87", - "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc", - "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941", - "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c", - "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb", - "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507", - "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068", - "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e", - "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434", - "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832", - "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9", - "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296", - "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339", - "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937", - "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac", - "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553", - "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455", - "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70", - "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc", - "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984", - "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c", - "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d", - "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933", - "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef", - "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13", - "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe", - "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736", - "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6", - "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360", - "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e", - "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560", - "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03", - "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a", - "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d", - "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508", - "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8", - "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12", - "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73", - "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c", - "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4", - "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92", - "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f", - "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d", - "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0", - "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc", - "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0", - "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d", - "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa", - "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211", - "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc", - "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1", - "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777", - "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d", - "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9", - "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c", - "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a", - "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07", - "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc", - "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8", - "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e", - "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3", - "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa", - "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d", - "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17", - "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b", - "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291", - "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f", - "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7", - "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e", - "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245", - "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022", - "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", - "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d" + "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", + "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", + "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", + "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", + "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", + "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", + "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", + "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", + "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", + "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", + "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", + "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", + "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", + "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", + "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", + "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", + "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", + "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", + "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", + "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", + "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", + "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", + "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", + "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", + "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", + "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", + "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", + "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", + "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", + "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", + "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", + "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", + "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", + "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", + "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", + "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", + "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", + "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", + "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", + "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", + "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", + "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", + "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", + "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", + "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", + "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", + "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", + "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", + "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", + "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", + "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", + "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", + "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", + "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", + "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", + "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", + "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", + "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", + "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", + "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", + "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", + "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", + "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", + "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", + "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", + "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", + "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", + "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", + "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", + "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", + "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", + "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", + "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", + "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", + "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", + "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", + "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", + "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", + "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", + "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", + "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", + "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", + "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", + "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", + "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", + "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", + "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", + "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", + "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", + "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", + "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", + "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", + "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", + "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", + "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", + "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", + "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", + "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", + "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", + "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", + "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", + "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", + "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", + "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", + "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", + "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0" ], "markers": "python_version >= '3.10'", - "version": "==7.12.0" + "version": "==7.13.4" + }, + "dill": { + "hashes": [ + "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", + "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa" + ], + "markers": "python_version >= '3.9'", + "version": "==0.4.1" }, "h11": { "hashes": [ @@ -259,13 +289,37 @@ "markers": "python_version >= '3.10'", "version": "==2.3.0" }, + "isort": { + "hashes": [ + "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", + "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75" + ], + "markers": "python_full_version >= '3.10.0'", + "version": "==8.0.1" + }, + "mccabe": { + "hashes": [ + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" + ], + "markers": "python_version >= '3.6'", + "version": "==0.7.0" + }, "packaging": { "hashes": [ - "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", - "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", + "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529" ], "markers": "python_version >= '3.8'", - "version": "==25.0" + "version": "==26.0" + }, + "platformdirs": { + "hashes": [ + "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", + "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868" + ], + "markers": "python_version >= '3.10'", + "version": "==4.9.4" }, "pluggy": { "hashes": [ @@ -283,6 +337,15 @@ "markers": "python_version >= '3.8'", "version": "==2.19.2" }, + "pylint": { + "hashes": [ + "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", + "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c" + ], + "index": "pypi", + "markers": "python_full_version >= '3.10.0'", + "version": "==4.0.5" + }, "pytest": { "hashes": [ "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", @@ -314,6 +377,14 @@ "editable": true, "file": "." }, + "tomlkit": { + "hashes": [ + "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", + "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064" + ], + "markers": "python_version >= '3.9'", + "version": "==0.14.0" + }, "typing-extensions": { "hashes": [ "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", diff --git a/pyproject.toml b/pyproject.toml index 1d55701..7d72af9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,4 +30,21 @@ include = ["switcher_client*"] [tool.setuptools.dynamic] version = { attr = "switcher_client.version.__version__" } readme = { file = ["README.md"], content-type = "text/markdown" } -dependencies = { file = ["requirements.txt"] } \ No newline at end of file +dependencies = { file = ["requirements.txt"] } + +[tool.pylint.main] +source-roots = ["switcher_client"] + +[tool.pylint.messages_control] +max-line-length = 120 +disable = [ + "missing-module-docstring", + "missing-class-docstring", + "missing-function-docstring", + # Relative imports are valid within the installed package; pylint reports + # false positives when invoked from the project root without install. + "relative-beyond-top-level" +] + +[tool.pylint.format] +max-line-length = 120 \ No newline at end of file diff --git a/switcher_client/__init__.py b/switcher_client/__init__.py index 8cf2a6d..5ad9299 100644 --- a/switcher_client/__init__.py +++ b/switcher_client/__init__.py @@ -8,4 +8,4 @@ 'Switcher', 'ContextOptions', 'WatchSnapshotCallback', -] \ No newline at end of file +] diff --git a/switcher_client/client.py b/switcher_client/client.py index 73eb9c9..2793617 100644 --- a/switcher_client/client.py +++ b/switcher_client/client.py @@ -11,13 +11,13 @@ from .lib.utils.execution_logger import ExecutionLogger from .lib.utils.timed_match.timed_match import TimedMatch from .lib.utils import get +from .errors import SnapshpotNotFoundError from .switcher import Switcher -class SwitcherOptions: - REGEX_MAX_BLACK_LIST = 'regex_max_black_list' - REGEX_MAX_TIME_LIMIT = 'regex_max_time_limit' - SNAPSHOT_AUTO_UPDATE_INTERVAL = 'snapshot_auto_update_interval' - SILENT_MODE = 'silent_mode' +REGEX_MAX_BLACK_LIST = 'regex_max_black_list' +REGEX_MAX_TIME_LIMIT = 'regex_max_time_limit' +SNAPSHOT_AUTO_UPDATE_INTERVAL = 'snapshot_auto_update_interval' +SILENT_MODE = 'silent_mode' class Client: _context: Context = Context.empty() @@ -25,16 +25,17 @@ class Client: _snapshot_auto_updater: SnapshotAutoUpdater = SnapshotAutoUpdater() _snapshot_watcher: SnapshotWatcher = SnapshotWatcher() + # pylint: disable=too-many-arguments @staticmethod - def build_context( - domain: str, + def build_context(*, + domain: str, url: Optional[str] = None, api_key: Optional[str] = None, component: Optional[str] = None, environment: Optional[str] = DEFAULT_ENVIRONMENT, options = ContextOptions()): - """ - Build the context for the client + """ + Build the context for the client :param domain: Domain name :param url:Switcher-API URL @@ -42,9 +43,14 @@ def build_context( :param component: Application/component name :param environment: Environment name :param options: Optional parameters - + """ - Client._context = Context(domain, url, api_key, component, environment, options) + Client._context = Context( + domain=domain, url=url, + api_key=api_key, + component=component, + environment=environment, + options=options) # Default values GlobalSnapshot.clear() @@ -59,12 +65,12 @@ def build_context( @staticmethod def _build_options(options: ContextOptions): options_handler = { - SwitcherOptions.SNAPSHOT_AUTO_UPDATE_INTERVAL: lambda: Client.schedule_snapshot_auto_update(), - SwitcherOptions.SILENT_MODE: lambda: Client._init_silent_mode(get(options.silent_mode, '')), - SwitcherOptions.REGEX_MAX_BLACK_LIST: lambda: TimedMatch.set_max_blacklisted(options.regex_max_black_list), - SwitcherOptions.REGEX_MAX_TIME_LIMIT: lambda: TimedMatch.set_max_time_limit(options.regex_max_time_limit) + SNAPSHOT_AUTO_UPDATE_INTERVAL: Client.schedule_snapshot_auto_update, + SILENT_MODE: lambda: Client._init_silent_mode(get(options.silent_mode, '')), + REGEX_MAX_BLACK_LIST: lambda: TimedMatch.set_max_blacklisted(options.regex_max_black_list), + REGEX_MAX_TIME_LIMIT: lambda: TimedMatch.set_max_time_limit(options.regex_max_time_limit) } - + for option_key, handler in options_handler.items(): if hasattr(options, option_key) and getattr(options, option_key) is not None: handler() @@ -78,7 +84,7 @@ def _init_silent_mode(silent_mode: str): @staticmethod def get_switcher(key: Optional[str] = None) -> Switcher: - """ + """ Creates a new instance of Switcher. Provide a key if you want to persist the instance. """ @@ -87,7 +93,7 @@ def get_switcher(key: Optional[str] = None) -> Switcher: if persisted_switcher is not None: return persisted_switcher - + switcher = Switcher(Client._context, key_value) \ .restrict_relay(Client._context.options.restrict_relay) @@ -95,7 +101,7 @@ def get_switcher(key: Optional[str] = None) -> Switcher: Client._switcher[key_value] = switcher return switcher - + @staticmethod def load_snapshot(options: Optional[LoadSnapshotOptions] = None) -> int: @@ -111,7 +117,7 @@ def load_snapshot(options: Optional[LoadSnapshotOptions] = None) -> int: Client.check_snapshot() return Client.snapshot_version() - + @staticmethod def check_snapshot(): """ Verifies if the current snapshot file is updated @@ -129,18 +135,18 @@ def check_snapshot(): if snapshot is not None: if Client._context.options.snapshot_location is not None: save_snapshot( - snapshot=snapshot, - snapshot_location=get(Client._context.options.snapshot_location, ''), + snapshot=snapshot, + snapshot_location=get(Client._context.options.snapshot_location, ''), environment=get(Client._context.environment, DEFAULT_ENVIRONMENT) ) GlobalSnapshot.init(snapshot) return True - + return False - + @staticmethod - def schedule_snapshot_auto_update(interval: Optional[int] = None, + def schedule_snapshot_auto_update(interval: Optional[int] = None, callback: Optional[Callable[[Optional[Exception], bool], None]] = None): """ Schedule Snapshot auto update """ callback = get(callback, lambda *_: None) @@ -155,7 +161,7 @@ def schedule_snapshot_auto_update(interval: Optional[int] = None, check_snapshot=Client.check_snapshot, callback=callback ) - + @staticmethod def terminate_snapshot_auto_update(): """ Terminate Snapshot auto update """ @@ -166,10 +172,11 @@ def watch_snapshot(callback: Optional[WatchSnapshotCallback] = None) -> None: """ Watch snapshot file for changes and invoke callbacks on result """ callback = get(callback, WatchSnapshotCallback()) snapshot_location = Client._context.options.snapshot_location - + if snapshot_location is None: - return callback.reject(Exception("Snapshot location is not defined in the context options")) - + callback.reject(SnapshpotNotFoundError("Snapshot location is not defined in the context options")) + return + environment = get(Client._context.environment, DEFAULT_ENVIRONMENT) Client._snapshot_watcher.watch_snapshot(snapshot_location, environment, callback) @@ -185,9 +192,9 @@ def snapshot_version() -> int: if snapshot is None: return 0 - + return snapshot.domain.version - + @staticmethod def check_switchers(switcher_keys: list[str]) -> None: """ Verifies if switchers are properly configured """ @@ -199,7 +206,7 @@ def check_switchers(switcher_keys: list[str]) -> None: @staticmethod def get_execution(switcher: Switcher) -> ExecutionLogger: """Retrieve execution log given a switcher""" - return ExecutionLogger.get_execution(switcher._key, switcher._input) + return ExecutionLogger.get_execution(switcher.key, switcher.inputs) @staticmethod def clear_logger() -> None: @@ -226,11 +233,11 @@ def subscribe_notify_error(callback: Callable[[Exception], None]) -> None: @staticmethod def _is_check_snapshot_available(fetch_remote = False) -> bool: return Client.snapshot_version() == 0 and (fetch_remote or not Client._context.options.local) - + @staticmethod def _check_switchers_remote(switcher_keys: list[str]) -> None: RemoteAuth.auth() Remote.check_switchers( - token=GlobalAuth.get_token(), - switcher_keys=switcher_keys, - context=Client._context) \ No newline at end of file + token=GlobalAuth.get_token(), + switcher_keys=switcher_keys, + context=Client._context) diff --git a/switcher_client/errors/__init__.py b/switcher_client/errors/__init__.py index 87aaab5..bc1f715 100644 --- a/switcher_client/errors/__init__.py +++ b/switcher_client/errors/__init__.py @@ -4,12 +4,10 @@ def __init__(self, message): super().__init__(self.message) class RemoteAuthError(RemoteError): - def __init__(self, message): - super().__init__(message) + pass class RemoteCriteriaError(RemoteError): - def __init__(self, message): - super().__init__(message) + pass class RemoteSwitcherError(RemoteError): def __init__(self, not_found: list): @@ -25,6 +23,11 @@ def __init__(self, message): self.message = message super().__init__(self.message) +class SnapshpotNotFoundError(Exception): + def __init__(self, message): + self.message = message + super().__init__(self.message) + __all__ = [ 'RemoteError', 'RemoteAuthError', @@ -32,4 +35,5 @@ def __init__(self, message): 'RemoteSwitcherError', 'LocalSwitcherError', 'LocalCriteriaError', -] \ No newline at end of file + 'SnapshpotNotFoundError' +] diff --git a/switcher_client/lib/__init__.py b/switcher_client/lib/__init__.py index 63036cc..036a4eb 100644 --- a/switcher_client/lib/__init__.py +++ b/switcher_client/lib/__init__.py @@ -4,4 +4,4 @@ __all__ = [ 'RemoteAuth', 'ResultDetail', -] \ No newline at end of file +] diff --git a/switcher_client/lib/globals/__init__.py b/switcher_client/lib/globals/__init__.py index 38cf4f2..20e5069 100644 --- a/switcher_client/lib/globals/__init__.py +++ b/switcher_client/lib/globals/__init__.py @@ -7,4 +7,4 @@ "Context", "ContextOptions", "RetryOptions", -] \ No newline at end of file +] diff --git a/switcher_client/lib/globals/global_auth.py b/switcher_client/lib/globals/global_auth.py index 393a6de..f01ca85 100644 --- a/switcher_client/lib/globals/global_auth.py +++ b/switcher_client/lib/globals/global_auth.py @@ -10,15 +10,15 @@ def init(): @staticmethod def get_token(): return GlobalAuth.__token - + @staticmethod def get_exp(): return GlobalAuth.__exp - + @staticmethod def set_token(token: str): GlobalAuth.__token = token @staticmethod def set_exp(exp: str): - GlobalAuth.__exp = exp \ No newline at end of file + GlobalAuth.__exp = exp diff --git a/switcher_client/lib/globals/global_context.py b/switcher_client/lib/globals/global_context.py index e383724..37dadfe 100644 --- a/switcher_client/lib/globals/global_context.py +++ b/switcher_client/lib/globals/global_context.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from typing import Optional DEFAULT_ENVIRONMENT = 'default' @@ -8,29 +9,44 @@ DEFAULT_REGEX_MAX_BLACKLISTED = 100 DEFAULT_REGEX_MAX_TIME_LIMIT = 3000 +@dataclass class ContextOptions: """ - :param local: When enabled it will use the local snapshot (file or in-memory). If not set, it will use the remote API - :param logger: When enabled it allows inspecting the result details with Client.get_execution(switcher). If not set, it will not log the result details - :param freeze: This option prevents the execution of background cache update when using throttle. Use Client.clear_logger() to reset the in-memory cache if snapshot are renewed - :param snapshot_location: The location of the snapshot file. If not set, it will use the in-memory snapshot - :param snapshot_auto_update_interval: The interval in milliseconds to auto-update the snapshot. If not set, it will not auto-update the snapshot - :param silent_mode: When defined it will switch to local during the specified time before it switches back to remote, e.g. 5s (s: seconds - m: minutes - h: hours) - :param throttle_max_workers: The maximum number of workers to use for background refresh when throttle is enabled. If None, the default value is based on the number of CPUs. Default is None - :param regex_max_black_list: The maximum number of blacklisted regex inputs. If not set, it will use the default value of 100 - :param regex_max_time_limit: The maximum time limit in milliseconds for regex matching. If not set, it will use the default value of 3000 ms - :param restrict_relay: When enabled it will restrict the use of relay when local is enabled. Default is True - :param cert_path: The path to the SSL certificate file for secure connections. If not set, it will use the default system certificates + :param local: When enabled it will use the local snapshot (file or in-memory). + If not set, it will use the remote API + :param logger: When enabled it allows inspecting the result details with + Client.get_execution(switcher). If not set, it will not log the result details + :param freeze: This option prevents the execution of background cache update when + using throttle. Use Client.clear_logger() to reset the in-memory cache if + snapshot are renewed + :param snapshot_location: The location of the snapshot file. + If not set, it will use the in-memory snapshot + :param snapshot_auto_update_interval: The interval in milliseconds to auto-update + the snapshot. If not set, it will not auto-update the snapshot + :param silent_mode: When defined it will switch to local during the specified time + before it switches back to remote, e.g. 5s (s: seconds - m: minutes - h: hours) + :param throttle_max_workers: The maximum number of workers to use for background + refresh when throttle is enabled. If None, the default value is based on the + number of CPUs. Default is None + :param regex_max_black_list: The maximum number of blacklisted regex inputs. + If not set, it will use the default value of 100 + :param regex_max_time_limit: The maximum time limit in milliseconds for regex + matching. If not set, it will use the default value of 3000 ms + :param restrict_relay: When enabled it will restrict the use of relay when local + is enabled. Default is True + :param cert_path: The path to the SSL certificate file for secure connections. + If not set, it will use the default system certificates """ - - def __init__(self, + # pylint: disable=too-many-arguments + # pylint: disable=too-many-instance-attributes + def __init__(self, *, local: bool = DEFAULT_LOCAL, logger: bool = DEFAULT_LOGGER, freeze: bool = DEFAULT_FREEZE, regex_max_black_list: int = DEFAULT_REGEX_MAX_BLACKLISTED, regex_max_time_limit: int = DEFAULT_REGEX_MAX_TIME_LIMIT, restrict_relay: bool = DEFAULT_RESTRICT_RELAY, - snapshot_location: Optional[str] = None, + snapshot_location: Optional[str] = None, snapshot_auto_update_interval: Optional[int] = None, silent_mode: Optional[str] = None, throttle_max_workers: Optional[int] = None, @@ -47,9 +63,19 @@ def __init__(self, self.regex_max_time_limit = regex_max_time_limit self.cert_path = cert_path +@dataclass class Context: - def __init__(self, - domain: Optional[str], url: Optional[str], api_key: Optional[str], component: Optional[str], + """ + :param domain: Your Switcher domain name + :param url: Switcher-API endpoint URL. + :param api_key: The API key for authentication + :param component: Your application identifier + :param environment: Target environment name. If not set, it will use the default environment + :param options: The context options + """ + # pylint: disable=too-many-arguments + def __init__(self, *, + domain: Optional[str], url: Optional[str], api_key: Optional[str], component: Optional[str], environment: Optional[str], options: ContextOptions = ContextOptions()): self.domain = domain self.url = url @@ -57,7 +83,7 @@ def __init__(self, self.component = component self.environment = environment self.options = options - + @classmethod def empty(cls): return cls( @@ -67,4 +93,4 @@ def empty(cls): component='', environment=DEFAULT_ENVIRONMENT, options=ContextOptions() - ) \ No newline at end of file + ) diff --git a/switcher_client/lib/globals/global_retry.py b/switcher_client/lib/globals/global_retry.py index 1cbf036..154fbde 100644 --- a/switcher_client/lib/globals/global_retry.py +++ b/switcher_client/lib/globals/global_retry.py @@ -1,3 +1,6 @@ +from dataclasses import dataclass + +@dataclass class RetryOptions: def __init__(self, retry_time: int, retry_duration_in: str): """ @@ -5,4 +8,4 @@ def __init__(self, retry_time: int, retry_duration_in: str): :param retry_duration_in: The duration to wait between retries (e.g. '5s' (s: seconds - m: minutes - h: hours)) """ self.retry_time = retry_time - self.retry_duration_in = retry_duration_in \ No newline at end of file + self.retry_duration_in = retry_duration_in diff --git a/switcher_client/lib/globals/global_snapshot.py b/switcher_client/lib/globals/global_snapshot.py index 88aed97..c003528 100644 --- a/switcher_client/lib/globals/global_snapshot.py +++ b/switcher_client/lib/globals/global_snapshot.py @@ -1,7 +1,9 @@ +from dataclasses import dataclass + from ...lib.types import Snapshot class GlobalSnapshot: - + @staticmethod def init(snapshot: Snapshot | None): GlobalSnapshot.snapshotStore = snapshot @@ -13,8 +15,9 @@ def clear(): @staticmethod def snapshot() -> Snapshot | None: return GlobalSnapshot.snapshotStore - + +@dataclass class LoadSnapshotOptions: def __init__(self, fetch_remote: bool = False, watch_snapshot: bool = False): self.fetch_remote = fetch_remote - self.watch_snapshot = watch_snapshot \ No newline at end of file + self.watch_snapshot = watch_snapshot diff --git a/switcher_client/lib/remote.py b/switcher_client/lib/remote.py index 71ad8a5..b54e042 100644 --- a/switcher_client/lib/remote.py +++ b/switcher_client/lib/remote.py @@ -1,9 +1,9 @@ import json import ssl -import httpx - from typing import Optional +import httpx + from ..errors import RemoteAuthError, RemoteError, RemoteCriteriaError, RemoteSwitcherError from ..lib.globals.global_context import DEFAULT_ENVIRONMENT, Context from ..lib.types import ResultDetail @@ -24,25 +24,25 @@ def auth(context: Context): 'switcher-api-key': context.api_key, 'Content-Type': 'application/json', }) - + if response.status_code == 200: return response.json()['token'], response.json()['exp'] raise RemoteAuthError('Invalid API key') - + @staticmethod def check_api_health(context: Context) -> bool: url = f'{context.url}/check' response = Remote._do_get(context, url) - + return response.status_code == 200 - + @staticmethod def check_criteria(token: Optional[str], context: Context, switcher: SwitcherData) -> ResultDetail: - url = f'{context.url}/criteria?showReason={str(switcher._show_details).lower()}&key={switcher._key}' - entry = get_entry(switcher._input) + url = f'{context.url}/criteria?showReason={str(switcher.show_details).lower()}&key={switcher.key}' + entry = get_entry(switcher.inputs) response = Remote._do_post(context, url, { 'entry': [e.to_dict() for e in entry] }, Remote._get_header(token)) - + if response.status_code == 200: json_response = response.json() return ResultDetail( @@ -52,7 +52,7 @@ def check_criteria(token: Optional[str], context: Context, switcher: SwitcherDat ) raise RemoteCriteriaError(f'[check_criteria] failed with status: {response.status_code}') - + @staticmethod def check_switchers(token: Optional[str], switcher_keys: list[str], context: Context) -> None: url = f'{context.url}/criteria/switchers_check' @@ -60,21 +60,21 @@ def check_switchers(token: Optional[str], switcher_keys: list[str], context: Con if response.status_code != 200: raise RemoteError(f'[check_switchers] failed with status: {response.status_code}') - + not_found = response.json().get('not_found', []) if len(not_found) > 0: raise RemoteSwitcherError(not_found) - + @staticmethod def check_snapshot_version(token: Optional[str], context: Context, snapshot_version: int) -> bool: url = f'{context.url}/criteria/snapshot_check/{snapshot_version}' response = Remote._do_get(context, url, Remote._get_header(token)) - + if response.status_code == 200: return response.json().get('status', False) raise RemoteError(f'[check_snapshot_version] failed with status: {response.status_code}') - + @staticmethod def resolve_snapshot(token: Optional[str], context: Context) -> str | None: domain = get(context.domain, '') @@ -102,9 +102,9 @@ def resolve_snapshot(token: Optional[str], context: Context) -> str | None: if response.status_code == 200: return json.dumps(response.json().get('data', {}), indent=4) - + raise RemoteError(f'[resolve_snapshot] failed with status: {response.status_code}') - + @classmethod def _get_client(cls, context: Context) -> httpx.Client: if cls._client is None or cls._client.is_closed: @@ -136,14 +136,14 @@ def _get_header(token: Optional[str]) -> dict: 'Authorization': f'Bearer {token}', 'Content-Type': 'application/json', } - + @staticmethod def _get_context(context: Context) -> bool | ssl.SSLContext: cert_path = context.options.cert_path if cert_path is None: return True - + ctx = ssl.create_default_context() ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.load_cert_chain(certfile=cert_path) - return ctx \ No newline at end of file + return ctx diff --git a/switcher_client/lib/remote_auth.py b/switcher_client/lib/remote_auth.py index a4be950..79d723c 100644 --- a/switcher_client/lib/remote_auth.py +++ b/switcher_client/lib/remote_auth.py @@ -32,20 +32,20 @@ def auth(): def check_health(): if GlobalAuth.get_token() != 'SILENT': return - + if RemoteAuth.is_token_expired(): RemoteAuth.update_silent_token() if Remote.check_api_health(RemoteAuth.__context): RemoteAuth.auth() - + @staticmethod def is_token_expired() -> bool: exp = GlobalAuth.get_exp() if exp is None: return True - + return float(exp) < time() - + @staticmethod def update_silent_token(): expiration_time = DateMoment(datetime.now()).add( @@ -55,7 +55,7 @@ def update_silent_token(): GlobalAuth.set_token('SILENT') GlobalAuth.set_exp(str(round(expiration_time))) - + @staticmethod def is_valid(): required_fields = [ diff --git a/switcher_client/lib/resolver.py b/switcher_client/lib/resolver.py index 82db442..6304085 100644 --- a/switcher_client/lib/resolver.py +++ b/switcher_client/lib/resolver.py @@ -6,6 +6,7 @@ from ..errors import LocalCriteriaError from ..switcher_data import SwitcherData +# pylint: disable=too-few-public-methods class Resolver: @staticmethod @@ -15,7 +16,7 @@ def check_criteria(snapshot: Snapshot | None, switcher: SwitcherData) -> ResultD raise LocalCriteriaError("Snapshot not loaded. Try to use 'Client.load_snapshot()'") return Resolver._check_domain(snapshot.domain, switcher) - + @staticmethod def _check_domain(domain: Domain, switcher: SwitcherData) -> ResultDetail: """ Checks if the domain is activated and proceeds to check groups. """ @@ -23,11 +24,11 @@ def _check_domain(domain: Domain, switcher: SwitcherData) -> ResultDetail: return ResultDetail.disabled("Domain is disabled") return Resolver._check_group(domain.group, switcher) - + @staticmethod def _check_group(groups: list[Group], switcher: SwitcherData) -> ResultDetail: """ Finds the correct config in the groups and checks it. """ - key = switcher._key + key = switcher.key for group in groups: config_found = next((c for c in group.config if c.key == key), None) @@ -35,25 +36,25 @@ def _check_group(groups: list[Group], switcher: SwitcherData) -> ResultDetail: if config_found is not None: if group.activated is False: return ResultDetail.disabled("Group disabled") - + return Resolver._check_config(config_found, switcher) - + raise LocalCriteriaError(f"Config with key '{key}' not found in the snapshot") - + @staticmethod def _check_config(config: Config, switcher: SwitcherData) -> ResultDetail: """ Checks if the config is activated and proceeds to check strategies. """ if config.activated is False: return ResultDetail.disabled("Config disabled") - - if Resolver._has_relay_enabled(config) and switcher._restrict_relay: + + if Resolver._has_relay_enabled(config) and switcher.is_restrict_relay(): return ResultDetail.disabled("Config has relay enabled") - + if config.strategies is not None and len(config.strategies) > 0: - return Resolver._check_strategy(config.strategies, switcher._input) + return Resolver._check_strategy(config.strategies, switcher.inputs) return ResultDetail.success() - + @staticmethod def _check_strategy(strategy_configs: list[StrategyConfig], inputs: list[list[str]]) -> ResultDetail: """ Checks each strategy configuration against the provided inputs. """ @@ -68,24 +69,24 @@ def _check_strategy(strategy_configs: list[StrategyConfig], inputs: list[list[st return strategy_result return ResultDetail.success() - + @staticmethod def _check_strategy_config(strategy_config: StrategyConfig, entry: list[Entry]) -> Optional[ResultDetail]: """ Checks a single strategy configuration against the provided entries. """ if len(entry) == 0: return ResultDetail.disabled(f"Strategy '{strategy_config.strategy}' did not receive any input") - + strategy_entry = [e for e in entry if e.strategy == strategy_config.strategy] if not Resolver._is_strategy_fulfilled(strategy_entry, strategy_config): return ResultDetail.disabled(f"Strategy '{strategy_config.strategy}' does not agree") - + return None - + @staticmethod def _is_strategy_fulfilled(strategy_entry: list[Entry], strategy_config: StrategyConfig) -> bool: """ Determines if the strategy conditions are fulfilled based on the entries and configuration. """ return len(strategy_entry) > 0 and process_operation(strategy_config, strategy_entry[0].input) is True - + @staticmethod def _has_relay_enabled(config: Config) -> bool: """ Checks if the config has relay enabled. """ diff --git a/switcher_client/lib/snapshot.py b/switcher_client/lib/snapshot.py index e9e09d8..429e72c 100644 --- a/switcher_client/lib/snapshot.py +++ b/switcher_client/lib/snapshot.py @@ -29,13 +29,13 @@ class OperationsType(Enum): HAS_ONE = "HAS_ONE" HAS_ALL = "HAS_ALL" -def process_operation(strategy_config: StrategyConfig, input_value: str) -> Optional[bool]: +def process_operation(strategy_config: StrategyConfig, input_value: str) -> Optional[bool]: # pylint: disable=too-many-return-statements """Process the operation based on strategy configuration and input value.""" strategy = strategy_config.strategy operation = strategy_config.operation values = strategy_config.values - + match strategy: case StrategiesType.VALUE.value: return _process_value(operation, values, input_value) @@ -51,7 +51,7 @@ def process_operation(strategy_config: StrategyConfig, input_value: str) -> Opti return _process_network(operation, values, input_value) case StrategiesType.REGEX.value: return _process_regex(operation, values, input_value) - + def _process_value(operation: str, values: list, input_value: str) -> Optional[bool]: """ Process VALUE strategy operations.""" @@ -64,17 +64,17 @@ def _process_value(operation: str, values: list, input_value: str) -> Optional[b return input_value in values case OperationsType.NOT_EQUAL.value: return input_value not in values - -def _process_numeric(operation: str, values: list, input_value: str) -> Optional[bool]: + +def _process_numeric(operation: str, values: list, input_value: str) -> Optional[bool]: # pylint: disable=too-many-return-statements """ Process NUMERIC strategy operations.""" try: numeric_input = float(input_value) except ValueError: return None - + numeric_values = [float(v) for v in values] - + match operation: case OperationsType.EXIST.value: return numeric_input in numeric_values @@ -89,7 +89,7 @@ def _process_numeric(operation: str, values: list, input_value: str) -> Optional case OperationsType.LOWER.value: return any(numeric_input < v for v in numeric_values) case OperationsType.BETWEEN.value: - return numeric_input >= numeric_values[0] and numeric_input <= numeric_values[1] + return numeric_values[0] <= numeric_input <= numeric_values[1] def _process_date(operation: str, values: list, input_value: str) -> Optional[bool]: """ Process DATE strategy operations.""" @@ -107,7 +107,7 @@ def _process_date(operation: str, values: list, input_value: str) -> Optional[bo return any(date_input >= v for v in date_values) case OperationsType.BETWEEN.value: return date_values[0] <= date_input <= date_values[1] - + def _process_time(operation: str, values: list, input_value: str) -> Optional[bool]: """ Process TIME strategy operations.""" @@ -124,16 +124,16 @@ def _process_time(operation: str, values: list, input_value: str) -> Optional[bo return any(time_input >= v for v in time_values) case OperationsType.BETWEEN.value: return time_values[0] <= time_input <= time_values[1] - + def _process_payload(operation: str, values: list, input_value: str) -> Optional[bool]: """ Process PAYLOAD strategy operations.""" input_json = parse_json(input_value) if input_json is None: return False - + keys = payload_reader(input_json) - + match operation: case OperationsType.HAS_ONE.value: return any(value in keys for value in values) @@ -144,13 +144,13 @@ def _process_network(operation: str, values: list, input_value: str) -> Optional """Process NETWORK strategy operations.""" cidr_regex = re.compile(r'^(\d{1,3}\.){3}\d{1,3}(\/(\d|[1-2]\d|3[0-2]))$') - + match operation: case OperationsType.EXIST.value: return _process_network_exist(input_value, values, cidr_regex) case OperationsType.NOT_EXIST.value: return _process_network_not_exist(input_value, values, cidr_regex) - + return False def _process_network_exist(input_value: str, values: list, cidr_regex) -> bool: @@ -164,7 +164,7 @@ def _process_network_exist(input_value: str, values: list, cidr_regex) -> bool: else: if input_value in values: return True - + return False def _process_network_not_exist(input_value: str, values: list, cidr_regex) -> bool: @@ -179,7 +179,7 @@ def _process_network_not_exist(input_value: str, values: list, cidr_regex) -> bo else: if input_value in values: result.append(element) - + return len(result) == 0 def _process_regex(operation: str, values: list, input_value: str) -> Optional[bool]: @@ -196,7 +196,7 @@ def _process_regex(operation: str, values: list, input_value: str) -> Optional[b case OperationsType.NOT_EQUAL.value: result = TimedMatch.try_match(values, input_value, use_fullmatch=True) return not result - + def _parse_datetime(date_str: str): """Parse datetime string that can be either date-only or datetime format.""" @@ -206,5 +206,5 @@ def _parse_datetime(date_str: str): return datetime.strptime(date_str, fmt) except ValueError: continue - - raise ValueError(f"Unable to parse date: {date_str}") \ No newline at end of file + + raise ValueError(f"Unable to parse date: {date_str}") diff --git a/switcher_client/lib/snapshot_auto_updater.py b/switcher_client/lib/snapshot_auto_updater.py index 94c8a92..55fae5f 100644 --- a/switcher_client/lib/snapshot_auto_updater.py +++ b/switcher_client/lib/snapshot_auto_updater.py @@ -10,10 +10,13 @@ def __init__(self): self._timer_thread: Optional[threading.Thread] = None self._stop_event: Optional[threading.Event] = None - def schedule(self, interval: int, check_snapshot: Callable[[], bool], callback: Callable[[Optional[Exception], bool], None]) -> None: + def schedule( + self, interval: int, + check_snapshot: Callable[[], bool], + callback: Callable[[Optional[Exception], bool], None]) -> None: """ Schedule periodic snapshot updates in a background thread. - + :param interval: Update interval in seconds :param check_snapshot: Function that checks and updates snapshot, returns True if updated :param callback: Callback function called with (error, updated) after each check @@ -43,7 +46,10 @@ def terminate(self) -> None: self._timer_thread = None self._stop_event = None - def _update_worker(self, interval: int, check_snapshot: Callable[[], bool], callback: Callable[[Optional[Exception], bool], None]) -> None: + def _update_worker( + self, interval: int, + check_snapshot: Callable[[], bool], + callback: Callable[[Optional[Exception], bool], None]) -> None: stop_event = self._stop_event time.sleep(interval) # delay start @@ -51,7 +57,7 @@ def _update_worker(self, interval: int, check_snapshot: Callable[[], bool], call try: updated = check_snapshot() callback(None, updated) - except Exception as error: + except Exception as error: # pylint: disable=broad-exception-caught callback(error, False) - stop_event.wait(interval) \ No newline at end of file + stop_event.wait(interval) diff --git a/switcher_client/lib/snapshot_loader.py b/switcher_client/lib/snapshot_loader.py index 1c17fc7..1ed98ed 100644 --- a/switcher_client/lib/snapshot_loader.py +++ b/switcher_client/lib/snapshot_loader.py @@ -22,11 +22,11 @@ def load_domain(snapshot_location: str, environment: str): if snapshot_location: os.makedirs(snapshot_location, exist_ok=True) - with open(snapshot_file, 'w') as file: + with open(snapshot_file, 'w', encoding='utf-8') as file: json.dump(json_data, file, indent=4) - + elif os.path.exists(snapshot_file): - with open(snapshot_file, 'r') as file: + with open(snapshot_file, 'r', encoding='utf-8') as file: json_data = json.load(file) snapshot = Snapshot(json_data.get('domain', {})) @@ -40,15 +40,15 @@ def validate_snapshot( """ Validate the snapshot data """ status = Remote.check_snapshot_version( - token=GlobalAuth.get_token(), + token=GlobalAuth.get_token(), context=context, snapshot_version=snapshot_version) - + if not status: snapshot_str = Remote.resolve_snapshot(GlobalAuth.get_token(), context) graphql_response = json.loads(snapshot_str or '{}') return Snapshot(graphql_response.get('domain', '{}')) - + return None def save_snapshot(snapshot: Snapshot, snapshot_location: str, environment: str): @@ -56,7 +56,7 @@ def save_snapshot(snapshot: Snapshot, snapshot_location: str, environment: str): os.makedirs(snapshot_location, exist_ok=True) snapshot_file = f"{snapshot_location}/{environment}.json" - with open(snapshot_file, 'w') as file: + with open(snapshot_file, 'w', encoding='utf-8') as file: json.dump(snapshot.to_dict(), file, indent=4) def check_switchers(snapshot: Snapshot | None, switcher_keys: list[str]) -> None: @@ -74,4 +74,4 @@ def check_switchers(snapshot: Snapshot | None, switcher_keys: list[str]) -> None not_found.append(switcher) if not_found: - raise LocalSwitcherError(not_found) \ No newline at end of file + raise LocalSwitcherError(not_found) diff --git a/switcher_client/lib/snapshot_watcher.py b/switcher_client/lib/snapshot_watcher.py index 5ab8bf2..e0ac65d 100644 --- a/switcher_client/lib/snapshot_watcher.py +++ b/switcher_client/lib/snapshot_watcher.py @@ -63,5 +63,5 @@ def _on_modify_snapshot(self, snapshot_location: str, environment: str, callback snapshot = load_domain(snapshot_location, environment) GlobalSnapshot.init(snapshot) callback.success() - except Exception as error: - callback.reject(error) \ No newline at end of file + except Exception as error: # pylint: disable=broad-exception-caught + callback.reject(error) diff --git a/switcher_client/lib/types.py b/switcher_client/lib/types.py index 48ac02a..e7f7563 100644 --- a/switcher_client/lib/types.py +++ b/switcher_client/lib/types.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from typing import Optional class ResultDetail: @@ -25,6 +26,7 @@ def to_dict(self) -> dict: 'metadata': self.metadata } +@dataclass class Domain: def __init__(self): self.name: str @@ -32,12 +34,14 @@ def __init__(self): self.activated: bool self.group: list[Group] +@dataclass class Group: def __init__(self): self.name: str self.activated: bool self.config: list[Config] +@dataclass class Config: def __init__(self): self.key: str @@ -45,6 +49,7 @@ def __init__(self): self.strategies: Optional[list[StrategyConfig]] = None self.relay: Optional[Relay] = None +@dataclass class StrategyConfig: def __init__(self): self.strategy: str @@ -52,7 +57,9 @@ def __init__(self): self.operation: str self.values: list[str] +@dataclass class Entry: + # pylint: disable=redefined-builtin def __init__(self, strategy: str, input: str): self.strategy = strategy self.input = input @@ -63,11 +70,13 @@ def to_dict(self) -> dict: 'input': self.input } +@dataclass class Relay: def __init__(self): self.type: str self.activated: bool +@dataclass class Snapshot: def __init__(self, json_data: dict): self._original_data = json_data @@ -80,12 +89,12 @@ def _parse_domain(self, domain_data: dict) -> Domain: domain.name = domain_data.get('name', '') domain.activated = domain_data.get('activated', False) domain.version = domain_data.get('version', 0) - + if 'group' in domain_data and domain_data['group']: domain.group = [] for group_data in domain_data['group']: domain.group.append(self._parse_group(group_data)) - + return domain def _parse_group(self, group_data: dict) -> Group: @@ -99,7 +108,7 @@ def _parse_group(self, group_data: dict) -> Group: group.config = [] for config_data in group_data['config']: group.config.append(self._parse_config(config_data)) - + return group def _parse_config(self, config_data: dict) -> Config: @@ -113,10 +122,10 @@ def _parse_config(self, config_data: dict) -> Config: config.strategies = [] for strategy_data in config_data['strategies']: config.strategies.append(self._parse_strategy(strategy_data)) - + if 'relay' in config_data and config_data['relay']: config.relay = self._parse_relay(config_data['relay']) - + return config def _parse_strategy(self, strategy_data: dict) -> StrategyConfig: @@ -127,7 +136,7 @@ def _parse_strategy(self, strategy_data: dict) -> StrategyConfig: strategy.activated = strategy_data.get('activated', False) strategy.operation = strategy_data.get('operation', '') strategy.values = strategy_data.get('values', []) - + return strategy def _parse_relay(self, relay_data: dict) -> Relay: @@ -141,5 +150,5 @@ def _parse_relay(self, relay_data: dict) -> Relay: def to_dict(self) -> dict: """ Convert Snapshot back to dictionary format for JSON serialization """ - - return {'domain': self._original_data} \ No newline at end of file + + return {'domain': self._original_data} diff --git a/switcher_client/lib/utils/__init__.py b/switcher_client/lib/utils/__init__.py index 276b687..08541eb 100644 --- a/switcher_client/lib/utils/__init__.py +++ b/switcher_client/lib/utils/__init__.py @@ -5,12 +5,13 @@ def get(value, default_value): """ Return value if not None, otherwise return default_value """ return value if value is not None else default_value +# pylint: disable=redefined-builtin def get_entry(input: list) -> list[Entry]: """ Prepare entry dictionary from input strategy handling """ entry: list[Entry] = [] for strategy_type, input_value in input: entry.append(Entry(strategy_type, input_value)) - + return entry __all__ = [ diff --git a/switcher_client/lib/utils/date_moment.py b/switcher_client/lib/utils/date_moment.py index c1b20cd..3513b69 100644 --- a/switcher_client/lib/utils/date_moment.py +++ b/switcher_client/lib/utils/date_moment.py @@ -4,16 +4,16 @@ class DateMoment: """ A utility class for date and time manipulation, similar to moment.js functionality. """ - + def __init__(self, date: datetime): self.date = date - + def get_date(self) -> datetime: return self.date - + def add(self, amount: int, unit: str) -> 'DateMoment': unit_lower = unit.lower() - + if unit_lower == 's': self.date += timedelta(seconds=amount) elif unit_lower == 'm': @@ -22,5 +22,5 @@ def add(self, amount: int, unit: str) -> 'DateMoment': self.date += timedelta(hours=amount) else: raise ValueError(f"Unit {unit} not compatible - try [s, m or h]") - - return self \ No newline at end of file + + return self diff --git a/switcher_client/lib/utils/execution_logger.py b/switcher_client/lib/utils/execution_logger.py index 86a6577..1962daa 100644 --- a/switcher_client/lib/utils/execution_logger.py +++ b/switcher_client/lib/utils/execution_logger.py @@ -1,3 +1,4 @@ +# pylint: disable=redefined-builtin from typing import Optional, Callable, List from ...lib.types import ResultDetail @@ -7,26 +8,23 @@ class ExecutionLogger: """It keeps track of latest execution results.""" - + _callback_error: Optional[Callable[[Exception], None]] = None - + def __init__(self): self.key: Optional[str] = None self.input: Optional[List[List[str]]] = None self.response: ResultDetail = ResultDetail(result=False, reason=None, metadata=None) - + @staticmethod def add(response: ResultDetail, key: str, input: Optional[List[List[str]]] = None) -> None: """Add new execution result""" - global _logger - # Remove existing execution with same key and input - for index in range(len(_logger)): - log = _logger[index] + for index, log in enumerate(_logger): if ExecutionLogger._has_execution(log, key, input): _logger.pop(index) break - + # Create new execution log entry new_log = ExecutionLogger() new_log.key = key @@ -36,63 +34,63 @@ def add(response: ResultDetail, key: str, input: Optional[List[List[str]]] = Non reason=response.reason, metadata={**(response.metadata or {}), 'cached': True} ) - + _logger.append(new_log) - + @staticmethod def get_execution(key: str, input: Optional[List[List[str]]] = None) -> 'ExecutionLogger': """Retrieve a specific result given a key and an input""" - global _logger - for log in _logger: if ExecutionLogger._has_execution(log, key, input): return log - + return ExecutionLogger() - + @staticmethod def get_by_key(key: str) -> List['ExecutionLogger']: """Retrieve results given a switcher key""" - global _logger - return [log for log in _logger if log.key == key] - + @staticmethod def clear_logger() -> None: """Clear all results""" - global _logger _logger.clear() + @classmethod + def callback_error(cls) -> Optional[Callable[[Exception], None]]: + """Returns the subscribed error callback.""" + return cls._callback_error + @staticmethod def subscribe_notify_error(callback: Callable[[Exception], None]) -> None: """Subscribe to notify when an asynchronous error is thrown.""" ExecutionLogger._callback_error = callback - + @staticmethod def _has_execution(log: 'ExecutionLogger', key: str, input: Optional[List[List[str]]]) -> bool: """Check if log matches the given key and input""" return log.key == key and ExecutionLogger._check_strategy_inputs(log.input, input) - + @staticmethod def _check_strategy_inputs(logger_inputs: Optional[List[List[str]]], inputs: Optional[List[List[str]]]) -> bool: """Check if strategy inputs match between logger and current inputs""" if not logger_inputs: return not inputs or len(inputs) == 0 - + if not inputs: return False - + for strategy_input in logger_inputs: if len(strategy_input) >= 2: strategy, input_value = strategy_input[0], strategy_input[1] # Find matching strategy and input in current inputs found = any( - len(current_input) >= 2 and - current_input[0] == strategy and + len(current_input) >= 2 and + current_input[0] == strategy and current_input[1] == input_value for current_input in inputs ) if not found: return False - + return True diff --git a/switcher_client/lib/utils/ipcidr.py b/switcher_client/lib/utils/ipcidr.py index 932e725..04a7ce0 100644 --- a/switcher_client/lib/utils/ipcidr.py +++ b/switcher_client/lib/utils/ipcidr.py @@ -1,22 +1,23 @@ +# pylint: disable=too-few-public-methods class IPCIDR: """Class for checking if an IP address is within a CIDR range.""" - + def __init__(self, cidr: str) -> None: """ Initialize IPCIDR with a CIDR string. - + Args: cidr: CIDR notation string (e.g., '192.168.1.0/24') """ self.cidr = cidr - + def _ip4_to_int(self, ip: str) -> int: """ Convert an IPv4 address string to a 32-bit integer. - + Args: ip: IPv4 address string (e.g., '192.168.1.1') - + Returns: 32-bit integer representation of the IP address """ @@ -25,21 +26,21 @@ def _ip4_to_int(self, ip: str) -> int: for octet in octets: result = (result << 8) + int(octet) return result & 0xFFFFFFFF # Ensure 32-bit unsigned integer - + def is_ip4_in_cidr(self, ip: str) -> bool: """ Check if an IPv4 address is within the CIDR range. - + Args: ip: IPv4 address string to check - + Returns: True if the IP is within the CIDR range, False otherwise """ cidr_parts = self.cidr.split('/') range_ip = cidr_parts[0] bits = int(cidr_parts[1]) if len(cidr_parts) > 1 else 32 - + mask = ~(2 ** (32 - bits) - 1) & 0xFFFFFFFF - - return (self._ip4_to_int(ip) & mask) == (self._ip4_to_int(range_ip) & mask) \ No newline at end of file + + return (self._ip4_to_int(ip) & mask) == (self._ip4_to_int(range_ip) & mask) diff --git a/switcher_client/lib/utils/payload_reader.py b/switcher_client/lib/utils/payload_reader.py index c61f07e..40a3761 100644 --- a/switcher_client/lib/utils/payload_reader.py +++ b/switcher_client/lib/utils/payload_reader.py @@ -4,7 +4,7 @@ def payload_reader(payload: Any) -> List[str]: """Extract all field keys from a JSON payload structure. - + This function recursively traverses a JSON structure and returns all possible field paths in dot notation. """ @@ -13,21 +13,21 @@ def payload_reader(payload: Any) -> List[str]: parsed = parse_json(payload) if parsed is not None: payload = parsed - + # If payload is a list/array, recursively process each element if isinstance(payload, list): result = [] for item in payload: result.extend(payload_reader(item)) return result - + # If payload is not a dict, return empty list (primitive values don't have keys) if not isinstance(payload, dict): return [] - + # Process dictionary keys result = [] - + for field in payload.keys(): result.append(field) nested_fields = payload_reader(payload[field]) diff --git a/switcher_client/lib/utils/timed_match/__init__.py b/switcher_client/lib/utils/timed_match/__init__.py index e42995b..b693250 100644 --- a/switcher_client/lib/utils/timed_match/__init__.py +++ b/switcher_client/lib/utils/timed_match/__init__.py @@ -1,3 +1,3 @@ from .timed_match import TimedMatch -__all__ = ['TimedMatch'] \ No newline at end of file +__all__ = ['TimedMatch'] diff --git a/switcher_client/lib/utils/timed_match/timed_match.py b/switcher_client/lib/utils/timed_match/timed_match.py index 01e597c..ed3ae1c 100644 --- a/switcher_client/lib/utils/timed_match/timed_match.py +++ b/switcher_client/lib/utils/timed_match/timed_match.py @@ -16,16 +16,16 @@ class Blacklist: class TimedMatch: """ This class provides regex match operations with timeout-based ReDoS protection. - + Operations are executed in isolated processes with configurable timeouts. Processes that exceed the timeout are terminated, preventing ReDoS attacks. Failed operations are cached in a blacklist to avoid repeated resource usage. """ - + _blacklisted: List[Blacklist] = [] _max_blacklisted: int = DEFAULT_REGEX_MAX_BLACKLISTED _max_time_limit: float = DEFAULT_REGEX_MAX_TIME_LIMIT / 1000.0 # Convert to seconds - + # Persistent worker management _worker_process: Optional[multiprocessing.Process] = None _task_queue: Optional[multiprocessing.Queue] = None @@ -34,28 +34,28 @@ class TimedMatch: _task_counter: int = 0 _worker_needs_restart: bool = False _old_workers_to_cleanup: List[multiprocessing.Process] = [] - + @classmethod def initialize_worker(cls) -> bool: """ Initialize the persistent worker process for regex matching. - + Creates a new worker process with communication queues. If a worker already exists, it will be terminated before creating a new one. - + Returns: True if worker was successfully initialized, False otherwise """ # Terminate existing worker if any cls.terminate_worker() - + # Create multiprocessing context cls._worker_ctx = multiprocessing.get_context('spawn') - + # Create communication queues cls._task_queue = cls._worker_ctx.Queue() cls._result_queue = cls._worker_ctx.Queue() - + # Create and start worker process cls._worker_process = cls._worker_ctx.Process( target=persistent_regex_worker, @@ -63,17 +63,17 @@ def initialize_worker(cls) -> bool: ) if cls._worker_process: cls._worker_process.start() - + # Reset task counter cls._task_counter = 0 - + return cls._worker_process is not None and cls._worker_process.is_alive() - + @classmethod def terminate_worker(cls) -> None: """ Terminate all worker processes (current and old ones). - + Sends a shutdown signal to workers and forcefully terminates them if needed. Cleans up all worker-related resources. """ @@ -81,12 +81,12 @@ def terminate_worker(cls) -> None: # Terminate current worker if cls._worker_process and cls._worker_process.is_alive(): cls._graceful_shutdown() - + # Terminate all old workers waiting for cleanup cls._terminate_all_old_workers() finally: cls._cleanup_resources() - + @classmethod def _graceful_shutdown(cls) -> None: """Attempt graceful shutdown of worker process.""" @@ -98,7 +98,7 @@ def _graceful_shutdown(cls) -> None: cls._task_queue.put(shutdown_task, timeout=1.0) if cls._worker_process: cls._worker_process.join(timeout=2.0) - + @classmethod def _cleanup_resources(cls) -> None: """Clean up all worker-related resources.""" @@ -109,43 +109,43 @@ def _cleanup_resources(cls) -> None: cls._task_counter = 0 cls._worker_needs_restart = False cls._old_workers_to_cleanup.clear() - + @classmethod def try_match(cls, patterns: List[str], input_value: str, use_fullmatch: bool = False) -> bool: """ Executes regex matching operation with timeout protection. - + The operation runs in an isolated process with timeout protection to prevent runaway regex operations that could lead to ReDoS attacks. - + Failed operations (timeouts, errors) are automatically added to a blacklist to prevent repeated attempts with the same problematic patterns. - + Args: patterns: Array of regular expression patterns to test against the input input_value: The input string to match against the regex patterns use_fullmatch: If True, uses re.fullmatch; if False, uses re.search - + Returns: True if any of the regex patterns match the input, false otherwise """ if cls._is_blacklisted(patterns, input_value): return False - + return cls._safe_match(patterns, input_value, use_fullmatch) - + @classmethod def _safe_match(cls, patterns: List[str], input_value: str, use_fullmatch: bool) -> bool: """ Run regex match with timeout protection using persistent worker.""" task_id = cls._create_and_send_task(patterns, input_value, use_fullmatch) return cls._wait_for_result(task_id, patterns, input_value) - + @classmethod def _create_and_send_task(cls, patterns: List[str], input_value: str, use_fullmatch: bool) -> str: """Create task and send to worker.""" cls._task_counter += 1 task_id = f"task_{cls._task_counter}_{time.time()}" - + task = WorkerTask( task_type=TaskType.MATCH, patterns=patterns, @@ -153,11 +153,11 @@ def _create_and_send_task(cls, patterns: List[str], input_value: str, use_fullma use_fullmatch=use_fullmatch, task_id=task_id ) - + if cls._task_queue: cls._task_queue.put(task, timeout=1.0) return task_id - + @classmethod def _wait_for_result(cls, task_id: str, patterns: List[str], input_value: str) -> bool: """Wait for result from worker with timeout.""" @@ -168,62 +168,61 @@ def _wait_for_result(cls, task_id: str, patterns: List[str], input_value: str) - result = cls._result_queue.get(timeout=0.1) if result.task_id == task_id: return cls._process_worker_result(result, patterns, input_value) - except Exception: + except Exception: # pylint: disable=broad-exception-caught continue - + # Timeout occurred - start new worker immediately and defer cleanup of old one cls._replace_worker_immediately() cls._add_to_blacklist(patterns, input_value) return False - + @classmethod def _process_worker_result(cls, result: WorkerResult, patterns: List[str], input_value: str) -> bool: """Process result from worker.""" if result.success: return result.result if result.result is not None else False - else: - cls._add_to_blacklist(patterns, input_value) - return False - + cls._add_to_blacklist(patterns, input_value) + return False + @classmethod def _is_blacklisted(cls, patterns: List[str], input_value: str) -> bool: for blacklisted in cls._blacklisted: # Check if input can contain same segment that could fail matching - if (blacklisted.input_value in input_value or input_value in blacklisted.input_value): + if blacklisted.input_value in input_value or input_value in blacklisted.input_value: # Check if any of the patterns match (regex order should not affect) matching_patterns = [p for p in patterns if p in blacklisted.patterns] if matching_patterns: return True return False - + @classmethod def _add_to_blacklist(cls, patterns: List[str], input_value: str) -> None: # Maintain blacklist size limit if len(cls._blacklisted) >= cls._max_blacklisted: cls._blacklisted.pop(0) # Remove oldest entry - + cls._blacklisted.append(Blacklist( patterns=patterns.copy(), input_value=input_value )) - + @classmethod def _replace_worker_immediately(cls) -> None: """Replace worker immediately without waiting for cleanup.""" # Move current worker to cleanup list if it exists if cls._worker_process: cls._old_workers_to_cleanup.append(cls._worker_process) - + # Clear current worker references (but don't cleanup yet) cls._worker_process = None cls._task_queue = None cls._result_queue = None cls._worker_ctx = None cls._task_counter = 0 - + # Initialize new worker immediately cls.initialize_worker() - + @classmethod def _terminate_all_old_workers(cls) -> None: """Forcefully terminate all old workers synchronously.""" @@ -231,17 +230,17 @@ def _terminate_all_old_workers(cls) -> None: if worker and worker.is_alive(): worker.terminate() worker.join(timeout=1.0) - + cls._old_workers_to_cleanup.clear() - + @classmethod def clear_blacklist(cls) -> None: cls._blacklisted.clear() - + @classmethod def set_max_blacklisted(cls, value: int) -> None: cls._max_blacklisted = value - + @classmethod def set_max_time_limit(cls, value: int) -> None: - cls._max_time_limit = value / 1000.0 # Convert to seconds \ No newline at end of file + cls._max_time_limit = value / 1000.0 # Convert to seconds diff --git a/switcher_client/lib/utils/timed_match/worker.py b/switcher_client/lib/utils/timed_match/worker.py index 1e900e8..73103bf 100644 --- a/switcher_client/lib/utils/timed_match/worker.py +++ b/switcher_client/lib/utils/timed_match/worker.py @@ -30,10 +30,10 @@ class WorkerResult: def persistent_regex_worker(task_queue: multiprocessing.Queue, result_queue: multiprocessing.Queue): """ Persistent worker function that processes regex matching tasks in a loop. - + This worker runs continuously, processing tasks from the task queue until it receives a shutdown signal or encounters an error. - + Args: task_queue: Queue to receive WorkerTask objects result_queue: Queue to send WorkerResult objects back to main process @@ -42,32 +42,29 @@ def persistent_regex_worker(task_queue: multiprocessing.Queue, result_queue: mul while True: try: task = task_queue.get(timeout=30.0) - + if task.task_type == TaskType.SHUTDOWN: result_queue.put(WorkerResult(success=True, task_id=task.task_id)) break - elif task.task_type == TaskType.MATCH: + if task.task_type == TaskType.MATCH: result = _process_match_task(task) result_queue.put(result) - - except Exception: + + except Exception: # pylint: disable=broad-exception-caught # Timeout or other error getting task, continue continue - - except Exception: + + except Exception: # pylint: disable=broad-exception-caught # Worker process error, exit - try: - result_queue.put(WorkerResult(success=False, error="Worker process error")) - except Exception: - pass + result_queue.put(WorkerResult(success=False, error="Worker process error")) def _process_match_task(task: WorkerTask) -> WorkerResult: """ Process a regex matching task. - + Args: task: WorkerTask containing the matching parameters - + Returns: WorkerResult with the matching result """ @@ -78,7 +75,7 @@ def _process_match_task(task: WorkerTask) -> WorkerResult: error="Invalid task parameters", task_id=task.task_id ) - + match_result = False for pattern in task.patterns: if task.use_fullmatch: @@ -89,15 +86,15 @@ def _process_match_task(task: WorkerTask) -> WorkerResult: if re.search(pattern, task.input_value): match_result = True break - + return WorkerResult( - success=True, - result=match_result, + success=True, + result=match_result, task_id=task.task_id ) - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught return WorkerResult( - success=False, - error=str(e), + success=False, + error=str(e), task_id=task.task_id - ) \ No newline at end of file + ) diff --git a/switcher_client/switcher.py b/switcher_client/switcher.py index 7aa0c63..8191971 100644 --- a/switcher_client/switcher.py +++ b/switcher_client/switcher.py @@ -14,8 +14,10 @@ class Switcher(SwitcherData): def __init__(self, context: Context, key: Optional[str] = None): - super().__init__(context, key) + super().__init__(context, key) self._context = context + self._show_details: bool = False + self._next_refresh_time: int = 0 self._init_worker(context) self._validate_args(key) @@ -41,9 +43,9 @@ def is_on(self, key: Optional[str] = None) -> bool: cached_result = self._try_cached_result() if cached_result is not None: return cached_result.result - + return self._submit().result - + def is_on_with_details(self, key: Optional[str] = None) -> ResultDetail: """ Execute criteria with details """ self._validate_args(key, details=True) @@ -52,17 +54,17 @@ def is_on_with_details(self, key: Optional[str] = None) -> ResultDetail: cached_result = self._try_cached_result() if cached_result is not None: return cached_result - + return self._submit() - + def schedule_background_refresh(self): """ Schedules background refresh of the last criteria request """ now = int(datetime.now().timestamp() * 1000) - + if now > self._next_refresh_time: self._next_refresh_time = now + self._throttle_period self._background_executor.submit(self._submit) - + def _submit(self) -> ResultDetail: """ Submit criteria for execution (local or remote) """ # verify if query from snapshot @@ -77,19 +79,19 @@ def _submit(self) -> ResultDetail: return self._execute_remote_criteria() except Exception as e: self._notify_error(e) - + if self._context.options.silent_mode: RemoteAuth.update_silent_token() return self._execute_local_criteria() raise e - + def validate(self) -> 'Switcher': """ Validates client settings for remote API calls """ errors = [] RemoteAuth.is_valid() - + if not self._key: errors.append('Missing key field') @@ -99,7 +101,7 @@ def validate(self) -> 'Switcher': if errors: raise ValueError(f"Something went wrong: {', '.join(errors)}") - + return self def _validate_args(self, key: Optional[str] = None, details: Optional[bool] = None): @@ -124,13 +126,14 @@ def _try_cached_result(self) -> Optional[ResultDetail]: cached_result_logger = ExecutionLogger.get_execution(self._key, self._input) if cached_result_logger.key is not None: return cached_result_logger.response - + return None def _notify_error(self, error: Exception): """ Notify asynchronous error to the subscribed callback """ - if ExecutionLogger._callback_error: - ExecutionLogger._callback_error(error) + callback = ExecutionLogger.callback_error() + if callback: + callback(error) def _execute_remote_criteria(self): """ Execute remote criteria """ @@ -142,9 +145,9 @@ def _execute_remote_criteria(self): ExecutionLogger.add(response, self._key, self._input) return response - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught return self._get_default_result_or_raise(e) - + def _execute_local_criteria(self): """ Execute local criteria """ try: @@ -153,21 +156,21 @@ def _execute_local_criteria(self): ExecutionLogger.add(response, self._key, self._input) return response - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught return self._get_default_result_or_raise(e) - + def _can_log(self) -> bool: """ Check if logging is enabled """ return self._context.options.logger and self._key is not None - + def _has_throttle(self) -> bool: """ Check if throttle is enabled and criteria was recently executed """ return self._throttle_period != 0 - + def _get_default_result_or_raise(self, e) -> ResultDetail: """ Get default result if set, otherwise raise the error """ if self._default_result is None: raise e - + self._notify_error(e) - return ResultDetail.create(result=self._default_result, reason="Default result") \ No newline at end of file + return ResultDetail.create(result=self._default_result, reason="Default result") diff --git a/switcher_client/switcher_data.py b/switcher_client/switcher_data.py index 4d24ec5..ea1d645 100644 --- a/switcher_client/switcher_data.py +++ b/switcher_client/switcher_data.py @@ -1,11 +1,16 @@ +# pylint: disable=redefined-builtin +import json + from datetime import datetime from abc import ABCMeta from typing import Optional, Self, Union +from .lib.utils import get from .lib.globals.global_context import Context from .lib.snapshot import StrategiesType class SwitcherData(metaclass=ABCMeta): + # pylint: disable=too-many-instance-attributes def __init__(self, context: Context,key: Optional[str] = None): self._context = context self._key = key @@ -26,25 +31,24 @@ def check(self, strategy_type: str, input: str)-> Self: def check_value(self, input: str) -> Self: """ Adds VALUE_VALIDATION input for strategy validation """ return self.check(StrategiesType.VALUE.value, input) - + def check_network(self, input: str) -> Self: """ Adds NETWORK_VALIDATION input for strategy validation """ return self.check(StrategiesType.NETWORK.value, input) - + def check_regex(self, input: str) -> Self: """ Adds REGEX_VALIDATION input for strategy validation """ return self.check(StrategiesType.REGEX.value, input) - + def check_payload(self, input: Union[str, dict]) -> Self: """ Adds PAYLOAD_VALIDATION input for strategy validation """ if isinstance(input, dict): - import json payload_str = json.dumps(input) else: payload_str = input - + return self.check(StrategiesType.PAYLOAD.value, payload_str) - + def throttle(self, period: int) -> Self: """ Sets throttle period in milliseconds """ self._throttle_period = period @@ -56,7 +60,7 @@ def throttle(self, period: int) -> Self: self._context.options.logger = True return self - + def remote(self, force_remote: bool = True) -> Self: """ Force the use of the remote API when local is enabled """ if not self._context.options.local: @@ -64,18 +68,37 @@ def remote(self, force_remote: bool = True) -> Self: self._force_remote = force_remote return self - + def default_result(self, result: bool) -> Self: """ Sets the default result for the switcher """ self._default_result = result return self - + def restrict_relay(self, restrict: bool = True) -> Self: """ Allow local snapshots to ignore or require Relay verification """ self._restrict_relay = restrict return self - + def reset_inputs(self) -> Self: """ Resets all strategy inputs """ self._input = [] - return self \ No newline at end of file + return self + + @property + def inputs(self) -> list[list[str]]: + """ Gets all strategy inputs """ + return self._input + + @property + def key(self) -> str: + """ Gets the switcher key """ + return get(self._key, '') + + @property + def show_details(self) -> bool: + """ Gets the show_details setting """ + return self._show_details + + def is_restrict_relay(self) -> bool: + """ Gets the restrict_relay setting """ + return self._restrict_relay diff --git a/switcher_client/version.py b/switcher_client/version.py index b3c06d4..f102a9c 100644 --- a/switcher_client/version.py +++ b/switcher_client/version.py @@ -1 +1 @@ -__version__ = "0.0.1" \ No newline at end of file +__version__ = "0.0.1"