diff --git a/.github/workflows/publish-to-pypi.yaml b/.github/workflows/publish-to-pypi.yaml index 6d7dead..07e3f4d 100644 --- a/.github/workflows/publish-to-pypi.yaml +++ b/.github/workflows/publish-to-pypi.yaml @@ -5,8 +5,11 @@ on: types: [published] jobs: - publish: - runs-on: ubuntu-latest + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] steps: - name: Checkout code @@ -15,13 +18,42 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: 3.11 - name: Install build tools - run: pip install build twine wheel + run: pip install build twine wheel cibuildwheel + + - name: Build wheel with cibuildwheel + env: + CIBW_BUILD: cp37-*,cp38-*,cp39-*,cp310-*,cp311-*,cp312-*,cp313-*,cp314-* + run: cibuildwheel --output-dir wheelhouse + + - name: Upload wheels + uses: actions/upload-artifact@v3 + with: + name: wheels-${{ matrix.os }} + path: wheelhouse/ + + publish: + needs: build + runs-on: ubuntu-latest + + steps: + - name: Download wheels + uses: actions/download-artifact@v3 + with: + name: wheels-ubuntu-latest + - name: Download wheels + uses: actions/download-artifact@v3 + with: + name: wheels-windows-latest + - name: Download wheels + uses: actions/download-artifact@v3 + with: + name: wheels-macos-latest - - name: Build package - run: python setup.py sdist bdist_wheel + - name: Combine wheels + run: mkdir dist && mv wheelhouse/* dist/ - name: Publish to PyPI env: diff --git a/.github/workflows/test-lib-building.yaml b/.github/workflows/test-lib-building.yaml index 990f0de..9745fe4 100644 --- a/.github/workflows/test-lib-building.yaml +++ b/.github/workflows/test-lib-building.yaml @@ -1,4 +1,4 @@ -name: Build and Test Library +name: Run Tests on all environments on: push: @@ -8,7 +8,10 @@ on: jobs: build-and-test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] steps: - name: Checkout code @@ -17,26 +20,28 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: 3.11 - name: Install build tools and test dependencies - run: | - pip install build pytest + run: pip install build pytest cython cibuildwheel - - name: Build the library - run: | - python -m build - id: build + - name: Build wheel with cibuildwheel + env: + CIBW_BUILD: cp311-* + run: cibuildwheel --output-dir wheelhouse - name: Install built library - run: | - pip install dist/*.whl + run: pip install flask_inputfilter --find-links=wheelhouse/ --force-reinstall - name: Verify library usage - Part I run: | - echo "import flask_inputfilter" > test_script.py + python -c "import shutil; shutil.rmtree('flask_inputfilter', ignore_errors=True)" + + echo "import flask_inputfilter.InputFilter" > test_script.py python test_script.py - name: Verify library usage - Part II run: | - PYTHONPATH="" pytest test/ + python -c "import shutil; shutil.rmtree('flask_inputfilter', ignore_errors=True)" + + pytest test/ diff --git a/.github/workflows/test-publish-to-pypi.yaml b/.github/workflows/test-publish-to-pypi.yaml new file mode 100644 index 0000000..7b95be2 --- /dev/null +++ b/.github/workflows/test-publish-to-pypi.yaml @@ -0,0 +1,59 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.11 + + - name: Install build tools + run: pip install build twine wheel cibuildwheel + + - name: Build wheel with cibuildwheel + env: + CIBW_BUILD: cp37-*,cp38-*,cp39-*,cp310-*,cp311-*,cp312-*,cp313-*,cp314-* + run: cibuildwheel --output-dir wheelhouse + + - name: Upload wheels + uses: actions/upload-artifact@v3 + with: + name: wheels-${{ matrix.os }} + path: wheelhouse/ + + publish: + needs: build + runs-on: ubuntu-latest + + steps: + - name: Download wheels + uses: actions/download-artifact@v3 + with: + name: wheels-ubuntu-latest + - name: Download wheels + uses: actions/download-artifact@v3 + with: + name: wheels-windows-latest + - name: Download wheels + uses: actions/download-artifact@v3 + with: + name: wheels-macos-latest + + - name: Combine wheels + run: mkdir dist && mv wheelhouse/* dist/ + + - name: Publish to PyPI + run: ls -la dist diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 85c3ac2..a03feaa 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,4 +1,4 @@ -name: Run Tests +name: Run Tests and Lint on: [push] diff --git a/.github/workflows/test_env.yaml b/.github/workflows/test_env.yaml index 3a03c2a..fa3b1ce 100644 --- a/.github/workflows/test_env.yaml +++ b/.github/workflows/test_env.yaml @@ -1,4 +1,4 @@ -name: Run Tests on all environments +name: Run Tests on all python versions on: [push] diff --git a/.gitignore b/.gitignore index 10e3dee..63b30f0 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,5 @@ cython_debug/ .vscode/ .DS_Store _build + +*.c diff --git a/Dockerfile b/Dockerfile index d3d6c83..f4bd6e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,3 +14,5 @@ COPY . /app COPY scripts /usr/local/bin RUN find /usr/local/bin -type f -name "*" -exec chmod +x {} \; + +ENV flask_inputfilter_dev=true diff --git a/MANIFEST.in b/MANIFEST.in index 9b1b3d6..859fd85 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,9 @@ include README.rst include LICENSE include docs/changelog.rst +include flask_inputfilter/*.py +include flask_inputfilter/*.pyx +include flask_inputfilter/*.pxd +include flask_inputfilter/*.so + +exclude test/* diff --git a/Makefile b/Makefile index d0c3cbf..91f74ba 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,12 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build -# Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/changelog.rst b/docs/changelog.rst index d77f017..be65cd2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -3,6 +3,14 @@ Changelog All notable changes to this project will be documented in this file. +[0.2.0] - 2025-03-26 +-------------------- + +Changed +^^^^^^^ +- Uses compiled version of InputFilter class to increase speed drastically. + + [0.1.0] - 2025-03-26 -------------------- diff --git a/docs/conf.py b/docs/conf.py index 22f5ce0..0d8c346 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,7 @@ project = "flask-inputfilter" copyright = "2025, Leander Cain Slotosch" author = "Leander Cain Slotosch" -release = "0.1.0" +release = "0.2.0" extensions = ["sphinx_rtd_theme"] diff --git a/env_configs/Dockerfile b/env_configs/Dockerfile index a436ec6..1855134 100644 --- a/env_configs/Dockerfile +++ b/env_configs/Dockerfile @@ -49,3 +49,5 @@ COPY ../requirements.txt /app RUN pip install --no-cache-dir -r requirements.txt && pip install tox COPY .. /app + +ENV flask_inputfilter_dev=true diff --git a/env_configs/requirements-py310.txt b/env_configs/requirements-py310.txt index be35d92..3a21f4b 100644 --- a/env_configs/requirements-py310.txt +++ b/env_configs/requirements-py310.txt @@ -1,3 +1,4 @@ +cython flask==2.1 pillow==8.0.0 pytest diff --git a/env_configs/requirements-py311.txt b/env_configs/requirements-py311.txt index be35d92..3a21f4b 100644 --- a/env_configs/requirements-py311.txt +++ b/env_configs/requirements-py311.txt @@ -1,3 +1,4 @@ +cython flask==2.1 pillow==8.0.0 pytest diff --git a/env_configs/requirements-py312.txt b/env_configs/requirements-py312.txt index 9bfbec0..2c7fbc6 100644 --- a/env_configs/requirements-py312.txt +++ b/env_configs/requirements-py312.txt @@ -1,5 +1,7 @@ +cython flask pillow pytest requests typing_extensions +setuptools diff --git a/env_configs/requirements-py313.txt b/env_configs/requirements-py313.txt index 9bfbec0..2c7fbc6 100644 --- a/env_configs/requirements-py313.txt +++ b/env_configs/requirements-py313.txt @@ -1,5 +1,7 @@ +cython flask pillow pytest requests typing_extensions +setuptools diff --git a/env_configs/requirements-py314.txt b/env_configs/requirements-py314.txt index 9bfbec0..2c7fbc6 100644 --- a/env_configs/requirements-py314.txt +++ b/env_configs/requirements-py314.txt @@ -1,5 +1,7 @@ +cython flask pillow pytest requests typing_extensions +setuptools diff --git a/env_configs/requirements-py37.txt b/env_configs/requirements-py37.txt index 264f672..150a6a5 100644 --- a/env_configs/requirements-py37.txt +++ b/env_configs/requirements-py37.txt @@ -1,3 +1,4 @@ +cython flask==2.1 pillow==8.0.0 pytest diff --git a/env_configs/requirements-py38.txt b/env_configs/requirements-py38.txt index be35d92..3a21f4b 100644 --- a/env_configs/requirements-py38.txt +++ b/env_configs/requirements-py38.txt @@ -1,3 +1,4 @@ +cython flask==2.1 pillow==8.0.0 pytest diff --git a/env_configs/requirements-py39.txt b/env_configs/requirements-py39.txt index be35d92..3a21f4b 100644 --- a/env_configs/requirements-py39.txt +++ b/env_configs/requirements-py39.txt @@ -1,3 +1,4 @@ +cython flask==2.1 pillow==8.0.0 pytest diff --git a/flask_inputfilter/InputFilter.py b/flask_inputfilter/InputFilter.pyx similarity index 98% rename from flask_inputfilter/InputFilter.py rename to flask_inputfilter/InputFilter.pyx index 9b2609e..610630d 100644 --- a/flask_inputfilter/InputFilter.py +++ b/flask_inputfilter/InputFilter.pyx @@ -13,13 +13,21 @@ API_PLACEHOLDER_PATTERN = re.compile(r"{{(.*?)}}") -class InputFilter: +cdef class InputFilter: """ Base class for input filters. """ + cdef readonly list __methods + cdef readonly dict __fields + cdef readonly list __conditions + cdef readonly list __global_filters + cdef readonly list __global_validators + cdef public dict __data + cdef readonly dict __validated_data + cdef readonly str __error_message def __init__(self, methods: Optional[List[str]] = None) -> None: - self.__methods = methods or ["GET", "POST", "PATCH", "PUT", "DELETE"] + self.__methods: List[str] = methods or ["GET", "POST", "PATCH", "PUT", "DELETE"] self.__fields: Dict[str, FieldModel] = {} self.__conditions: List[BaseCondition] = [] self.__global_filters: List[BaseFilter] = [] diff --git a/flask_inputfilter/Validator/IsIntegerValidator.py b/flask_inputfilter/Validator/IsIntegerValidator.py index 6a18a19..bb07aff 100644 --- a/flask_inputfilter/Validator/IsIntegerValidator.py +++ b/flask_inputfilter/Validator/IsIntegerValidator.py @@ -15,5 +15,5 @@ def __init__(self, error_message: Optional[str] = None) -> None: def validate(self, value: Any) -> None: if not isinstance(value, int): raise ValidationError( - self.error_message, f"Value '{value}' is not an integer." + self.error_message or f"Value '{value}' is not an integer." ) diff --git a/flask_inputfilter/__init__.py b/flask_inputfilter/__init__.py index 2595f98..efc2a50 100644 --- a/flask_inputfilter/__init__.py +++ b/flask_inputfilter/__init__.py @@ -1 +1,8 @@ +import os + +if os.getenv("flask_inputfilter_dev"): + import pyximport + + pyximport.install() + from .InputFilter import InputFilter diff --git a/make.bat b/make.bat index dc1312a..088cd24 100644 --- a/make.bat +++ b/make.bat @@ -2,7 +2,7 @@ pushd %~dp0 -REM Command file for Sphinx documentation +REM if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build diff --git a/pyproject.toml b/pyproject.toml index 45dd249..5d42401 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools", "wheel"] +requires = ["setuptools", "wheel", "Cython"] build-backend = "setuptools.build_meta" [tool.black] diff --git a/requirements.txt b/requirements.txt index dab4561..070d389 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,8 @@ autoflake black coverage coveralls -docformatter +cython +#docformatter flake8==4.0.0 flask==2.1 isort diff --git a/scripts/lint b/scripts/lint index 4473d5e..1a29cb4 100644 --- a/scripts/lint +++ b/scripts/lint @@ -11,5 +11,5 @@ autoflake --in-place --remove-all-unused-imports --ignore-init-module-imports -- echo 'Running black' black . -echo 'Running docformatter' -docformatter --in-place . +# echo 'Running docformatter' +# docformatter --in-place . diff --git a/setup.py b/setup.py index bbf5789..0bb280f 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,11 @@ -from setuptools import find_packages, setup +from Cython.Build import cythonize +from setuptools import Extension, find_packages, setup setup( name="flask_inputfilter", - version="0.1.0", + version="0.2.0", + license="MIT", + license_files=["LICENSE"], author="Leander Cain Slotosch", author_email="slotosch.leander@outlook.de", description="A library to filter and validate input data in " @@ -13,14 +16,23 @@ packages=find_packages( include=["flask_inputfilter", "flask_inputfilter.*"] ), + ext_modules=cythonize( + [ + Extension( + name="flask_inputfilter.InputFilter", + sources=["flask_inputfilter/InputFilter.pyx"], + ), + ] + ), + package_data={"flask_inputfilter": ["*.pyx", "*.py"]}, + include_package_data=True, install_requires=[ - "Flask>=2.1", + "flask>=2.1", "pillow>=8.0.0", "requests>=2.22.0", "typing_extensions>=3.6.2", ], classifiers=[ - "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.13", diff --git a/test/test_input_filter.py b/test/test_input_filter.py index 844038d..adf61d0 100644 --- a/test/test_input_filter.py +++ b/test/test_input_filter.py @@ -27,7 +27,6 @@ def setUp(self) -> None: """ Set up a basic InputFilter instance for testing. """ - self.inputFilter = InputFilter() def test_validate_decorator(self) -> None: @@ -104,7 +103,7 @@ def test_route(username): self.assertEqual(response.status_code, 200) self.assertEqual(response.json, {"username": "test_user"}) - def test_unsupported_method(self) -> None: + def test_custom_method(self) -> None: """ Test that a method not supported by the InputFilter instance raises a TypeError. @@ -116,19 +115,24 @@ def __init__(self): app = Flask(__name__) - @app.route("/test-unsupported", methods=["POST"]) + @app.route("/test-custom") @MyInputFilter.validate() - def test_unsupported_route(): + def test_custom_route(): validated_data = g.validated_data return jsonify(validated_data) with app.test_client() as client: - response = client.post("/test-unsupported") + response = client.post("/test-custom") self.assertEqual(response.status_code, 405) - @patch.object(InputFilter, "validateData") - def test_validation_error_response(self, mock_validateData): - mock_validateData.side_effect = ValidationError("Invalid data") + response = client.get("/test-custom") + self.assertEqual(response.status_code, 200) + + def test_validation_error_response(self): + """ + Tests the behavior of the application when a validation error occurs + due to invalid input data. + """ class MyInputFilter(InputFilter): def __init__(self): @@ -137,7 +141,9 @@ def __init__(self): name="age", required=False, default=18, - validators=[IsIntegerValidator()], + validators=[ + IsIntegerValidator(error_message="Invalid data") + ], ) app = Flask(__name__) @@ -153,49 +159,11 @@ def test_route(): self.assertEqual(response.status_code, 400) self.assertEqual(response.data.decode(), "Invalid data") - @patch.object(InputFilter, "validateData") - def test_custom_supported_methods(self, mock_validateData): - mock_validateData.return_value = {"username": "test_user", "age": 25} - - class MyInputFilter(InputFilter): - def __init__(self): - super().__init__(methods=["GET"]) - - self.add( - name="username", - required=True, - ) - self.add( - name="age", - required=False, - default=18, - validators=[IsIntegerValidator()], - ) - - app = Flask(__name__) - - @app.route("/test", methods=["GET", "POST"]) - @MyInputFilter.validate() - def test_route(): - validated_data = g.validated_data - return jsonify(validated_data) - - with app.test_client() as client: - response = client.get( - "/test", query_string={"username": "test_user"} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual( - response.json, {"username": "test_user", "age": 25} - ) - - response = client.post("/test", json={"username": "test_user"}) - self.assertEqual(response.status_code, 405) - def test_optional(self) -> None: """ Test that optional field validation works. """ + self.inputFilter.add("name", required=True) self.inputFilter.validateData({"name": "Alice"}) @@ -207,6 +175,7 @@ def test_default(self) -> None: """ Test that default field works. """ + self.inputFilter.add("available", default=True) validated_data = self.inputFilter.validateData({}) @@ -219,6 +188,7 @@ def test_fallback(self) -> None: """ Test that fallback field works. """ + self.inputFilter.add("available", required=True, fallback=True) self.inputFilter.add( "color", diff --git a/tox.ini b/tox.ini index d47df33..e336f17 100644 --- a/tox.ini +++ b/tox.ini @@ -5,5 +5,6 @@ envlist = py37, py38, py39, py310, py311, py312, py313, py314 deps = -renv_configs/requirements-{envname}.txt install_command = {envbindir}/python -I -m pip install {opts} {packages} +commands_pre = python setup.py build_ext --inplace commands = pytest