From 76bb8b3c1d25c46809178394742562a8a0f86aa6 Mon Sep 17 00:00:00 2001 From: ZhymabekRoman Date: Fri, 8 Nov 2024 15:04:32 +0500 Subject: [PATCH] feat(services): integrate pre-commit hooks and enhance Medium API configuration --- freedium-library/.pre-commit-config.yaml | 15 ++ .../{src/freedium_library => lab}/lab.py | 0 freedium-library/pdm.lock | 233 +++++++++++++++++- freedium-library/pyproject.toml | 7 + .../src/freedium_library/services/base.py | 7 +- .../freedium_library/services/medium/api.py | 87 ++++++- .../services/medium/config.py | 10 + .../services/medium/container.py | 13 +- .../services/medium/medium.py | 6 +- .../services/medium/static/medium_domains.txt | 34 +++ .../services/medium/static/test_static.py | 58 +++++ .../services/medium/validators.py | 33 ++- .../services/medium/validators_url_test.py | 4 +- .../src/freedium_library/utils/__init__.py | 4 + .../src/freedium_library/utils/hash.py | 14 ++ .../src/freedium_library/utils/http/client.py | 3 +- .../src/freedium_library/utils/json.py | 130 ++++++++++ .../src/freedium_library/utils/json_test.py | 117 +++++++++ .../src/freedium_library/utils/utils.py | 0 src/freedium_library/utils/json_test.py | 1 + 20 files changed, 757 insertions(+), 19 deletions(-) create mode 100644 freedium-library/.pre-commit-config.yaml rename freedium-library/{src/freedium_library => lab}/lab.py (100%) create mode 100644 freedium-library/src/freedium_library/services/medium/config.py create mode 100644 freedium-library/src/freedium_library/services/medium/static/medium_domains.txt create mode 100644 freedium-library/src/freedium_library/services/medium/static/test_static.py create mode 100644 freedium-library/src/freedium_library/utils/hash.py create mode 100644 freedium-library/src/freedium_library/utils/json.py create mode 100644 freedium-library/src/freedium_library/utils/json_test.py create mode 100644 freedium-library/src/freedium_library/utils/utils.py create mode 100644 src/freedium_library/utils/json_test.py diff --git a/freedium-library/.pre-commit-config.yaml b/freedium-library/.pre-commit-config.yaml new file mode 100644 index 0000000..dc75e0f --- /dev/null +++ b/freedium-library/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: debug-statements + - id: check-toml + - id: check-json + - id: check-xml + - id: check-added-large-files + - id: check-ast + - id: check-merge-conflict diff --git a/freedium-library/src/freedium_library/lab.py b/freedium-library/lab/lab.py similarity index 100% rename from freedium-library/src/freedium_library/lab.py rename to freedium-library/lab/lab.py diff --git a/freedium-library/pdm.lock b/freedium-library/pdm.lock index 0dc3c20..bc60f9a 100644 --- a/freedium-library/pdm.lock +++ b/freedium-library/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:cbe7c4d9cd8848458b3af09be1a95005fbc4adf178dd7a96f83810a4b45bff66" +content_hash = "sha256:f33cda53180165f833ddded2b624db8f9a24af2e825c9e137db33c5813de495d" [[metadata.targets]] requires_python = "==3.12.*" @@ -38,6 +38,17 @@ files = [ {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, ] +[[package]] +name = "cfgv" +version = "3.4.0" +requires_python = ">=3.8" +summary = "Validate configuration and produce human readable error messages." +groups = ["dev"] +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + [[package]] name = "click" version = "8.1.7" @@ -65,6 +76,29 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "deprecation" +version = "2.1.0" +summary = "A library to handle automated deprecations" +groups = ["default"] +dependencies = [ + "packaging", +] +files = [ + {file = "deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a"}, + {file = "deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff"}, +] + +[[package]] +name = "distlib" +version = "0.3.9" +summary = "Distribution utilities" +groups = ["dev"] +files = [ + {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, + {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, +] + [[package]] name = "execnet" version = "2.1.1" @@ -91,6 +125,17 @@ files = [ {file = "faker-30.8.1.tar.gz", hash = "sha256:93e8b70813f76d05d98951154681180cb795cfbcff3eced7680d963bcc0da2a9"}, ] +[[package]] +name = "filelock" +version = "3.16.1" +requires_python = ">=3.8" +summary = "A platform independent file lock." +groups = ["dev"] +files = [ + {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, + {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, +] + [[package]] name = "h11" version = "0.14.0" @@ -138,6 +183,17 @@ files = [ {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, ] +[[package]] +name = "identify" +version = "2.6.1" +requires_python = ">=3.8" +summary = "File identification library for Python" +groups = ["dev"] +files = [ + {file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"}, + {file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"}, +] + [[package]] name = "idna" version = "3.10" @@ -257,17 +313,86 @@ files = [ {file = "multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a"}, ] +[[package]] +name = "nodeenv" +version = "1.9.1" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +summary = "Node.js virtual environment builder" +groups = ["dev"] +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "orjson" +version = "3.10.11" +requires_python = ">=3.8" +summary = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" +groups = ["default", "dev"] +files = [ + {file = "orjson-3.10.11-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:360a4e2c0943da7c21505e47cf6bd725588962ff1d739b99b14e2f7f3545ba51"}, + {file = "orjson-3.10.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:496e2cb45de21c369079ef2d662670a4892c81573bcc143c4205cae98282ba97"}, + {file = "orjson-3.10.11-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7dfa8db55c9792d53c5952900c6a919cfa377b4f4534c7a786484a6a4a350c19"}, + {file = "orjson-3.10.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51f3382415747e0dbda9dade6f1e1a01a9d37f630d8c9049a8ed0e385b7a90c0"}, + {file = "orjson-3.10.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f35a1b9f50a219f470e0e497ca30b285c9f34948d3c8160d5ad3a755d9299433"}, + {file = "orjson-3.10.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f3b7c5803138e67028dde33450e054c87e0703afbe730c105f1fcd873496d5"}, + {file = "orjson-3.10.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f91d9eb554310472bd09f5347950b24442600594c2edc1421403d7610a0998fd"}, + {file = "orjson-3.10.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dfbb2d460a855c9744bbc8e36f9c3a997c4b27d842f3d5559ed54326e6911f9b"}, + {file = "orjson-3.10.11-cp312-none-win32.whl", hash = "sha256:d4a62c49c506d4d73f59514986cadebb7e8d186ad510c518f439176cf8d5359d"}, + {file = "orjson-3.10.11-cp312-none-win_amd64.whl", hash = "sha256:f1eec3421a558ff7a9b010a6c7effcfa0ade65327a71bb9b02a1c3b77a247284"}, + {file = "orjson-3.10.11.tar.gz", hash = "sha256:e35b6d730de6384d5b2dab5fd23f0d76fae8bbc8c353c2f78210aa5fa4beb3ef"}, +] + [[package]] name = "packaging" version = "24.1" requires_python = ">=3.8" summary = "Core utilities for Python packages" -groups = ["dev"] +groups = ["default", "dev"] files = [ {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "pendulum" +version = "3.0.0" +requires_python = ">=3.8" +summary = "Python datetimes made easy" +groups = ["default"] +dependencies = [ + "backports-zoneinfo>=0.2.1; python_version < \"3.9\"", + "importlib-resources>=5.9.0; python_version < \"3.9\"", + "python-dateutil>=2.6", + "time-machine>=2.6.0; implementation_name != \"pypy\"", + "tzdata>=2020.1", +] +files = [ + {file = "pendulum-3.0.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:409e64e41418c49f973d43a28afe5df1df4f1dd87c41c7c90f1a63f61ae0f1f7"}, + {file = "pendulum-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a38ad2121c5ec7c4c190c7334e789c3b4624798859156b138fcc4d92295835dc"}, + {file = "pendulum-3.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fde4d0b2024b9785f66b7f30ed59281bd60d63d9213cda0eb0910ead777f6d37"}, + {file = "pendulum-3.0.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b2c5675769fb6d4c11238132962939b960fcb365436b6d623c5864287faa319"}, + {file = "pendulum-3.0.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8af95e03e066826f0f4c65811cbee1b3123d4a45a1c3a2b4fc23c4b0dff893b5"}, + {file = "pendulum-3.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2165a8f33cb15e06c67070b8afc87a62b85c5a273e3aaa6bc9d15c93a4920d6f"}, + {file = "pendulum-3.0.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ad5e65b874b5e56bd942546ea7ba9dd1d6a25121db1c517700f1c9de91b28518"}, + {file = "pendulum-3.0.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17fe4b2c844bbf5f0ece69cfd959fa02957c61317b2161763950d88fed8e13b9"}, + {file = "pendulum-3.0.0-cp312-none-win_amd64.whl", hash = "sha256:78f8f4e7efe5066aca24a7a57511b9c2119f5c2b5eb81c46ff9222ce11e0a7a5"}, + {file = "pendulum-3.0.0-cp312-none-win_arm64.whl", hash = "sha256:28f49d8d1e32aae9c284a90b6bb3873eee15ec6e1d9042edd611b22a94ac462f"}, + {file = "pendulum-3.0.0.tar.gz", hash = "sha256:5d034998dea404ec31fae27af6b22cff1708f830a1ed7353be4d1019bb9f584e"}, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +requires_python = ">=3.8" +summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +groups = ["dev"] +files = [ + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, +] + [[package]] name = "pluggy" version = "1.5.0" @@ -294,6 +419,24 @@ files = [ {file = "polyfactory-2.17.0.tar.gz", hash = "sha256:099d86f7c79c51a2caaf7c8598cc56e7b0a57c11b5918ddf699e82380735b6b7"}, ] +[[package]] +name = "pre-commit" +version = "4.0.1" +requires_python = ">=3.9" +summary = "A framework for managing and maintaining multi-language pre-commit hooks." +groups = ["dev"] +dependencies = [ + "cfgv>=2.0.0", + "identify>=1.0.0", + "nodeenv>=0.11.1", + "pyyaml>=5.1", + "virtualenv>=20.10.0", +] +files = [ + {file = "pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878"}, + {file = "pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2"}, +] + [[package]] name = "psutil" version = "6.1.0" @@ -370,6 +513,17 @@ files = [ {file = "pytest_httpx-0.33.0.tar.gz", hash = "sha256:4af9ab0dae5e9c14cb1e27d18af3db1f627b2cf3b11c02b34ddf26aff6b0a24c"}, ] +[[package]] +name = "pytest-integration" +version = "0.2.3" +requires_python = ">=3.6" +summary = "Organizing pytests by integration or not" +groups = ["dev"] +files = [ + {file = "pytest_integration-0.2.3-py3-none-any.whl", hash = "sha256:7f59ed1fa1cc8cb240f9495b68bc02c0421cce48589f78e49b7b842231604b12"}, + {file = "pytest_integration-0.2.3.tar.gz", hash = "sha256:b00988a5de8a6826af82d4c7a3485b43fbf32c11235e9f4a8b7225eef5fbcf65"}, +] + [[package]] name = "pytest-xdist" version = "3.6.1" @@ -420,7 +574,7 @@ name = "pyyaml" version = "6.0.2" requires_python = ">=3.8" summary = "YAML parser and emitter for Python" -groups = ["default"] +groups = ["default", "dev"] files = [ {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, @@ -489,6 +643,31 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "time-machine" +version = "2.16.0" +requires_python = ">=3.9" +summary = "Travel through time in your tests." +groups = ["default"] +marker = "implementation_name != \"pypy\"" +dependencies = [ + "python-dateutil", +] +files = [ + {file = "time_machine-2.16.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:84788f4d62a8b1bf5e499bb9b0e23ceceea21c415ad6030be6267ce3d639842f"}, + {file = "time_machine-2.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:15ec236b6571730236a193d9d6c11d472432fc6ab54e85eac1c16d98ddcd71bf"}, + {file = "time_machine-2.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cedc989717c8b44a3881ac3d68ab5a95820448796c550de6a2149ed1525157f0"}, + {file = "time_machine-2.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9d26d79de1c63a8c6586c75967e09b0ff306aa7e944a1eaddb74595c9b1839ca"}, + {file = "time_machine-2.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:317b68b56a9c3731e0cf8886e0f94230727159e375988b36c60edce0ddbcb44a"}, + {file = "time_machine-2.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:43e1e18279759897be3293a255d53e6b1cb0364b69d9591d0b80c51e461c94b0"}, + {file = "time_machine-2.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e43adb22def972a29d2b147999b56897116085777a0fea182fd93ee45730611e"}, + {file = "time_machine-2.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0c766bea27a0600e36806d628ebc4b47178b12fcdfb6c24dc0a566a9c06bfe7f"}, + {file = "time_machine-2.16.0-cp312-cp312-win32.whl", hash = "sha256:6dae82ab647d107817e013db82223e20a9853fa88543fec853ae326382d03c2e"}, + {file = "time_machine-2.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:265462c77dc9576267c3c7f20707780a171a9fdbac93ac22e608c309efd68c33"}, + {file = "time_machine-2.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:ef768e14768eebe3bb1196c0dece8e14c1c6991605721214a0c3c68cf77eb216"}, + {file = "time_machine-2.16.0.tar.gz", hash = "sha256:4a99acc273d2f98add23a89b94d4dd9e14969c01214c8514bfa78e4e9364c7e2"}, +] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -499,3 +678,51 @@ files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] + +[[package]] +name = "tzdata" +version = "2024.2" +requires_python = ">=2" +summary = "Provider of IANA time zone data" +groups = ["default"] +files = [ + {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"}, + {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, +] + +[[package]] +name = "ujson" +version = "5.10.0" +requires_python = ">=3.8" +summary = "Ultra fast JSON encoder and decoder for Python" +groups = ["default"] +files = [ + {file = "ujson-5.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98ba15d8cbc481ce55695beee9f063189dce91a4b08bc1d03e7f0152cd4bbdd5"}, + {file = "ujson-5.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9d2edbf1556e4f56e50fab7d8ff993dbad7f54bac68eacdd27a8f55f433578e"}, + {file = "ujson-5.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6627029ae4f52d0e1a2451768c2c37c0c814ffc04f796eb36244cf16b8e57043"}, + {file = "ujson-5.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8ccb77b3e40b151e20519c6ae6d89bfe3f4c14e8e210d910287f778368bb3d1"}, + {file = "ujson-5.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3caf9cd64abfeb11a3b661329085c5e167abbe15256b3b68cb5d914ba7396f3"}, + {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6e32abdce572e3a8c3d02c886c704a38a1b015a1fb858004e03d20ca7cecbb21"}, + {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a65b6af4d903103ee7b6f4f5b85f1bfd0c90ba4eeac6421aae436c9988aa64a2"}, + {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:604a046d966457b6cdcacc5aa2ec5314f0e8c42bae52842c1e6fa02ea4bda42e"}, + {file = "ujson-5.10.0-cp312-cp312-win32.whl", hash = "sha256:6dea1c8b4fc921bf78a8ff00bbd2bfe166345f5536c510671bccececb187c80e"}, + {file = "ujson-5.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:38665e7d8290188b1e0d57d584eb8110951a9591363316dd41cf8686ab1d0abc"}, + {file = "ujson-5.10.0.tar.gz", hash = "sha256:b3cd8f3c5d8c7738257f1018880444f7b7d9b66232c64649f562d7ba86ad4bc1"}, +] + +[[package]] +name = "virtualenv" +version = "20.27.1" +requires_python = ">=3.8" +summary = "Virtual Python Environment builder" +groups = ["dev"] +dependencies = [ + "distlib<1,>=0.3.7", + "filelock<4,>=3.12.2", + "importlib-metadata>=6.6; python_version < \"3.8\"", + "platformdirs<5,>=3.9.1", +] +files = [ + {file = "virtualenv-20.27.1-py3-none-any.whl", hash = "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4"}, + {file = "virtualenv-20.27.1.tar.gz", hash = "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba"}, +] diff --git a/freedium-library/pyproject.toml b/freedium-library/pyproject.toml index 179ee6f..45ea1ce 100644 --- a/freedium-library/pyproject.toml +++ b/freedium-library/pyproject.toml @@ -7,6 +7,10 @@ authors = [ ] dependencies = [ "litestar>=2.12.1", + "deprecation>=2.1.0", + "orjson>=3.10.11", + "pendulum>=3.0.0", + "ujson>=5.10.0", ] requires-python = "==3.12.*" readme = "README.md" @@ -21,4 +25,7 @@ dev = [ "pytest-asyncio>=0.24.0", "pytest-httpx>=0.33.0", "pytest-xdist[psutil]>=3.6.1", + "orjson>=3.10.11", + "pytest-integration>=0.2.3", + "pre-commit>=4.0.1", ] diff --git a/freedium-library/src/freedium_library/services/base.py b/freedium-library/src/freedium_library/services/base.py index 7bd272c..2731e28 100644 --- a/freedium-library/src/freedium_library/services/base.py +++ b/freedium-library/src/freedium_library/services/base.py @@ -1,10 +1,15 @@ +from __future__ import annotations + from abc import ABC, abstractmethod +from typing import TYPE_CHECKING from dependency_injector.wiring import Provide from loguru import logger from freedium_library.container import Container -from freedium_library.utils.http import Request + +if TYPE_CHECKING: + from freedium_library.utils.http import Request class BaseService(ABC): diff --git a/freedium-library/src/freedium_library/services/medium/api.py b/freedium-library/src/freedium_library/services/medium/api.py index abebb79..6b563ce 100644 --- a/freedium-library/src/freedium_library/services/medium/api.py +++ b/freedium-library/src/freedium_library/services/medium/api.py @@ -2,10 +2,93 @@ from __future__ import annotations from typing import TYPE_CHECKING +from loguru import logger + +from freedium_library.utils import JSON, HashLib + if TYPE_CHECKING: - from freedium_library.models import Request + from freedium_library.services.medium.config import MediumConfig + from freedium_library.utils.http import Request class MediumApiService: - def __init__(self, request: Request): + def __init__( + self, + request: Request, + config: MediumConfig, + ): self.request = request + self.config = config + + async def query_post_by_id(self, post_id: str): + logger.debug("Using graphql implementation") + return await self.query_post_graphql(post_id) + + async def query_post_graphql(self, post_id: str): + logger.debug(f"Starting request construction for post {post_id}") + + headers = { + "X-APOLLO-OPERATION-ID": HashLib.random_sha256(), + "X-APOLLO-OPERATION-NAME": "FullPostQuery", + "Accept": "multipart/mixed; deferSpec=20220824, application/json, application/json", + "Accept-Language": "en-US", + "X-Obvious-CID": "android", + "X-Xsrf-Token": "1", + "X-Client-Date": str(get_unix_ms()), + "User-Agent": "AdsBot-Google-Mobile", # "donkey/4.5.1187420", # <---- There is Medium version + "Cache-Control": "public, max-age=-1", + "Content-Type": "application/json", + "Connection": "Keep-Alive", + } + + if self.auth_cookies is not None: + headers["Cookie"] = self.auth_cookies + + graphql_data = { + "operationName": "FullPostQuery", + "variables": { + "postId": post_id, + "postMeteringOptions": {}, + }, + "query": "query FullPostQuery($postId: ID!, $postMeteringOptions: PostMeteringOptions) { post(id: $postId) { __typename id ...FullPostData } meterPost(postId: $postId, postMeteringOptions: $postMeteringOptions) { __typename ...MeteringInfoData } } fragment UserFollowData on User { id socialStats { followingCount followerCount } viewerEdge { isFollowing } } fragment NewsletterData on NewsletterV3 { id viewerEdge { id isSubscribed } } fragment UserNewsletterData on User { id newsletterV3 { __typename ...NewsletterData } } fragment ImageMetadataData on ImageMetadata { id originalWidth originalHeight focusPercentX focusPercentY alt } fragment CollectionFollowData on Collection { id subscriberCount viewerEdge { isFollowing } } fragment CollectionNewsletterData on Collection { id newsletterV3 { __typename ...NewsletterData } } fragment BylineData on Post { id readingTime creator { __typename id imageId username name bio tippingLink viewerEdge { isUser } ...UserFollowData ...UserNewsletterData } collection { __typename id name avatar { __typename id ...ImageMetadataData } ...CollectionFollowData ...CollectionNewsletterData } isLocked firstPublishedAt latestPublishedVersion } fragment ResponseCountData on Post { postResponses { count } } fragment InResponseToPost on Post { id title creator { name } clapCount responsesCount isLocked } fragment PostVisibilityData on Post { id collection { viewerEdge { isEditor canEditPosts canEditOwnPosts } } creator { id } isLocked visibility } fragment PostMenuData on Post { id title creator { __typename ...UserFollowData } collection { __typename ...CollectionFollowData } } fragment PostMetaData on Post { __typename id title visibility ...ResponseCountData clapCount viewerEdge { clapCount } detectedLanguage mediumUrl readingTime updatedAt isLocked allowResponses isProxyPost latestPublishedVersion isSeries firstPublishedAt previewImage { id } inResponseToPostResult { __typename ...InResponseToPost } inResponseToMediaResource { mediumQuote { startOffset endOffset paragraphs { text type markups { type start end anchorType } } } } inResponseToEntityType canonicalUrl collection { id slug name shortDescription avatar { __typename id ...ImageMetadataData } viewerEdge { isFollowing isEditor canEditPosts canEditOwnPosts isMuting } } creator { id isFollowing name bio imageId mediumMemberAt twitterScreenName viewerEdge { isBlocking isMuting isUser } } previewContent { subtitle } pinnedByCreatorAt ...PostVisibilityData ...PostMenuData } fragment LinkMetadataList on Post { linkMetadataList { url alts { type url } } } fragment MediaResourceData on MediaResource { id iframeSrc thumbnailUrl } fragment IframeData on Iframe { iframeHeight iframeWidth mediaResource { __typename ...MediaResourceData } } fragment MarkupData on Markup { name type start end href title rel type anchorType userId creatorIds } fragment CatalogSummaryData on Catalog { id name description type visibility predefined responsesLocked creator { id name username imageId bio viewerEdge { isUser } } createdAt version itemsLastInsertedAt postItemsCount } fragment CatalogPreviewData on Catalog { __typename ...CatalogSummaryData id itemsConnection(pagingOptions: { limit: 10 } ) { items { entity { __typename ... on Post { id previewImage { id } } } } paging { count } } } fragment MixtapeMetadataData on MixtapeMetadata { mediaResourceId href thumbnailImageId mediaResource { mediumCatalog { __typename ...CatalogPreviewData } } } fragment ParagraphData on Paragraph { id name href text iframe { __typename ...IframeData } layout markups { __typename ...MarkupData } metadata { __typename ...ImageMetadataData } mixtapeMetadata { __typename ...MixtapeMetadataData } type hasDropCap dropCapImage { __typename ...ImageMetadataData } codeBlockMetadata { lang mode } } fragment QuoteData on Quote { id postId userId startOffset endOffset paragraphs { __typename id ...ParagraphData } quoteType } fragment HighlightsData on Post { id highlights { __typename ...QuoteData } } fragment PostFooterCountData on Post { __typename id clapCount viewerEdge { clapCount } ...ResponseCountData responsesLocked mediumUrl title collection { id viewerEdge { isMuting isFollowing } } creator { id viewerEdge { isMuting isFollowing } } } fragment TagNoViewerEdgeData on Tag { id normalizedTagSlug displayTitle followerCount postCount } fragment VideoMetadataData on VideoMetadata { videoId previewImageId originalWidth originalHeight } fragment SectionData on Section { name startIndex textLayout imageLayout videoLayout backgroundImage { __typename ...ImageMetadataData } backgroundVideo { __typename ...VideoMetadataData } } fragment PostBodyData on RichText { sections { __typename ...SectionData } paragraphs { __typename id ...ParagraphData } } fragment FullPostData on Post { __typename ...BylineData ...PostMetaData ...LinkMetadataList ...HighlightsData ...PostFooterCountData tags { __typename id ...TagNoViewerEdgeData } content(postMeteringOptions: $postMeteringOptions) { bodyModel { __typename ...PostBodyData } validatedShareKey } } fragment MeteringInfoData on MeteringInfo { maxUnlockCount unlocksRemaining postIds }", + } + + response_data = None + exception = None + + logger.debug("Request started...") + + async with aiohttp.ClientSession(connector=connector) as session: + async with RetryClient( + client_session=session, + raise_for_status=False, + retry_options=RetryOptions, + ) as retry_client: + async with retry_client.post( + "https://medium.com/_/graphql", + headers=headers, + json=graphql_data, + timeout=self.timeout, + ) as request: + if request.status != 200: + logger.error( + f"Failed to fetch post by ID {post_id} with status code: {request.status}" + ) + return None + + try: + response_data = await request.json(loads=JSON.loads) + except Exception as ex: + logger.debug("Failed to parse response data as JSON") + logger.exception(ex) + exception = ex + + logger.debug("Request finished...") + + if exception: + logger.error( + f"Exception occured while fetching post {post_id}, so let's just fuck it up" + ) + raise exception + + return response_data diff --git a/freedium-library/src/freedium_library/services/medium/config.py b/freedium-library/src/freedium_library/services/medium/config.py new file mode 100644 index 0000000..e7d49d0 --- /dev/null +++ b/freedium-library/src/freedium_library/services/medium/config.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from typing import Optional + +from pydantic import Field +from pydantic_settings import BaseSettings + + +class MediumConfig(BaseSettings): + cookies: Optional[str] = Field(default=None) diff --git a/freedium-library/src/freedium_library/services/medium/container.py b/freedium-library/src/freedium_library/services/medium/container.py index ff0f1a6..029c085 100644 --- a/freedium-library/src/freedium_library/services/medium/container.py +++ b/freedium-library/src/freedium_library/services/medium/container.py @@ -1,9 +1,18 @@ from dependency_injector import containers, providers +from freedium_library.utils.http import Request + from .api import MediumApiService +from .config import MediumConfig from .validators import MediumServicePathValidator class MediumContainer(containers.DeclarativeContainer): - medium_api_service = providers.Singleton(MediumApiService) - medium_path_validator = providers.Singleton(MediumServicePathValidator) + config = providers.Singleton(MediumConfig) + request = providers.Singleton(Request) + api_service = providers.Singleton( + MediumApiService, + request=request, + config=config, + ) + validator = providers.Singleton(MediumServicePathValidator) diff --git a/freedium-library/src/freedium_library/services/medium/medium.py b/freedium-library/src/freedium_library/services/medium/medium.py index 1d91cf7..39dbd7a 100644 --- a/freedium-library/src/freedium_library/services/medium/medium.py +++ b/freedium-library/src/freedium_library/services/medium/medium.py @@ -21,10 +21,8 @@ class MediumService(BaseService): def __init__( self, request: Request = Provide[Container.request], - api_service: MediumApiService = Provide[MediumContainer.medium_api_service], - path_validator: MediumServicePathValidator = Provide[ - MediumContainer.medium_path_validator - ], + api_service: MediumApiService = Provide[MediumContainer.api_service], + path_validator: MediumServicePathValidator = Provide[MediumContainer.validator], ): self.request = request self.api_service = api_service diff --git a/freedium-library/src/freedium_library/services/medium/static/medium_domains.txt b/freedium-library/src/freedium_library/services/medium/static/medium_domains.txt new file mode 100644 index 0000000..92feaab --- /dev/null +++ b/freedium-library/src/freedium_library/services/medium/static/medium_domains.txt @@ -0,0 +1,34 @@ +medium.com +uxplanet.org +osintteam.blog +ahmedelfakharany.com +drlee.io +generativeai.pub +productcoalition.com +towardsdev.com +infosecwriteups.com +towardsdatascience.com +thetaoist.online +devopsquare.com +bettermarketing.pub +itnext.io +betterprogramming.pub +curiouse.co +betterhumans.pub +uxdesign.cc +thebolditalic.com +codeburst.io +writingcooperative.com +entrepreneurshandbook.co +storiusmag.com +javascript.plainenglish.io +code.likeagirl.io +medium.datadriveninvestor.com +blog.det.life +python.plainenglish.io +blog.stackademic.com +ai.gopubby.com +blog.devops.dev +levelup.gitconnected.com +betterhumans.coach.me +ai.plainenglish.io \ No newline at end of file diff --git a/freedium-library/src/freedium_library/services/medium/static/test_static.py b/freedium-library/src/freedium_library/services/medium/static/test_static.py new file mode 100644 index 0000000..7d61e20 --- /dev/null +++ b/freedium-library/src/freedium_library/services/medium/static/test_static.py @@ -0,0 +1,58 @@ +import os +from typing import Optional + +import httpx +import pytest +from bs4 import BeautifulSoup +from loguru import logger + + +def check_medium_meta_tag(domain: str) -> Optional[bool]: + url = f"https://{domain}" + logger.info(f"Checking Medium meta tag for domain: {domain}") + + try: + with httpx.Client(timeout=10.0) as client: + logger.debug(f"Sending GET request to {url}") + response = client.get(url, follow_redirects=True) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + meta_tag = soup.find( + "meta", + {"property": "al:android:package", "content": "com.medium.reader"}, + ) + + if meta_tag is not None: + logger.success(f"Medium meta tag found for domain: {domain}") + return True + else: + logger.warning(f"No Medium meta tag found for domain: {domain}") + return False + + except (httpx.RequestError, httpx.HTTPStatusError) as e: + logger.error(f"Error checking {domain}: {str(e)}") + return None + + +def get_domains_from_file(filepath: str) -> list[str]: + logger.info(f"Reading domains from file: {filepath}") + with open(filepath, "r") as file: + domains = [line.strip() for line in file if line.strip()] + logger.debug(f"Loaded {len(domains)} domains from file") + return domains + + +@pytest.mark.integration +@pytest.mark.parametrize( + "domain", + get_domains_from_file( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "medium_domains.txt") + ), +) +def test_medium_domain_meta_tag(domain: str): + logger.info(f"Starting test for domain: {domain}") + result = check_medium_meta_tag(domain) + assert result is not None, f"Failed to check domain {domain}" + assert result is True, f"Domain {domain} does not have Medium meta tag" + logger.success(f"Test passed for domain: {domain}") diff --git a/freedium-library/src/freedium_library/services/medium/validators.py b/freedium-library/src/freedium_library/services/medium/validators.py index fcbf6eb..50f4860 100644 --- a/freedium-library/src/freedium_library/services/medium/validators.py +++ b/freedium-library/src/freedium_library/services/medium/validators.py @@ -1,10 +1,12 @@ from __future__ import annotations import re +import string from typing import TYPE_CHECKING, Any, Optional from urllib.parse import parse_qs, urlparse from dependency_injector.wiring import Provide +from deprecation import deprecated from loguru import logger from freedium_library.container import Container @@ -65,7 +67,7 @@ class _MediumServiceURLValidator: parsed_url = urlparse(url) parsed_netloc = URLProcessor.un_wwwify(parsed_url.netloc) - if parsed_url.path.startswith("/p/"): + if parsed_url.path.startswith("/p/"): # TODO: add more information logger.debug("URL is Medium 'mobile' link") post_id = parsed_url.path.rsplit("/p/")[1] @@ -83,7 +85,7 @@ class _MediumServiceURLValidator: elif ( parsed_netloc == "webcache.googleusercontent.com" and parsed_url.path.startswith("/search") - ): + ): # TODO: is'n it deprecated? https://www.seozoom.com/google-cache/ logger.debug("URL seems like is Google Web Archive page link") parsed_query = parse_qs(parsed_url.query) @@ -174,8 +176,31 @@ class _MediumServiceURLValidator: class _MediumServiceHashesValidator: - def is_valid(self, hash: str) -> bool: - return bool(self.extract_hashes(hash)) + VALID_ID_CHARS = set(string.ascii_letters + string.digits) + + def is_valid(self, path: str) -> bool: + return bool(self.extract_hashes(path)) + + @deprecated(details="Use is_valid instead") + def is_valid_old(self, hex_string: str) -> bool: + # Check if the string is a valid hexadecimal string + for char in hex_string: + if char not in self.VALID_ID_CHARS: + return False + + # Unfortunately, this logic doesn't works correctly sometimes, because + # there is some unique URLs that are has only digits, like this: + # https://valeman.medium.com/python-vs-r-for-time-series-forecasting-395390432598 + + # Check if the string contains only lowercase hexadecimal characters + # if not hex_string.islower(): + # return False + + # Check if the length of the string is correct for a hexadecimal string (e.g., 10, 11 or 12 characters) + if len(hex_string) not in range(8, 12 + 1): + return False + + return True def extract_hashes(self, path: str) -> list[str]: logger.debug(f"Extracting hashes from path: {path}") diff --git a/freedium-library/src/freedium_library/services/medium/validators_url_test.py b/freedium-library/src/freedium_library/services/medium/validators_url_test.py index 15e9e12..2c0de16 100644 --- a/freedium-library/src/freedium_library/services/medium/validators_url_test.py +++ b/freedium-library/src/freedium_library/services/medium/validators_url_test.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, Mock import pytest from freedium_library.services.medium.api import MediumApiService +from freedium_library.services.medium.config import MediumConfig from freedium_library.services.medium.validators import ( _MediumServiceHashesValidator, # type: ignore _MediumServiceURLValidator, # type: ignore @@ -117,7 +118,8 @@ async def test_resolve_medium_url_with_real_short_link( @pytest.mark.asyncio async def test_resolve_medium_url_with_real_short_link_integration() -> None: request = Request() - api_service = MediumApiService(request=request) + config = MediumConfig() + api_service = MediumApiService(request=request, config=config) hash_validator = _MediumServiceHashesValidator() url_validator = _MediumServiceURLValidator(api_service, hash_validator, request) diff --git a/freedium-library/src/freedium_library/utils/__init__.py b/freedium-library/src/freedium_library/utils/__init__.py index e69de29..2078315 100644 --- a/freedium-library/src/freedium_library/utils/__init__.py +++ b/freedium-library/src/freedium_library/utils/__init__.py @@ -0,0 +1,4 @@ +from .hash import HashLib +from .json import JSON, json_instance + +__all__ = ["json_instance", "JSON", "HashLib"] diff --git a/freedium-library/src/freedium_library/utils/hash.py b/freedium-library/src/freedium_library/utils/hash.py new file mode 100644 index 0000000..e49e68f --- /dev/null +++ b/freedium-library/src/freedium_library/utils/hash.py @@ -0,0 +1,14 @@ +import hashlib +import secrets + + +class HashLib: + @staticmethod + def sha256(data: bytes) -> str: + sha256_hash = hashlib.sha256() + sha256_hash.update(data) + return sha256_hash.hexdigest() + + @staticmethod + def random_sha256() -> str: + return HashLib.sha256(secrets.token_bytes()) diff --git a/freedium-library/src/freedium_library/utils/http/client.py b/freedium-library/src/freedium_library/utils/http/client.py index 0ee0ecf..4b039bc 100644 --- a/freedium-library/src/freedium_library/utils/http/client.py +++ b/freedium-library/src/freedium_library/utils/http/client.py @@ -1,4 +1,3 @@ -import asyncio import warnings from dataclasses import dataclass from typing import Any, Dict, Optional @@ -68,7 +67,7 @@ class Request: def __del__(self): self._client.close() - asyncio.run(self._async_client.aclose()) + # asyncio.run(self._async_client.aclose()) # TODO: doesnt works def _check_context_manager(self): if not self._in_context_manager: diff --git a/freedium-library/src/freedium_library/utils/json.py b/freedium-library/src/freedium_library/utils/json.py new file mode 100644 index 0000000..ad3e58a --- /dev/null +++ b/freedium-library/src/freedium_library/utils/json.py @@ -0,0 +1,130 @@ +from abc import ABC, abstractmethod +from typing import Any, Optional, Type + +from loguru import logger + + +class JSONBackend(ABC): + @abstractmethod + def dumps(self, obj: Any, pretty: bool = False) -> str: + pass + + @abstractmethod + def loads(self, string: str) -> Any: + pass + + @property + @abstractmethod + def name(self) -> str: + pass + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(backend='{self.name}')" + + def __str__(self) -> str: + return f"JSON Backend: {self.name}" + + +class OrjsonBackend(JSONBackend): + def __init__(self): + import orjson + + self._orjson = orjson + + def dumps(self, obj: Any, pretty: bool = False) -> str: + opts = self._orjson.OPT_INDENT_2 if pretty else None + return self._orjson.dumps(obj, option=opts).decode("utf-8") + + def loads(self, string: str) -> Any: + return self._orjson.loads(string) + + @property + def name(self) -> str: + return "orjson" + + +class UjsonBackend(JSONBackend): + def __init__(self): + import ujson + + self._ujson = ujson + + def dumps(self, obj: Any, pretty: bool = False) -> str: + return self._ujson.dumps(obj, indent=2 if pretty else 0) + + def loads(self, string: str) -> Any: + return self._ujson.loads(string) + + @property + def name(self) -> str: + return "ujson" + + +class StandardJSONBackend(JSONBackend): + def __init__(self): + import json + + self._json = json + + def dumps(self, obj: Any, pretty: bool = False) -> str: + return self._json.dumps(obj, indent=2 if pretty else None) + + def loads(self, string: str) -> Any: + return self._json.loads(string) + + @property + def name(self) -> str: + return "json" + + +class JSON: + _instance: Optional[Type["JSON"]] = None + _backend: Optional[JSONBackend] = None + + def __init__(self, backend: Optional[JSONBackend] = None) -> None: + if backend: + JSON._backend = backend + elif not JSON._backend: + JSON._backend = JSON._get_best_backend() + + @classmethod + def _ensure_backend(cls) -> None: + if cls._backend is None: + cls._backend = cls._get_best_backend() + + @classmethod + def dumps(cls, obj: Any, pretty: bool = False) -> str: + cls._ensure_backend() + return cls._backend.dumps(obj, pretty) # type: ignore + + @classmethod + def loads(cls, string: str) -> Any: + cls._ensure_backend() + return cls._backend.loads(string) # type: ignore + + @classmethod + def backend(cls) -> str: + cls._ensure_backend() + return cls._backend.name # type: ignore + + @classmethod + def _get_best_backend(cls) -> JSONBackend: + backends = [ + (OrjsonBackend, "orjson"), + (UjsonBackend, "ujson"), + (StandardJSONBackend, "json"), + ] + + for backend_class, module_name in backends: + try: + __import__(module_name) + logger.debug(f"Using {module_name} as JSON backend") + return backend_class() + except ImportError: + continue + + logger.warning("No JSON backend found, using standard JSON backend") + return StandardJSONBackend() + + +json_instance = JSON() diff --git a/freedium-library/src/freedium_library/utils/json_test.py b/freedium-library/src/freedium_library/utils/json_test.py new file mode 100644 index 0000000..1939899 --- /dev/null +++ b/freedium-library/src/freedium_library/utils/json_test.py @@ -0,0 +1,117 @@ +from typing import Any, Dict +from unittest.mock import patch + +import pytest + +from freedium_library.utils.json import ( + JSON, + JSONBackend, + OrjsonBackend, + StandardJSONBackend, + UjsonBackend, +) + +# Test data +SIMPLE_DICT: Dict[str, Any] = {"key": "value", "number": 42} +COMPLEX_DICT: Dict[str, Any] = { + "string": "hello", + "number": 42, + "float": 3.14, + "bool": True, + "null": None, + "array": [1, 2, 3], + "nested": {"a": 1, "b": 2}, +} +UNICODE_DICT: Dict[str, str] = {"unicode": "Hello 世界"} + + +@pytest.fixture +def json_backends() -> list[JSONBackend]: + return [OrjsonBackend(), UjsonBackend(), StandardJSONBackend()] + + +def test_backend_interface() -> None: + """Test that all backends implement the required interface""" + for backend in [OrjsonBackend(), UjsonBackend(), StandardJSONBackend()]: + assert isinstance(backend, JSONBackend) + assert callable(backend.dumps) + assert callable(backend.loads) + assert isinstance(backend.name, str) + assert str(backend).startswith("JSON Backend:") + assert repr(backend).endswith(f"backend='{backend.name}')") + + +@pytest.mark.parametrize( + "backend", [OrjsonBackend(), UjsonBackend(), StandardJSONBackend()] +) +class TestBackendBehavior: + def test_simple_roundtrip(self, backend: JSONBackend) -> None: + """Test serialization and deserialization of simple data""" + serialized: str = backend.dumps(SIMPLE_DICT) + deserialized: Dict[str, Any] = backend.loads(serialized) + assert deserialized == SIMPLE_DICT + + def test_complex_roundtrip(self, backend: JSONBackend) -> None: + """Test serialization and deserialization of complex data""" + serialized: str = backend.dumps(COMPLEX_DICT) + deserialized: Dict[str, Any] = backend.loads(serialized) + assert deserialized == COMPLEX_DICT + + def test_unicode_handling(self, backend: JSONBackend) -> None: + """Test proper Unicode handling""" + serialized: str = backend.dumps(UNICODE_DICT) + deserialized: Dict[str, str] = backend.loads(serialized) + assert deserialized == UNICODE_DICT + + def test_pretty_printing(self, backend: JSONBackend) -> None: + """Test pretty printing functionality""" + ugly: str = backend.dumps(COMPLEX_DICT, pretty=False) + pretty: str = backend.dumps(COMPLEX_DICT, pretty=True) + assert len(pretty) > len(ugly) + assert backend.loads(pretty) == backend.loads(ugly) + + +class TestJSONClass: + def test_backend_selection(self) -> None: + """Test automatic backend selection""" + json = JSON() + assert json.backend() in ["orjson", "ujson", "json"] + + def test_backend_fallback(self) -> None: + """Test fallback behavior when preferred backends are unavailable""" + JSON._instance = None + JSON._backend = None + with patch.dict("sys.modules", {"orjson": None, "ujson": None}): + json = JSON() + assert json.backend() == "json" + + def test_explicit_backend(self) -> None: + """Test using an explicitly provided backend""" + backend = StandardJSONBackend() + json = JSON(backend=backend) + assert json.backend() == "json" + + def test_singleton_behavior(self) -> None: + """Test that JSON class maintains singleton behavior""" + json1 = JSON() + json2 = JSON() + assert json1._backend is json2._backend + + def test_class_methods(self) -> None: + """Test class-level convenience methods""" + data: Dict[str, Any] = COMPLEX_DICT + serialized: str = JSON.dumps(data) + deserialized: Dict[str, Any] = JSON.loads(serialized) + assert deserialized == data + + +@pytest.mark.parametrize( + "backend", [OrjsonBackend(), UjsonBackend(), StandardJSONBackend()] +) +def test_error_handling(backend: JSONBackend) -> None: + """Test error handling for invalid JSON""" + with pytest.raises(Exception): + backend.loads("invalid json") + + with pytest.raises(Exception): + backend.dumps(object()) diff --git a/freedium-library/src/freedium_library/utils/utils.py b/freedium-library/src/freedium_library/utils/utils.py new file mode 100644 index 0000000..e69de29 diff --git a/src/freedium_library/utils/json_test.py b/src/freedium_library/utils/json_test.py new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/src/freedium_library/utils/json_test.py @@ -0,0 +1 @@ + \ No newline at end of file