From 3d9501a5af352ce29e960ef86a1989718f7d8faa Mon Sep 17 00:00:00 2001 From: Leander Cain Slotosch Date: Thu, 17 Apr 2025 22:42:03 +0200 Subject: [PATCH] 31 | Add fallback for cython --- .github/workflows/publish-to-pypi.yaml | 2 +- .../{test_env.yaml => test-env.yaml} | 6 +- .github/workflows/test-lib-building.yaml | 44 +- .github/workflows/test.yaml | 52 +- docker-compose.yaml | 2 +- docs/source/changelog.rst | 9 + docs/source/conf.py | 2 +- docs/source/guides/frontend_validation.rst | 1 + docs/source/index.rst | 1 + docs/source/options/condition.rst | 18 + docs/source/options/copy.rst | 2 + docs/source/options/deserialization.rst | 1 + docs/source/options/external_api.rst | 1 + docs/source/options/filter.rst | 30 + docs/source/options/special_validator.rst | 9 + docs/source/options/validator.rst | 48 +- Dockerfile => env_configs/cython.Dockerfile | 4 +- env_configs/{Dockerfile => env.Dockerfile} | 1 - env_configs/pure.Dockerfile | 14 + env_configs/requirements-py310.txt | 1 - env_configs/requirements-py311.txt | 1 - env_configs/requirements-py312.txt | 1 - env_configs/requirements-py313.txt | 1 - env_configs/requirements-py314.txt | 1 - env_configs/requirements-py38.txt | 1 - env_configs/requirements-py39.txt | 1 - .../Filter/ToNormalizedUnicodeFilter.py | 6 +- flask_inputfilter/Filter/ToTypedDictFilter.py | 12 +- flask_inputfilter/InputFilter.py | 888 ++++++++++++++++++ flask_inputfilter/InputFilter.pyi | 97 ++ flask_inputfilter/Mixin/ExternalApiMixin.py | 158 ++++ ...rnalApiMixin.pyx => _ExternalApiMixin.pyx} | 0 flask_inputfilter/Mixin/__init__.py | 8 +- .../Validator/IsTypedDictValidator.py | 9 +- .../{InputFilter.pyx => _InputFilter.pyx} | 0 flask_inputfilter/__init__.py | 25 +- pyproject.toml | 26 +- setup.py | 21 +- tests/test_filter.py | 20 +- tests/test_input_filter.py | 8 + tests/test_validator.py | 18 +- 41 files changed, 1471 insertions(+), 79 deletions(-) rename .github/workflows/{test_env.yaml => test-env.yaml} (89%) rename Dockerfile => env_configs/cython.Dockerfile (71%) rename env_configs/{Dockerfile => env.Dockerfile} (99%) create mode 100644 env_configs/pure.Dockerfile create mode 100644 flask_inputfilter/InputFilter.py create mode 100644 flask_inputfilter/InputFilter.pyi create mode 100644 flask_inputfilter/Mixin/ExternalApiMixin.py rename flask_inputfilter/Mixin/{ExternalApiMixin.pyx => _ExternalApiMixin.pyx} (100%) rename flask_inputfilter/{InputFilter.pyx => _InputFilter.pyx} (100%) diff --git a/.github/workflows/publish-to-pypi.yaml b/.github/workflows/publish-to-pypi.yaml index a88ef84..275a138 100644 --- a/.github/workflows/publish-to-pypi.yaml +++ b/.github/workflows/publish-to-pypi.yaml @@ -12,7 +12,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: 3.11 diff --git a/.github/workflows/test_env.yaml b/.github/workflows/test-env.yaml similarity index 89% rename from .github/workflows/test_env.yaml rename to .github/workflows/test-env.yaml index a65f827..54b245f 100644 --- a/.github/workflows/test_env.yaml +++ b/.github/workflows/test-env.yaml @@ -17,12 +17,12 @@ jobs: uses: actions/cache@v4 with: path: /tmp/.buildx-cache - key: ${{ runner.os }}-docker-flask-inputfilter-env-${{ hashFiles('env_configs/Dockerfile') }} + key: ${{ runner.os }}-docker-flask-inputfilter-env-${{ hashFiles('env_configs/env.Dockerfile') }} restore-keys: | ${{ runner.os }}-docker-flask-inputfilter-env- - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 with: driver-opts: image=moby/buildkit:latest @@ -32,7 +32,7 @@ jobs: --cache-from=type=local,src=/tmp/.buildx-cache \ --cache-to=type=local,dest=/tmp/.buildx-cache,mode=max \ -t flask-inputfilter-env \ - -f env_configs/Dockerfile \ + -f env_configs/env.Dockerfile \ . --load - name: Run tests in Docker diff --git a/.github/workflows/test-lib-building.yaml b/.github/workflows/test-lib-building.yaml index a1c2640..d91a51d 100644 --- a/.github/workflows/test-lib-building.yaml +++ b/.github/workflows/test-lib-building.yaml @@ -7,13 +7,16 @@ on: pull_request: jobs: - build-and-test: + build-and-test-pure: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - name: Remove g++ compiler + run: sudo apt-get remove --purge -y g++ + + - uses: actions/setup-python@v5 with: python-version: 3.11 @@ -25,10 +28,43 @@ jobs: id: build - name: Install built library - run: pip install "$(ls dist/*.whl | head -n 1)[optional]" + run: pip install "$(ls dist/*.tar.gz | head -n 1)[optional]" - name: Verify library usage - Part I - run: python -c "import flask_inputfilter.InputFilter" + run: | + python -c "import flask_inputfilter.InputFilter" + python -c "from flask_inputfilter import InputFilter" - name: Verify library usage - Part II run: pytest tests/ + + build-and-test-cython: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install compilers + run: sudo apt-get install -y g++ + + - uses: actions/setup-python@v5 + with: + python-version: 3.11 + + - name: Install build tools and test dependencies + run: pip install build pytest + + - name: Build the library + run: python -m build + id: build + + - name: Install built library + run: pip install "$(ls dist/*.tar.gz | head -n 1)[optional]" + + - name: Verify library usage - Part I + run: | + python -c "import flask_inputfilter.InputFilter" + python -c "from flask_inputfilter import InputFilter" + + #- name: Verify library usage - Part II + # run: pytest tests/ diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a1e1c69..c4baec6 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -7,7 +7,7 @@ permissions: contents: read jobs: - build: + build-and-test-pure: runs-on: ubuntu-latest steps: @@ -17,21 +17,21 @@ jobs: uses: actions/cache@v4 with: path: /tmp/.buildx-cache - key: ${{ runner.os }}-docker-flask-inputfilter-${{ hashFiles('Dockerfile') }} + key: ${{ runner.os }}-docker-flask-inputfilter-pure-${{ hashFiles('env_configs/pure.Dockerfile') }} restore-keys: | - ${{ runner.os }}-docker-flask-inputfilter- + ${{ runner.os }}-docker-flask-inputfilter-pure- - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 with: driver-opts: image=moby/buildkit:latest - - name: Build flask-inputfilter image with cache + - name: Build flask-inputfilter-pure image with cache run: | docker buildx build \ --cache-from=type=local,src=/tmp/.buildx-cache \ --cache-to=type=local,dest=/tmp/.buildx-cache,mode=max \ - -t flask-inputfilter . --load + -t flask-inputfilter-pure -f env_configs/pure.Dockerfile . --load - name: Run tests in Docker and upload coverage to Coveralls env: @@ -41,7 +41,7 @@ jobs: set -e # Exit immediately if a command exits with a non-zero status. set -u # Exit immediately if a variable is not defined. - docker run --rm -e COVERALLS_REPO_TOKEN=${{ secrets.COVERALLS_REPO_TOKEN }} flask-inputfilter sh -c "coverage run --source=flask_inputfilter -m pytest tests/ && coveralls" + docker run --rm -e COVERALLS_REPO_TOKEN=${{ secrets.COVERALLS_REPO_TOKEN }} flask-inputfilter-pure sh -c "coverage run --source=flask_inputfilter -m pytest tests/ && coveralls" - name: Run code style checks run: | @@ -49,4 +49,40 @@ jobs: set -e # Exit immediately if a command exits with a non-zero status set -u # Exit immediately if a variable is not defined - docker run --rm flask-inputfilter flake8 + docker run --rm flask-inputfilter-pure flake8 + + build-and-test-cython: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-docker-flask-inputfilter-cython-${{ hashFiles('env_configs/cython.Dockerfile') }} + restore-keys: | + ${{ runner.os }}-docker-flask-inputfilter-cython- + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver-opts: image=moby/buildkit:latest + + - name: Build flask-inputfilter-cython image with cache + run: | + docker buildx build \ + --cache-from=type=local,src=/tmp/.buildx-cache \ + --cache-to=type=local,dest=/tmp/.buildx-cache,mode=max \ + -t flask-inputfilter-cython -f env_configs/cython.Dockerfile . --load + + - name: Run tests in Docker + env: + COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} + run: | + set -x # Print commands and their arguments as they are executed. + set -e # Exit immediately if a command exits with a non-zero status. + set -u # Exit immediately if a variable is not defined. + + docker run --rm flask-inputfilter-cython pytest diff --git a/docker-compose.yaml b/docker-compose.yaml index c741221..28b6ec4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,7 +3,7 @@ services: flask-inputfilter: build: context: . - dockerfile: Dockerfile + dockerfile: env_configs/cython.Dockerfile container_name: flask-inputfilter volumes: - .:/app diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 194514b..6c5bc22 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -3,6 +3,15 @@ Changelog All notable changes to this project will be documented in this file. +[0.4.0a2] - 2025-04-17 +---------------------- + +Changed +^^^^^^^ +- Added fallback for ``cython`` to use ``python`` if no c++ compiler is installed. +- super().__init__() is now **ONLY** optional, if you are using the cython version. + + [0.4.0a1] - 2025-04-17 ---------------------- diff --git a/docs/source/conf.py b/docs/source/conf.py index 7b5cfa8..8335ef5 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,7 +1,7 @@ project = "flask-inputfilter" copyright = "2025, Leander Cain Slotosch" author = "Leander Cain Slotosch" -release = "0.4.0a1" +release = "0.4.0a2" extensions = ["sphinx_rtd_theme"] diff --git a/docs/source/guides/frontend_validation.rst b/docs/source/guides/frontend_validation.rst index 388eecb..6f77d9d 100644 --- a/docs/source/guides/frontend_validation.rst +++ b/docs/source/guides/frontend_validation.rst @@ -24,6 +24,7 @@ Example implementation class UpdateZipcodeInputFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'id', diff --git a/docs/source/index.rst b/docs/source/index.rst index 99c3587..1f1a304 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -63,6 +63,7 @@ Definition class UpdateZipcodeInputFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'id', diff --git a/docs/source/options/condition.rst b/docs/source/options/condition.rst index b5d6079..37613ee 100644 --- a/docs/source/options/condition.rst +++ b/docs/source/options/condition.rst @@ -20,6 +20,7 @@ Example class TestInputFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'username', @@ -88,6 +89,7 @@ Validates that the length of the array from ``first_array_field`` is equal to th class ArrayLengthFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'list1', @@ -127,6 +129,7 @@ Validates that the array in ``longer_field`` has more elements than the array in class ArrayComparisonFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'list1', @@ -168,6 +171,7 @@ Executes the provided callable with the input data. The condition passes if the class CustomFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'age', @@ -201,6 +205,7 @@ Validates that the values of ``first_field`` and ``second_field`` are equal. Fai class EqualFieldsFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'password' @@ -237,6 +242,7 @@ Counts the number of specified fields present in the data and validates that the class ExactFieldsFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'field1' @@ -278,6 +284,7 @@ Validates that exactly ``n`` fields among the specified ones have the given valu class MatchFieldsFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'field1' @@ -313,6 +320,7 @@ Validates that only one field among the specified fields exists in the input dat class OneFieldFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'email' @@ -349,6 +357,7 @@ Validates that exactly one of the specified fields has the given value. class OneMatchFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'option1' @@ -386,6 +395,7 @@ Validates that the integer value from ``bigger_field`` is greater than the value class NumberComparisonFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'field_should_be_bigger', @@ -424,6 +434,7 @@ Validates that the count of the specified fields present is greater than or equa class MinimumFieldsFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'field1' @@ -465,6 +476,7 @@ Validates that the count of fields matching the given value is greater than or e class MinimumMatchFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'field1' @@ -501,6 +513,7 @@ Validates that the values of ``first_field`` and ``second_field`` are not equal. class DifferenceFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'field1' @@ -536,6 +549,7 @@ Validates that at least one field from the specified list is present. Fails if n class OneFieldRequiredFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'email' @@ -572,6 +586,7 @@ Validates that at least one field from the specified list has the given value. class OneMatchRequiredFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'option1' @@ -609,6 +624,7 @@ If the value of ``condition_field`` matches the specified value (or is in the sp class ConditionalRequiredFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'status' @@ -651,6 +667,7 @@ Validates that the string in ``longer_field`` has a greater length than the stri class StringLengthFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'description' @@ -687,6 +704,7 @@ Validates that the date in ``smaller_date_field`` is earlier than the date in `` class DateOrderFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'start_date' diff --git a/docs/source/options/copy.rst b/docs/source/options/copy.rst index edc3268..b7b62b9 100644 --- a/docs/source/options/copy.rst +++ b/docs/source/options/copy.rst @@ -24,6 +24,7 @@ Basic Copy Integration class MyInputFilter(InputFilter): def __init__(self): + super().__init__() self.add( "username" @@ -56,6 +57,7 @@ The coping can also be used as a chain. class MyInputFilter(InputFilter): def __init__(self): + super().__init__() self.add( "username" diff --git a/docs/source/options/deserialization.rst b/docs/source/options/deserialization.rst index 1b57b7b..4f749f1 100644 --- a/docs/source/options/deserialization.rst +++ b/docs/source/options/deserialization.rst @@ -35,6 +35,7 @@ into an instance of the model class, if there is a model class set. class UserInputFilter(InputFilter): def __init__(self): + super().__init__() self.setModel(User) diff --git a/docs/source/options/external_api.rst b/docs/source/options/external_api.rst index e59c2b8..4db3f8b 100644 --- a/docs/source/options/external_api.rst +++ b/docs/source/options/external_api.rst @@ -57,6 +57,7 @@ Basic External API Integration class MyInputFilter(InputFilter): def __init__(self): + super().__init__() self.add( "user_id", required=True diff --git a/docs/source/options/filter.rst b/docs/source/options/filter.rst index b00fa17..cfd8e57 100644 --- a/docs/source/options/filter.rst +++ b/docs/source/options/filter.rst @@ -20,6 +20,7 @@ Example class TestInputFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'username', @@ -94,6 +95,7 @@ If the input value is a string, it returns a list of substrings. For non-string class TagFilter(InputFilter): def __init__(self): + super().__init__() self.add('tags', filters=[ ArrayExplodeFilter(delimiter=";") @@ -125,6 +127,7 @@ If the image (or its base64 representation) exceeds the target dimensions, the f class ImageFilter(InputFilter): def __init__(self): + super().__init__() self.add('profile_pic', filters=[ Base64ImageDownscaleFilter(size=1024*1024) @@ -156,6 +159,7 @@ The filter resizes and compresses the image iteratively until its size is below class AvatarFilter(InputFilter): def __init__(self): + super().__init__() self.add('avatar', filters=[ Base64ImageResizeFilter(max_size=4*1024*1024) @@ -185,6 +189,7 @@ Filters out unwanted substrings or keys based on a predefined blacklist. class CommentFilter(InputFilter): def __init__(self): + super().__init__() self.add('comment', filters=[ BlacklistFilter(blacklist=["badword1", "badword2"]) @@ -209,6 +214,7 @@ If the input is a string, all emoji characters are removed; non-string inputs ar class CommentFilter(InputFilter): def __init__(self): + super().__init__() self.add('comment', filters=[ StringRemoveEmojisFilter() @@ -233,6 +239,7 @@ Normalizes Unicode, converts to ASCII, lowercases the string, and replaces space class PostFilter(InputFilter): def __init__(self): + super().__init__() self.add('title', filters=[ StringSlugifyFilter() @@ -257,6 +264,7 @@ If the input is a string, it returns the trimmed version. Otherwise, the value r class UserFilter(InputFilter): def __init__(self): + super().__init__() self.add('username', filters=[ StringTrimFilter() @@ -281,6 +289,7 @@ Strips out any character that is not a letter, digit, or underscore from the inp class CodeFilter(InputFilter): def __init__(self): + super().__init__() self.add('code', filters=[ ToAlphaNumericFilter() @@ -305,6 +314,7 @@ Uses Python’s built-in ``bool()`` conversion. Note that non-empty strings and class ActiveFilter(InputFilter): def __init__(self): + super().__init__() self.add('active', filters=[ ToBooleanFilter() @@ -329,6 +339,7 @@ Normalizes delimiters such as spaces, underscores, or hyphens, capitalizes each class IdentifierFilter(InputFilter): def __init__(self): + super().__init__() self.add('identifier', filters=[ ToCamelCaseFilter() @@ -357,6 +368,7 @@ If the input is a dictionary, it instantiates the provided dataclass using the d class DataFilter(InputFilter): def __init__(self): + super().__init__() self.add('data', filters=[ ToDataclassFilter(MyDataClass) @@ -383,6 +395,7 @@ Converts an input value to a ``date`` object. Supports ISO 8601 formatted string class BirthdateFilter(InputFilter): def __init__(self): + super().__init__() self.add('birthdate', filters=[ ToDateFilter() @@ -410,6 +423,7 @@ Converts an input value to a ``datetime`` object. Supports ISO 8601 formatted st class TimestampFilter(InputFilter): def __init__(self): + super().__init__() self.add('timestamp', filters=[ ToDateTimeFilter() @@ -436,6 +450,7 @@ Converts a string to a numeric type (either an integer or a float). class QuantityFilter(InputFilter): def __init__(self): + super().__init__() self.add('quantity', filters=[ ToDigitsFilter() @@ -467,6 +482,7 @@ Converts a value to an instance of a specified Enum. class ColorFilter(InputFilter): def __init__(self): + super().__init__() self.add('color', filters=[ ToEnumFilter(ColorEnum) @@ -489,6 +505,7 @@ Converts the input value to a float. class PriceFilter(InputFilter): def __init__(self): + super().__init__() self.add('price', filters=[ ToFloatFilter() @@ -513,6 +530,7 @@ Converts the input value to an integer. class AgeFilter(InputFilter): def __init__(self): + super().__init__() self.add('age', filters=[ ToIntegerFilter() @@ -537,6 +555,7 @@ Converts a date or datetime object to an ISO 8601 formatted string. class TimestampIsoFilter(InputFilter): def __init__(self): + super().__init__() self.add('timestamp', filters=[ ToIsoFilter() @@ -561,6 +580,7 @@ Converts a string to lowercase. class UsernameFilter(InputFilter): def __init__(self): + super().__init__() self.add('username', filters=[ ToLowerFilter() @@ -587,6 +607,7 @@ Normalizes a Unicode string to a specified form. class TextFilter(InputFilter): def __init__(self): + super().__init__() self.add('text', filters=[ ToNormalizedUnicodeFilter(form="NFKC") @@ -609,6 +630,7 @@ Transforms the input to ``None`` if it is an empty string or already ``None``. class MiddleNameFilter(InputFilter): def __init__(self): + super().__init__() self.add('middle_name', filters=[ ToNullFilter() @@ -631,6 +653,7 @@ Converts a string to PascalCase. class ClassNameFilter(InputFilter): def __init__(self): + super().__init__() self.add('class_name', filters=[ ToPascalCaseFilter() @@ -653,6 +676,7 @@ Converts a string to snake_case. class VariableFilter(InputFilter): def __init__(self): + super().__init__() self.add('variableName', filters=[ ToSnakeCaseFilter() @@ -674,6 +698,7 @@ Converts any input value to its string representation. class IdFilter(InputFilter): def __init__(self): + super().__init__() self.add('id', filters=[ ToStringFilter() @@ -700,6 +725,7 @@ Converts a dictionary into an instance of a specified TypedDict. class ConfigFilter(InputFilter): def __init__(self): + super().__init__() self.add('config', filters=[ ToTypedDictFilter(MyTypedDict) @@ -722,6 +748,7 @@ Converts a string to uppercase. class CodeFilter(InputFilter): def __init__(self): + super().__init__() self.add('code', filters=[ ToUpperFilter() @@ -748,6 +775,7 @@ Truncates a string to a specified maximum length. class DescriptionFilter(InputFilter): def __init__(self): + super().__init__() self.add('description', filters=[ TruncateFilter(max_length=100) @@ -775,6 +803,7 @@ Filters the input by only keeping elements that appear in a predefined whitelist class RolesFilter(InputFilter): def __init__(self): + super().__init__() self.add('roles', filters=[ WhitelistFilter(whitelist=["admin", "user"]) @@ -797,6 +826,7 @@ Collapses multiple consecutive whitespace characters into a single space. class AddressFilter(InputFilter): def __init__(self): + super().__init__() self.add('address', filters=[ WhitespaceCollapseFilter() diff --git a/docs/source/options/special_validator.rst b/docs/source/options/special_validator.rst index 702a8e7..bf5d634 100644 --- a/docs/source/options/special_validator.rst +++ b/docs/source/options/special_validator.rst @@ -17,6 +17,8 @@ Example class NotIntegerInputFilter(InputFilter): def __init__(self): + super().__init__() + self.add('value', validators=[ NotValidator(validator=IsIntegerValidator()) ]) @@ -59,6 +61,8 @@ The validator sequentially applies each validator in the provided list to the in class AndInputFilter(InputFilter): def __init__(self): + super().__init__() + self.add('value', validators=[ AndValidator([IsIntegerValidator(), RangeValidator(min_value=0, max_value=100)]) ]) @@ -87,6 +91,8 @@ Executes the inner validator on the input. If the inner validator does not raise class NotIntegerInputFilter(InputFilter): def __init__(self): + super().__init__() + self.add('value', validators=[ NotValidator(validator=IsIntegerValidator()) ]) @@ -116,6 +122,8 @@ The validator applies each validator in the provided list to the input value. If class OrInputFilter(InputFilter): def __init__(self): + super().__init__() + self.add('value', validators=[ OrValidator([IsIntegerValidator(), IsStringValidator()]) ]) @@ -145,6 +153,7 @@ The validator applies each validator in the provided list to the input value and class XorInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('value', validators=[ XorValidator([IsIntegerValidator(), IsStringValidator()]) diff --git a/docs/source/options/validator.rst b/docs/source/options/validator.rst index 788298d..3f67d97 100644 --- a/docs/source/options/validator.rst +++ b/docs/source/options/validator.rst @@ -22,6 +22,7 @@ Example class UpdatePointsInputFilter(InputFilter): def __init__(self): + super().__init__() self.add( 'id', @@ -121,6 +122,7 @@ Verifies that the input is a list and then applies the provided filter to each e class TagInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('tags', validators=[ ArrayElementValidator(elementFilter=MyElementFilter()) @@ -151,6 +153,7 @@ Ensures that the input is a list and that its length is between the specified mi class ListInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('items', validators=[ ArrayLengthValidator(min_length=1, max_length=5) @@ -181,6 +184,7 @@ If the input is a string, it attempts to parse it as JSON. It then confirms that class JsonInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('data', validators=[ CustomJsonValidator( @@ -213,6 +217,7 @@ Converts both the input and the reference date to datetime objects and verifies class EventInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('event_date', validators=[ DateAfterValidator(reference_date="2023-01-01") @@ -242,6 +247,7 @@ Parses the input and reference date into datetime objects and checks that the in class RegistrationInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('birth_date', validators=[ DateBeforeValidator(reference_date="2005-01-01") @@ -272,6 +278,7 @@ Ensures the input date is not earlier than ``min_date`` and not later than ``max class BookingInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('booking_date', validators=[ DateRangeValidator(min_date="2023-01-01", max_date="2023-12-31") @@ -302,6 +309,7 @@ Converts the number to a string and checks the total number of digits and the di class PriceInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('price', validators=[ FloatPrecisionValidator(precision=5, scale=2) @@ -332,6 +340,7 @@ Verifies that the value is present in the list. In strict mode, type compatibili class StatusInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('status', validators=[ InArrayValidator(haystack=["active", "inactive"]) @@ -367,6 +376,7 @@ Performs a case-insensitive comparison to ensure that the value matches one of t class ColorInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('color', validators=[ InEnumValidator(enumClass=ColorEnum) @@ -395,6 +405,7 @@ Raises a ``ValidationError`` if the input is not a list. class ListInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('items', validators=[ IsArrayValidator() @@ -425,6 +436,7 @@ Decodes the Base64 string to determine the image size and raises a ``ValidationE class ImageInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('image', validators=[ IsBase64ImageCorrectSizeValidator(minSize=1024, maxSize=2 * 1024 * 1024) @@ -453,6 +465,7 @@ Attempts to decode the Base64 string and open the image using the PIL library. I class AvatarInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('avatar', validators=[ IsBase64ImageValidator() @@ -481,6 +494,7 @@ Raises a ``ValidationError`` if the input value is not of type bool. class FlagInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('is_active', validators=[ IsBooleanValidator() @@ -516,6 +530,7 @@ Ensures the input is a dictionary and, that all expected keys are present. Raise class UserInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('user', validators=[ IsDataclassValidator(dataclass_type=User) @@ -544,6 +559,7 @@ Raises a ``ValidationError`` if the input value is not of type float. class MeasurementInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('temperature', validators=[ IsFloatValidator() @@ -572,6 +588,7 @@ Parses the input date and compares it to the current date and time. If the input class AppointmentInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('appointment_date', validators=[ IsFutureDateValidator() @@ -600,12 +617,13 @@ Verifies that the input is a string and attempts to convert it to an integer usi class HexInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('hex_value', validators=[ IsHexadecimalValidator() ]) IsHorizontalImageValidator -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Description:** Ensures that the provided image is horizontally oriented. This validator accepts either a Base64 encoded string or an image object. @@ -627,6 +645,7 @@ Decodes the image (if provided as a string) and checks that its width is greater class HorizontalImageInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('image', validators=[ IsHorizontalImageValidator() ]) @@ -655,12 +674,13 @@ Verifies that the input is a string and checks for HTML tags using a regular exp class HtmlInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('html_content', validators=[ IsHtmlValidator() ]) IsInstanceValidator -~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~ **Description:** Validates that the provided value is an instance of a specified class. @@ -686,12 +706,13 @@ Raises a ``ValidationError`` if the input is not an instance of the specified cl class InstanceInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('object', validators=[ IsInstanceValidator(classType=MyClass) ]) IsIntegerValidator -~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~ **Description:** Checks whether the provided value is an integer. @@ -713,6 +734,7 @@ Raises a ``ValidationError`` if the input value is not of type int. class NumberInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('number', validators=[ IsIntegerValidator() ]) @@ -740,6 +762,7 @@ Attempts to parse the input using JSON decoding. Raises a ``ValidationError`` if class JsonInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('json_data', validators=[ IsJsonValidator() ]) @@ -768,6 +791,7 @@ Confirms that the input is a string and verifies that all characters are lowerca class LowercaseInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('username', validators=[ IsLowercaseValidator() ]) @@ -797,6 +821,7 @@ Ensures the input is a string and matches a regular expression pattern for MAC a class NetworkInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('mac_address', validators=[ IsMacAddressValidator() ]) @@ -824,6 +849,7 @@ Parses the input date and verifies that it is earlier than the current date and class HistoryInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('past_date', validators=[ IsPastDateValidator() ]) @@ -852,6 +878,7 @@ Ensures that the input is an integer and that it lies within the valid range for class PortInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('port', validators=[ IsPortValidator() ]) @@ -881,6 +908,7 @@ Verifies that the input is a string, matches the RGB color format using a regula class ColorInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('color', validators=[ IsRgbColorValidator() ]) @@ -909,6 +937,7 @@ Ensures that the input is a string and matches the expected slug pattern (e.g., class SlugInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('slug', validators=[ IsSlugValidator() ]) @@ -936,6 +965,7 @@ Raises a ``ValidationError`` if the input is not of type str. class TextInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('text', validators=[ IsStringValidator() ]) @@ -969,6 +999,7 @@ Ensures the input is a dictionary and, that all expected keys are present. Raise class PersonInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('person', validators=[ IsTypedDictValidator(typed_dict_type=PersonDict) ]) @@ -997,6 +1028,7 @@ Ensures that the input is a string and that all characters are uppercase using t class UppercaseInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('code', validators=[ IsUppercaseValidator() ]) @@ -1026,6 +1058,7 @@ Verifies that the input is a string and uses URL parsing (via ``urllib.parse.url class UrlInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('website', validators=[ IsUrlValidator() ]) @@ -1053,6 +1086,7 @@ Verifies that the input is a string and attempts to parse it as a UUID. Raises a class UUIDInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('uuid', validators=[ IsUUIDValidator() ]) @@ -1080,6 +1114,7 @@ Decodes the image (if provided as a string) and checks that its height is greate class VerticalImageInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('image', validators=[ IsVerticalImageValidator() ]) @@ -1107,6 +1142,7 @@ Parses the input date and verifies that it corresponds to a weekday. Raises a `` class WorkdayInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('date', validators=[ IsWeekdayValidator() ]) @@ -1134,6 +1170,7 @@ Parses the input date and confirms that it corresponds to a weekend day. Raises class WeekendInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('date', validators=[ IsWeekendValidator() ]) @@ -1163,6 +1200,7 @@ Checks the length of the input string and raises a ``ValidationError`` if it is class TextLengthInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('username', validators=[ LengthValidator(min_length=3, max_length=15) ]) @@ -1193,6 +1231,7 @@ Raises a ``ValidationError`` if the value is found in the disallowed list, or if class UsernameInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('username', validators=[ NotInArrayValidator(haystack=["admin", "root"]) ]) @@ -1222,6 +1261,7 @@ Verifies that the numeric input is not less than ``min_value`` and not greater t class ScoreInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('score', validators=[ RangeValidator(min_value=0, max_value=100) ]) @@ -1250,7 +1290,7 @@ Uses the Python ``re`` module to compare the input string against the provided p class EmailInputFilter(InputFilter): def __init__(self): + super().__init__() self.add('email', validators=[ RegexValidator(pattern=r"[^@]+@[^@]+\.[^@]+") ]) - diff --git a/Dockerfile b/env_configs/cython.Dockerfile similarity index 71% rename from Dockerfile rename to env_configs/cython.Dockerfile index 162a08e..980aec9 100644 --- a/Dockerfile +++ b/env_configs/cython.Dockerfile @@ -2,13 +2,13 @@ FROM python:3.7-slim WORKDIR /app -RUN apt-get update && apt-get install -y gcc g++ python3-dev git +RUN apt-get update && apt-get install -y g++ python3-dev git COPY pyproject.toml /app RUN python -m pip install .[dev] -COPY . /app +COPY .. /app COPY scripts /usr/local/bin RUN find /usr/local/bin -type f -name "*" -exec chmod +x {} \; diff --git a/env_configs/Dockerfile b/env_configs/env.Dockerfile similarity index 99% rename from env_configs/Dockerfile rename to env_configs/env.Dockerfile index 876857d..e2df914 100644 --- a/env_configs/Dockerfile +++ b/env_configs/env.Dockerfile @@ -5,7 +5,6 @@ WORKDIR /app RUN apt-get update && apt-get install -y \ build-essential \ curl \ - gcc \ g++ \ git \ libbz2-dev \ diff --git a/env_configs/pure.Dockerfile b/env_configs/pure.Dockerfile new file mode 100644 index 0000000..9001419 --- /dev/null +++ b/env_configs/pure.Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.7-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y python3-dev git + +COPY pyproject.toml /app + +RUN python -m pip install .[dev] + +COPY .. /app + +COPY scripts /usr/local/bin +RUN find /usr/local/bin -type f -name "*" -exec chmod +x {} \; diff --git a/env_configs/requirements-py310.txt b/env_configs/requirements-py310.txt index 39f6518..84f3eee 100644 --- a/env_configs/requirements-py310.txt +++ b/env_configs/requirements-py310.txt @@ -4,4 +4,3 @@ pillow==8.0.0 pytest requests==2.22.0 Werkzeug==2.0.3 -typing_extensions diff --git a/env_configs/requirements-py311.txt b/env_configs/requirements-py311.txt index e843411..d1575ca 100644 --- a/env_configs/requirements-py311.txt +++ b/env_configs/requirements-py311.txt @@ -4,4 +4,3 @@ pillow==8.0.0 pytest requests==2.22.0 Werkzeug==2.0.3 -typing_extensions diff --git a/env_configs/requirements-py312.txt b/env_configs/requirements-py312.txt index 6e3108f..c12cdfa 100644 --- a/env_configs/requirements-py312.txt +++ b/env_configs/requirements-py312.txt @@ -4,4 +4,3 @@ flask pillow pytest requests -typing_extensions diff --git a/env_configs/requirements-py313.txt b/env_configs/requirements-py313.txt index 4427213..ad424ba 100644 --- a/env_configs/requirements-py313.txt +++ b/env_configs/requirements-py313.txt @@ -4,4 +4,3 @@ flask pillow pytest requests -typing_extensions diff --git a/env_configs/requirements-py314.txt b/env_configs/requirements-py314.txt index 4427213..ad424ba 100644 --- a/env_configs/requirements-py314.txt +++ b/env_configs/requirements-py314.txt @@ -4,4 +4,3 @@ flask pillow pytest requests -typing_extensions diff --git a/env_configs/requirements-py38.txt b/env_configs/requirements-py38.txt index a2a67b4..2ca2835 100644 --- a/env_configs/requirements-py38.txt +++ b/env_configs/requirements-py38.txt @@ -4,4 +4,3 @@ pillow==8.0.0 pytest requests==2.22.0 Werkzeug==2.0.3 -typing_extensions diff --git a/env_configs/requirements-py39.txt b/env_configs/requirements-py39.txt index 7d3e1d6..faf3f4d 100644 --- a/env_configs/requirements-py39.txt +++ b/env_configs/requirements-py39.txt @@ -4,4 +4,3 @@ pillow==8.0.0 pytest requests==2.22.0 Werkzeug==2.0.3 -typing_extensions diff --git a/flask_inputfilter/Filter/ToNormalizedUnicodeFilter.py b/flask_inputfilter/Filter/ToNormalizedUnicodeFilter.py index 145b695..387868d 100644 --- a/flask_inputfilter/Filter/ToNormalizedUnicodeFilter.py +++ b/flask_inputfilter/Filter/ToNormalizedUnicodeFilter.py @@ -3,8 +3,6 @@ import unicodedata from typing import Any, Union -from typing_extensions import Literal - from flask_inputfilter.Enum import UnicodeFormEnum from flask_inputfilter.Filter import BaseFilter @@ -18,9 +16,7 @@ class ToNormalizedUnicodeFilter(BaseFilter): def __init__( self, - form: Union[ - UnicodeFormEnum, Literal["NFC", "NFD", "NFKC", "NFKD"] - ] = UnicodeFormEnum.NFC, + form: UnicodeFormEnum = UnicodeFormEnum.NFC, ) -> None: if not isinstance(form, UnicodeFormEnum): form = UnicodeFormEnum(form) diff --git a/flask_inputfilter/Filter/ToTypedDictFilter.py b/flask_inputfilter/Filter/ToTypedDictFilter.py index 2162dc4..ea203ee 100644 --- a/flask_inputfilter/Filter/ToTypedDictFilter.py +++ b/flask_inputfilter/Filter/ToTypedDictFilter.py @@ -1,8 +1,6 @@ from __future__ import annotations -from typing import Any, Type - -from typing_extensions import TypedDict +from typing import Any from flask_inputfilter.Filter import BaseFilter @@ -14,7 +12,13 @@ class ToTypedDictFilter(BaseFilter): __slots__ = ("typed_dict",) - def __init__(self, typed_dict: Type[TypedDict]) -> None: + def __init__(self, typed_dict) -> None: + """ + Parameters: + typed_dict (Type[TypedDict]): The TypedDict class + to convert the dictionary to. + """ + self.typed_dict = typed_dict def apply(self, value: Any) -> Any: diff --git a/flask_inputfilter/InputFilter.py b/flask_inputfilter/InputFilter.py new file mode 100644 index 0000000..2992c03 --- /dev/null +++ b/flask_inputfilter/InputFilter.py @@ -0,0 +1,888 @@ +from __future__ import annotations + +import json +import logging +from typing import Any, Dict, List, Optional, Type, TypeVar, Union + +from flask import Response, g, request + +from flask_inputfilter.Condition import BaseCondition +from flask_inputfilter.Exception import ValidationError +from flask_inputfilter.Filter import BaseFilter +from flask_inputfilter.Mixin import ExternalApiMixin +from flask_inputfilter.Model import ExternalApiConfig, FieldModel +from flask_inputfilter.Validator import BaseValidator + +T = TypeVar("T") + + +class InputFilter: + """ + Base class for all input filters. + """ + + def __init__(self, methods: Optional[List[str]] = None) -> None: + 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] = [] + self.global_validators: List[BaseValidator] = [] + self.data: Dict[str, Any] = {} + self.validated_data: Dict[str, Any] = {} + self.errors: Dict[str, str] = {} + self.model_class: Optional = None + + def isValid(self): + """ + Checks if the object's state or its attributes meet certain + conditions to be considered valid. This function is typically used to + ensure that the current state complies with specific requirements or + rules. + + Returns: + bool: Returns True if the state or attributes of the object fulfill + all required conditions; otherwise, returns False. + """ + try: + self.validateData() + + except ValidationError as e: + self.errors = e.args[0] + return False + + return True + + @classmethod + def validate( + cls, + ): + """ + Decorator for validating input data in routes. + + Args: + cls + + Returns: + Callable[ + [Any], + Callable[ + [Tuple[Any, ...], Dict[str, Any]], + Union[Response, Tuple[Any, Dict[str, Any]]], + ], + ] + """ + + def decorator( + f, + ): + """ + Decorator function to validate input data for a Flask route. + + Args: + f (Callable): The Flask route function to be decorated. + + Returns: + Callable[ + [Any, Any], + Union[ + Response, + Tuple[Any, Dict[str, Any]] + ] + ]: The wrapped function with input validation. + """ + + def wrapper(*args, **kwargs): + """ + Wrapper function to handle input validation and + error handling for the decorated route function. + + Args: + *args: Positional arguments for the route function. + **kwargs: Keyword arguments for the route function. + + Returns: + Union[Response, Tuple[Any, Dict[str, Any]]]: The response + from the route function or an error response. + """ + + input_filter = cls() + if request.method not in input_filter.methods: + return Response(status=405, response="Method Not Allowed") + + data = request.json if request.is_json else request.args + + try: + kwargs = kwargs or {} + + input_filter.data = {**data, **kwargs} + + g.validated_data = input_filter.validateData() + + except ValidationError as e: + return Response( + status=400, + response=json.dumps(e.args[0]), + mimetype="application/json", + ) + + except Exception: + logging.getLogger(__name__).exception( + "An unexpected exception occurred while " + "validating input data.", + ) + return Response(status=500) + + return f(*args, **kwargs) + + return wrapper + + return decorator + + def validateData(self, data: Optional[Dict[str, Any]] = None): + """ + Validates input data against defined field rules, including applying + filters, validators, custom logic steps, and fallback mechanisms. The + validation process also ensures the required fields are handled + appropriately and conditions are checked after processing. + + Args: + data (Dict[str, Any]): A dictionary containing the input data to + be validated where keys represent field names and values + represent the corresponding data. + + Returns: + Union[Dict[str, Any], Type[T]]: A dictionary containing the + validated data with any modifications, default values, + or processed values as per the defined validation rules. + + Raises: + Any errors raised during external API calls, validation, or + logical steps execution of the respective fields or conditions + will propagate without explicit handling here. + """ + data = data or self.data + errors = {} + + for field_name, field_info in self.fields.items(): + value = data.get(field_name) + + required = field_info.required + default = field_info.default + fallback = field_info.fallback + filters = field_info.filters + validators = field_info.validators + steps = field_info.steps + external_api = field_info.external_api + copy = field_info.copy + + try: + if copy: + value = self.validated_data.get(copy) + + if external_api: + value = ExternalApiMixin().callExternalApi( + external_api, fallback, self.validated_data + ) + + value = self.applyFilters(filters, value) + value = ( + self.validateField(validators, fallback, value) or value + ) + value = self.applySteps(steps, fallback, value) or value + value = InputFilter.checkForRequired( + field_name, required, default, fallback, value + ) + + self.validated_data[field_name] = value + + except ValidationError as e: + errors[field_name] = str(e) + + try: + self.checkConditions(self.validated_data) + except ValidationError as e: + errors["_condition"] = str(e) + + if errors: + raise ValidationError(errors) + + if self.model_class is not None: + return self.serialize() + + return self.validated_data + + def addCondition(self, condition: BaseCondition): + """ + Add a condition to the input filter. + + Args: + condition (BaseCondition): The condition to add. + """ + self.conditions.append(condition) + + def getConditions(self): + """ + Retrieve the list of all registered conditions. + + This function provides access to the conditions that have been + registered and stored. Each condition in the returned list + is represented as an instance of the BaseCondition type. + + Returns: + List[BaseCondition]: A list containing all currently registered + instances of BaseCondition. + """ + return self.conditions + + def checkConditions(self, validated_data: Dict[str, Any]): + """ + Checks if all conditions are met. + + This method iterates through all registered conditions and checks + if they are satisfied based on the provided validated data. If any + condition is not met, a ValidationError is raised with an appropriate + message indicating which condition failed. + + Args: + validated_data (Dict[str, Any]): + The validated data to check against the conditions. + """ + for condition in self.conditions: + if not condition.check(validated_data): + raise ValidationError( + f"Condition '{condition.__class__.__name__}' not met." + ) + + def setData(self, data: Dict[str, Any]): + """ + Filters and sets the provided data into the object's internal + storage, ensuring that only the specified fields are considered and + their values are processed through defined filters. + + Parameters: + data (Dict[str, Any]): + The input dictionary containing key-value pairs where keys + represent field names and values represent the associated + data to be filtered and stored. + """ + self.data = {} + for field_name, field_value in data.items(): + if field_name in self.fields: + field_value = self.applyFilters( + filters=self.fields[field_name].filters, + value=field_value, + ) + + self.data[field_name] = field_value + + def getValue(self, name: str): + """ + This method retrieves a value associated with the provided name. It + searches for the value based on the given identifier and returns the + corresponding result. If no value is found, it typically returns a + default or fallback output. The method aims to provide flexibility in + retrieving data without explicitly specifying the details of the + underlying implementation. + + Args: + name (str): A string that represents the identifier for which the + corresponding value is being retrieved. It is used to perform + the lookup. + + Returns: + Any: The retrieved value associated with the given name. The + specific type of this value is dependent on the + implementation and the data being accessed. + """ + return self.validated_data.get(name) + + def getValues(self): + """ + Retrieves a dictionary of key-value pairs from the current object. + This method provides access to the internal state or configuration of + the object in a dictionary format, where keys are strings and values + can be of various types depending on the object's design. + + Returns: + Dict[str, Any]: A dictionary containing string keys and their + corresponding values of any data type. + """ + return self.validated_data + + def getRawValue(self, name: str): + """ + Fetches the raw value associated with the provided key. + + This method is used to retrieve the underlying value linked to the + given key without applying any transformations or validations. It + directly fetches the raw stored value and is typically used in + scenarios where the raw data is needed for processing or debugging + purposes. + + Args: + name (str): The name of the key whose raw value is to be + retrieved. + + Returns: + Any: The raw value associated with the provided key. + """ + return self.data.get(name) if name in self.data else None + + def getRawValues(self): + """ + Retrieves raw values from a given source and returns them as a + dictionary. + + This method is used to fetch and return unprocessed or raw data in + the form of a dictionary where the keys are strings, representing + the identifiers, and the values are of any data type. + + Returns: + Dict[str, Any]: A dictionary containing the raw values retrieved. + The keys are strings representing the identifiers, and the + values can be of any type, depending on the source + being accessed. + """ + if not self.fields: + return {} + + return { + field: self.data[field] + for field in self.fields + if field in self.data + } + + def getUnfilteredData(self): + """ + Fetches unfiltered data from the data source. + + This method retrieves data without any filtering, processing, or + manipulations applied. It is intended to provide raw data that has + not been altered since being retrieved from its source. The usage + of this method should be limited to scenarios where unprocessed data + is required, as it does not perform any validations or checks. + + Returns: + Dict[str, Any]: The unfiltered, raw data retrieved from the + data source. The return type may vary based on the + specific implementation of the data source. + """ + return self.data + + def setUnfilteredData(self, data: Dict[str, Any]): + """ + Sets unfiltered data for the current instance. This method assigns a + given dictionary of data to the instance for further processing. It + updates the internal state using the provided data. + + Parameters: + data (Dict[str, Any]): A dictionary containing the unfiltered + data to be associated with the instance. + """ + self.data = data + + def hasUnknown(self) -> bool: + """ + Checks whether any values in the current data do not have + corresponding configurations in the defined fields. + + Returns: + bool: True if there are any unknown fields; False otherwise. + """ + if not self.data and self.fields: + return True + + return any( + field_name not in self.fields.keys() + for field_name in self.data.keys() + ) + + def getErrorMessage(self, field_name: str): + """ + Retrieves and returns a predefined error message. + + This method is intended to provide a consistent error message + to be used across the application when an error occurs. The + message is predefined and does not accept any parameters. + The exact content of the error message may vary based on + specific implementation, but it is designed to convey meaningful + information about the nature of an error. + + Args: + field_name (str): The name of the field for which the error + message is being retrieved. + + Returns: + str: A string representing the predefined error message. + """ + return self.errors.get(field_name) + + def getErrorMessages(self): + """ + Retrieves all error messages associated with the fields in the + input filter. + + This method aggregates and returns a dictionary of error messages + where the keys represent field names, and the values are their + respective error messages. + + Returns: + Dict[str, str]: A dictionary containing field names as keys and + their corresponding error messages as values. + """ + return self.errors + + def add( + self, + name: str, + required: bool = False, + default: Any = None, + fallback: Any = None, + filters: Optional[List[BaseFilter]] = None, + validators: Optional[List[BaseValidator]] = None, + steps: Optional[List[Union[BaseFilter, BaseValidator]]] = None, + external_api: Optional[ExternalApiConfig] = None, + copy: Optional[str] = None, + ): + """ + Add the field to the input filter. + + Args: + name (str): The name of the field. + + required (Optional[bool]): Whether the field is required. + + default (Optional[Any]): The default value of the field. + + fallback (Optional[Any]): The fallback value of the field, if + validations fails or field None, although it is required. + + filters (Optional[List[BaseFilter]]): The filters to apply to + the field value. + + validators (Optional[List[BaseValidator]]): The validators to + apply to the field value. + + steps (Optional[List[Union[BaseFilter, BaseValidator]]]): Allows + to apply multiple filters and validators in a specific order. + + external_api (Optional[ExternalApiConfig]): Configuration for an + external API call. + + copy (Optional[str]): The name of the field to copy the value + from. + """ + if name in self.fields: + raise ValueError(f"Field '{name}' already exists.") + + self.fields[name] = FieldModel( + required=required, + default=default, + fallback=fallback, + filters=filters or [], + validators=validators or [], + steps=steps or [], + external_api=external_api, + copy=copy, + ) + + def has(self, field_name: str): + """ + This method checks the existence of a specific field within the + input filter values, identified by its field name. It does not return a + value, serving purely as a validation or existence check mechanism. + + Args: + field_name (str): The name of the field to check for existence. + + Returns: + bool: True if the field exists in the input filter, + otherwise False. + """ + return field_name in self.fields + + def getInput(self, field_name: str): + """ + Represents a method to retrieve a field by its name. + + This method allows fetching the configuration of a specific field + within the object, using its name as a string. It ensures + compatibility with various field names and provides a generic + return type to accommodate different data types for the fields. + + Args: + field_name (str): A string representing the name of the field who + needs to be retrieved. + + Returns: + Optional[FieldModel]: The field corresponding to the + specified name. + """ + return self.fields.get(field_name) + + def getInputs(self): + """ + Retrieve the dictionary of input fields associated with the object. + + Returns: + Dict[str, FieldModel]: Dictionary containing field names as + keys and their corresponding FieldModel instances as values + """ + return self.fields + + def remove(self, field_name: str): + """ + Removes the specified field from the instance or collection. + + This method is used to delete a specific field identified by + its name. It ensures the designated field is removed entirely + from the relevant data structure. No value is returned upon + successful execution. + + Args: + field_name (str): The name of the field to be removed. + + Returns: + Any: The value of the removed field, if any. + """ + return self.fields.pop(field_name, None) + + def count(self): + """ + Counts the total number of elements in the collection. + + This method returns the total count of elements stored within the + underlying data structure, providing a quick way to ascertain the + size or number of entries available. + + Returns: + int: The total number of elements in the collection. + """ + return len(self.fields) + + def replace( + self, + name: str, + required: bool = False, + default: Any = None, + fallback: Any = None, + filters: Optional[List[BaseFilter]] = None, + validators: Optional[List[BaseValidator]] = None, + steps: Optional[List[Union[BaseFilter, BaseValidator]]] = None, + external_api: Optional[ExternalApiConfig] = None, + copy: Optional[str] = None, + ): + """ + Replaces a field in the input filter. + + Args: + name (str): The name of the field. + + required (Optional[bool]): Whether the field is required. + + default (Optional[Any]): The default value of the field. + + fallback (Optional[Any]): The fallback value of the field, if + validations fails or field None, although it is required. + + filters (Optional[List[BaseFilter]]): The filters to apply to + the field value. + + validators (Optional[List[BaseValidator]]): The validators to + apply to the field value. + + steps (Optional[List[Union[BaseFilter, BaseValidator]]]): Allows + to apply multiple filters and validators in a specific order. + + external_api (Optional[ExternalApiConfig]): Configuration for an + external API call. + + copy (Optional[str]): The name of the field to copy the value + from. + """ + self.fields[name] = FieldModel( + required=required, + default=default, + fallback=fallback, + filters=filters or [], + validators=validators or [], + steps=steps or [], + external_api=external_api, + copy=copy, + ) + + def applySteps( + self, + steps: List[Union[BaseFilter, BaseValidator]], + fallback: Any, + value: Any, + ): + """ + Apply multiple filters and validators in a specific order. + + This method processes a given value by sequentially applying a list of + filters and validators. Filters modify the value, while validators + ensure the value meets specific criteria. If a validation error occurs + and a fallback value is provided, the fallback is returned. Otherwise, + the validation error is raised. + + Args: + steps (List[Union[BaseFilter, BaseValidator]]): + A list of filters and validators to be applied in order. + fallback (Any): + A fallback value to return if validation fails. + value (Any): + The initial value to be processed. + + Returns: + Any: The processed value after applying all filters and validators. + If a validation error occurs and a fallback is provided, the + fallback value is returned. + + Raises: + ValidationError: If validation fails and no fallback value is + provided. + """ + if value is None: + return + + try: + for step in steps: + if isinstance(step, BaseFilter): + value = step.apply(value) + elif isinstance(step, BaseValidator): + step.validate(value) + except ValidationError: + if fallback is None: + raise + return fallback + return value + + @staticmethod + def checkForRequired( + field_name: str, + required: bool, + default: Any, + fallback: Any, + value: Any, + ): + """ + Determine the value of the field, considering the required and + fallback attributes. + + If the field is not required and no value is provided, the default + value is returned. If the field is required and no value is provided, + the fallback value is returned. If no of the above conditions are met, + a ValidationError is raised. + + Args: + field_name (str): The name of the field being processed. + required (bool): Indicates whether the field is required. + default (Any): The default value to use if the field is not + provided and not required. + fallback (Any): The fallback value to use if the field is required + but not provided. + value (Any): The current value of the field being processed. + + Returns: + Any: The determined value of the field after considering required, + default, and fallback attributes. + + Raises: + ValidationError: If the field is required and no value or fallback + is provided. + """ + if value is not None: + return value + + if not required: + return default + + if fallback is not None: + return fallback + + raise ValidationError(f"Field '{field_name}' is required.") + + def addGlobalFilter(self, filter: BaseFilter): + """ + Add a global filter to be applied to all fields. + + Args: + filter: The filter to add. + """ + self.global_filters.append(filter) + + def getGlobalFilters(self): + """ + Retrieve all global filters associated with this InputFilter instance. + + This method returns a list of BaseFilter instances that have been + added as global filters. These filters are applied universally to + all fields during data processing. + + Returns: + List[BaseFilter]: A list of global filters. + """ + return self.global_filters + + def applyFilters(self, filters: List[BaseFilter], value: Any): + """ + Apply filters to the field value. + + Args: + filters (List[BaseFilter]): A list of filters to apply to the + value. + value (Any): The value to be processed by the filters. + + Returns: + Any: The processed value after applying all filters. + If the value is None, None is returned. + """ + if value is None: + return + + for filter in self.global_filters + filters: + value = filter.apply(value) + + return value + + def clear(self): + """ + Resets all fields of the InputFilter instance to + their initial empty state. + + This method clears the internal storage of fields, + conditions, filters, validators, and data, effectively + resetting the object as if it were newly initialized. + """ + self.fields.clear() + self.conditions.clear() + self.global_filters.clear() + self.global_validators.clear() + self.data.clear() + self.validated_data.clear() + self.errors.clear() + + def merge(self, other: "InputFilter") -> None: + """ + Merges another InputFilter instance intelligently into the current + instance. + + - Fields with the same name are merged recursively if possible, + otherwise overwritten. + - Conditions, are combined and duplicated. + - Global filters and validators are merged without duplicates. + + Args: + other (InputFilter): The InputFilter instance to merge. + """ + if not isinstance(other, InputFilter): + raise TypeError( + "Can only merge with another InputFilter instance." + ) + + for key, new_field in other.getInputs().items(): + self.fields[key] = new_field + + self.conditions += other.conditions + + for filter in other.global_filters: + existing_type_map = { + type(v): i for i, v in enumerate(self.global_filters) + } + if type(filter) in existing_type_map: + self.global_filters[existing_type_map[type(filter)]] = filter + else: + self.global_filters.append(filter) + + for validator in other.global_validators: + existing_type_map = { + type(v): i for i, v in enumerate(self.global_validators) + } + if type(validator) in existing_type_map: + self.global_validators[ + existing_type_map[type(validator)] + ] = validator + else: + self.global_validators.append(validator) + + def setModel(self, model_class: Type[T]) -> None: + """ + Set the model class for serialization. + + Args: + model_class (Type[T]): The class to use for serialization. + """ + self.model_class = model_class + + def serialize(self) -> Union[Dict[str, Any], T]: + """ + Serialize the validated data. If a model class is set, + returns an instance of that class, otherwise returns the + raw validated data. + + Returns: + Union[Dict[str, Any], T]: The serialized data. + """ + if self.model_class is None: + return self.validated_data + + return self.model_class(**self.validated_data) + + def addGlobalValidator(self, validator: BaseValidator) -> None: + """ + Add a global validator to be applied to all fields. + + Args: + validator (BaseValidator): The validator to add. + """ + self.global_validators.append(validator) + + def getGlobalValidators(self) -> List[BaseValidator]: + """ + Retrieve all global validators associated with this + InputFilter instance. + + This method returns a list of BaseValidator instances that have been + added as global validators. These validators are applied universally + to all fields during validation. + + Returns: + List[BaseValidator]: A list of global validators. + """ + return self.global_validators + + def validateField( + self, validators: List[BaseValidator], fallback: Any, value: Any + ) -> Any: + """ + Validate the field value. + + Args: + validators (List[BaseValidator]): A list of validators to apply + to the field value. + fallback (Any): A fallback value to return if validation fails. + value (Any): The value to be validated. + + Returns: + Any: The validated value if all validators pass. If validation + fails and a fallback is provided, the fallback value is + returned. + """ + if value is None: + return + + try: + for validator in self.global_validators + validators: + validator.validate(value) + except ValidationError: + if fallback is None: + raise + + return fallback diff --git a/flask_inputfilter/InputFilter.pyi b/flask_inputfilter/InputFilter.pyi new file mode 100644 index 0000000..861acac --- /dev/null +++ b/flask_inputfilter/InputFilter.pyi @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Type, TypeVar, Union + +from flask_inputfilter.Condition import BaseCondition +from flask_inputfilter.Filter import BaseFilter +from flask_inputfilter.Model import ExternalApiConfig, FieldModel +from flask_inputfilter.Validator import BaseValidator + +T = TypeVar("T") + +class InputFilter: + methods: List[str] + fields: Dict[str, FieldModel] + conditions: List[BaseCondition] + global_filters: List[BaseFilter] + global_validators: List[BaseValidator] + data: Dict[str, Any] + validated_data: Dict[str, Any] + errors: Dict[str, str] + model_class: Optional[Type[T]] + + def __init__(self, methods: Optional[List[str]] = ...) -> None: ... + def isValid(self) -> bool: ... + @classmethod + def validate(cls) -> Any: ... + def validateData( + self, data: Optional[Dict[str, Any]] = ... + ) -> Union[Dict[str, Any], Type[T]]: ... + def addCondition(self, condition: BaseCondition) -> None: ... + def getConditions(self) -> List[BaseCondition]: ... + def checkConditions(self, validated_data: Dict[str, Any]) -> None: ... + def setData(self, data: Dict[str, Any]) -> None: ... + def getValue(self, name: str) -> Any: ... + def getValues(self) -> Dict[str, Any]: ... + def getRawValue(self, name: str) -> Any: ... + def getRawValues(self) -> Dict[str, Any]: ... + def getUnfilteredData(self) -> Dict[str, Any]: ... + def setUnfilteredData(self, data: Dict[str, Any]) -> None: ... + def hasUnknown(self) -> bool: ... + def getErrorMessage(self, field_name: str) -> Optional[str]: ... + def getErrorMessages(self) -> Dict[str, str]: ... + def add( + self, + name: str, + required: bool = ..., + default: Any = ..., + fallback: Any = ..., + filters: Optional[List[BaseFilter]] = ..., + validators: Optional[List[BaseValidator]] = ..., + steps: Optional[List[Union[BaseFilter, BaseValidator]]] = ..., + external_api: Optional[ExternalApiConfig] = ..., + copy: Optional[str] = ..., + ) -> None: ... + def has(self, field_name: str) -> bool: ... + def getInput(self, field_name: str) -> Optional[FieldModel]: ... + def getInputs(self) -> Dict[str, FieldModel]: ... + def remove(self, field_name: str) -> Optional[FieldModel]: ... + def count(self) -> int: ... + def replace( + self, + name: str, + required: bool = ..., + default: Any = ..., + fallback: Any = ..., + filters: Optional[List[BaseFilter]] = ..., + validators: Optional[List[BaseValidator]] = ..., + steps: Optional[List[Union[BaseFilter, BaseValidator]]] = ..., + external_api: Optional[ExternalApiConfig] = ..., + copy: Optional[str] = ..., + ) -> None: ... + def applySteps( + self, + steps: List[Union[BaseFilter, BaseValidator]], + fallback: Any, + value: Any, + ) -> Any: ... + @staticmethod + def checkForRequired( + field_name: str, + required: bool, + default: Any, + fallback: Any, + value: Any, + ) -> Any: ... + def addGlobalFilter(self, filter: BaseFilter) -> None: ... + def getGlobalFilters(self) -> List[BaseFilter]: ... + def applyFilters(self, filters: List[BaseFilter], value: Any) -> Any: ... + def clear(self) -> None: ... + def merge(self, other: InputFilter) -> None: ... + def setModel(self, model_class: Type[T]) -> None: ... + def serialize(self) -> Union[Dict[str, Any], T]: ... + def addGlobalValidator(self, validator: BaseValidator) -> None: ... + def getGlobalValidators(self) -> List[BaseValidator]: ... + def validateField( + self, validators: List[BaseValidator], fallback: Any, value: Any + ) -> Any: ... diff --git a/flask_inputfilter/Mixin/ExternalApiMixin.py b/flask_inputfilter/Mixin/ExternalApiMixin.py new file mode 100644 index 0000000..d6c84c0 --- /dev/null +++ b/flask_inputfilter/Mixin/ExternalApiMixin.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import re +from typing import Any, Dict, Optional + +from flask_inputfilter.Exception import ValidationError +from flask_inputfilter.Model import ExternalApiConfig + + +class ExternalApiMixin: + def callExternalApi( + self, + config: ExternalApiConfig, + fallback: Any, + validated_data: Dict[str, Any], + ) -> Optional[Any]: + """ + Makes a call to an external API using provided configuration and + returns the response. + + Summary: + The function constructs a request based on the given API + configuration and validated data, including headers, parameters, + and other request settings. It utilizes the `requests` library + to send the API call and processes the response. If a fallback + value is supplied, it is returned in case of any failure during + the API call. If no fallback is provided, a validation error is + raised. + + Parameters: + config (ExternalApiConfig): + An object containing the configuration details for the + external API call, such as URL, headers, method, and API key. + fallback (Any): + The value to be returned in case the external API call fails. + validated_data (Dict[str, Any]): + The dictionary containing data used to replace placeholders + in the URL and parameters of the API request. + + Returns: + Optional[Any]: + The JSON-decoded response from the API, or the fallback + value if the call fails and a fallback is provided. + + Raises: + ValidationError + Raised if the external API call does not succeed and no + fallback value is provided. + """ + import logging + + import requests + + logger = logging.getLogger(__name__) + + data_key = config.data_key + + requestData = { + "headers": {}, + "params": {}, + } + + if config.api_key: + requestData["headers"]["Authorization"] = ( + f"Bearer " f"{config.api_key}" + ) + + if config.headers: + requestData["headers"].update(config.headers) + + if config.params: + requestData[ + "params" + ] = ExternalApiMixin.replacePlaceholdersInParams( + config.params, validated_data + ) + + requestData["url"] = ExternalApiMixin.replacePlaceholders( + config.url, validated_data + ) + requestData["method"] = config.method + + try: + response = requests.request(**requestData) + result = response.json() + except requests.exceptions.RequestException: + if fallback is None: + logger.exception("External API request failed unexpectedly.") + raise ValidationError( + f"External API call failed for field " f"'{data_key}'." + ) + return fallback + except ValueError: + if fallback is None: + logger.exception( + "External API response could not be parsed to json." + ) + raise ValidationError( + f"External API call failed for field " f"'{data_key}'." + ) + return fallback + + if response.status_code != 200: + if fallback is None: + logger.error( + f"External API request failed with status " + f"{response.status_code}: {response.text}" + ) + raise ValidationError( + f"External API call failed for field " f"'{data_key}'." + ) + return fallback + + return result.get(data_key) if data_key else result + + @staticmethod + def replacePlaceholders(value: str, validated_data: Dict[str, Any]) -> str: + """ + Replace all placeholders, marked with '{{ }}' in value + with the corresponding values from validated_data. + + Params: + value (str): The string containing placeholders to be replaced. + validated_data (Dict[str, Any]): The dictionary containing + the values to replace the placeholders with. + + Returns: + str: The value with all placeholders replaced with + the corresponding values from validated_data. + """ + return re.compile(r"{{(.*?)}}").sub( + lambda match: str(validated_data.get(match.group(1))), + value, + ) + + @staticmethod + def replacePlaceholdersInParams( + params: dict, validated_data: Dict[str, Any] + ) -> dict: + """ + Replace all placeholders in params with the corresponding + values from validated_data. + + Params: + params (dict): The params dictionary containing placeholders. + validated_data (Dict[str, Any]): The dictionary containing + the values to replace the placeholders with. + + Returns: + dict: The params dictionary with all placeholders replaced + with the corresponding values from validated_data. + """ + return { + key: ExternalApiMixin.replacePlaceholders(value, validated_data) + if isinstance(value, str) + else value + for key, value in params.items() + } diff --git a/flask_inputfilter/Mixin/ExternalApiMixin.pyx b/flask_inputfilter/Mixin/_ExternalApiMixin.pyx similarity index 100% rename from flask_inputfilter/Mixin/ExternalApiMixin.pyx rename to flask_inputfilter/Mixin/_ExternalApiMixin.pyx diff --git a/flask_inputfilter/Mixin/__init__.py b/flask_inputfilter/Mixin/__init__.py index 032a2dc..a16d856 100644 --- a/flask_inputfilter/Mixin/__init__.py +++ b/flask_inputfilter/Mixin/__init__.py @@ -1 +1,7 @@ -from .ExternalApiMixin import ExternalApiMixin +import shutil + +if shutil.which("g++") is not None: + from ._ExternalApiMixin import ExternalApiMixin + +else: + from .ExternalApiMixin import ExternalApiMixin diff --git a/flask_inputfilter/Validator/IsTypedDictValidator.py b/flask_inputfilter/Validator/IsTypedDictValidator.py index aaf3b48..a2be3db 100644 --- a/flask_inputfilter/Validator/IsTypedDictValidator.py +++ b/flask_inputfilter/Validator/IsTypedDictValidator.py @@ -2,7 +2,6 @@ from typing import Any, Optional - from flask_inputfilter.Exception import ValidationError from flask_inputfilter.Validator import BaseValidator @@ -19,6 +18,14 @@ def __init__( typed_dict_type, error_message: Optional[str] = None, ) -> None: + """ + Parameters: + typed_dict_type (Type[TypedDict]): The TypedDict class + to validate against. + error_message (Optional[str]): Custom error message to + use if validation fails. + """ + self.typed_dict_type = typed_dict_type self.error_message = error_message diff --git a/flask_inputfilter/InputFilter.pyx b/flask_inputfilter/_InputFilter.pyx similarity index 100% rename from flask_inputfilter/InputFilter.pyx rename to flask_inputfilter/_InputFilter.pyx diff --git a/flask_inputfilter/__init__.py b/flask_inputfilter/__init__.py index f38597e..b83f476 100644 --- a/flask_inputfilter/__init__.py +++ b/flask_inputfilter/__init__.py @@ -1,16 +1,23 @@ +import logging +import shutil + try: - from .InputFilter import InputFilter + from ._InputFilter import InputFilter except ImportError: - import logging + if shutil.which("g++") is not None: + import logging - import pyximport + import pyximport - pyximport.install(setup_args={"script_args": ["--quiet"]}) + pyximport.install(setup_args={"script_args": ["--quiet"]}) - from .InputFilter import InputFilter + from ._InputFilter import InputFilter - logging.getLogger(__name__).warning( - "flask-inputfilter not compiled, using pure Python version. " - + "Consider installing a C compiler to compile the Cython version for better performance." - ) + else: + logging.getLogger(__name__).warning( + "Cython is not installed and g++ is not available. " + "Falling back to pure Python implementation. " + "Consider installing Cython and g++ for better performance." + ) + from .InputFilter import InputFilter diff --git a/pyproject.toml b/pyproject.toml index fa9df42..94adfb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "flask_inputfilter" -version = "0.4.0a1" +version = "0.4.0a2" description = "A library to easily filter and validate input data in Flask applications" readme = "README.rst" requires-python = ">=3.7" @@ -13,14 +13,7 @@ authors = [ {name = "Leander Cain Slotosch", email = "slotosch.leander@outlook.de"} ] dependencies = [ - "flask>=2.1", - "typing_extensions>=3.6.2", - "cython>=0.29; python_version <= '3.8'", - "cython>=0.29.21; python_version == '3.9'", - "cython>=0.29.24; python_version == '3.10'", - "cython>=0.29.32; python_version == '3.11'", - "cython>=3.0; python_version == '3.12'", - "cython>=3.0.12; python_version >= '3.13'", + "flask>=2.1" ] classifiers = [ "Operating System :: OS Independent", @@ -41,6 +34,7 @@ dev = [ "build", "coverage", "coveralls", + "cython", "flake8-pyproject==1.2.3", "flake8==5.0.4", "isort", @@ -55,6 +49,20 @@ dev = [ optional = [ "pillow>=8.0.0", "requests>=2.22.0", + "cython>=0.29; python_version <= '3.8'", + "cython>=0.29.21; python_version == '3.9'", + "cython>=0.29.24; python_version == '3.10'", + "cython>=0.29.32; python_version == '3.11'", + "cython>=3.0; python_version == '3.12'", + "cython>=3.0.12; python_version >= '3.13'", +] +compile = [ + "cython>=0.29; python_version <= '3.8'", + "cython>=0.29.21; python_version == '3.9'", + "cython>=0.29.24; python_version == '3.10'", + "cython>=0.29.32; python_version == '3.11'", + "cython>=3.0; python_version == '3.12'", + "cython>=3.0.12; python_version >= '3.13'", ] [project.urls] diff --git a/setup.py b/setup.py index 0e12b09..ba3a26d 100644 --- a/setup.py +++ b/setup.py @@ -1,17 +1,18 @@ -import os +import shutil -from Cython.Build import cythonize from setuptools import setup -os.environ["CC"] = "g++" -os.environ["CXX"] = "g++" +if shutil.which("g++") is not None: + from Cython.Build import cythonize -setup( - ext_modules=cythonize( + ext_modules = cythonize( [ - "flask_inputfilter/Mixin/ExternalApiMixin.pyx", - "flask_inputfilter/InputFilter.pyx", + "flask_inputfilter/Mixin/_ExternalApiMixin.pyx", + "flask_inputfilter/_InputFilter.pyx", ], language_level=3, - ), -) + ) +else: + ext_modules = [] + +setup(ext_modules=ext_modules) diff --git a/tests/test_filter.py b/tests/test_filter.py index 23ee8b0..0744706 100644 --- a/tests/test_filter.py +++ b/tests/test_filter.py @@ -6,7 +6,6 @@ from enum import Enum from PIL import Image -from typing_extensions import TypedDict from flask_inputfilter import InputFilter from flask_inputfilter.Filter import ( @@ -588,9 +587,22 @@ def test_to_typed_dict_filter(self) -> None: Test that ToTypedDictFilter converts a dictionary to a TypedDict. """ - class Person(TypedDict): - name: str - age: int + # TODO: Readd when Python 3.7 support is dropped + # class Person(TypedDict): + # name: str + # age: int + + class Person: + __annotations__ = {"name": str, "age": int} + + def __init__(self, name: str, age: int) -> None: + self.name = name + self.age = age + + def __eq__(self, other): + if isinstance(other, dict): + return other == {"name": self.name, "age": self.age} + return NotImplemented self.inputFilter.add( "person", required=True, filters=[ToTypedDictFilter(Person)] diff --git a/tests/test_input_filter.py b/tests/test_input_filter.py index f893a3a..579ecaa 100644 --- a/tests/test_input_filter.py +++ b/tests/test_input_filter.py @@ -39,6 +39,8 @@ def test_validate_decorator(self) -> None: class MyInputFilter(InputFilter): def __init__(self): + super().__init__() + self.add( name="username", required=True, @@ -87,6 +89,8 @@ def test_route_params(self) -> None: class MyInputFilter(InputFilter): def __init__(self): + super().__init__() + self.add( name="username", ) @@ -137,6 +141,8 @@ def test_validation_error_response(self): class MyInputFilter(InputFilter): def __init__(self): + super().__init__() + self.add( name="age", required=False, @@ -999,6 +1005,8 @@ def __init__(self, username: str): class MyInputFilter(InputFilter): def __init__(self): + super().__init__() + self.add("username") self.setModel(User) diff --git a/tests/test_validator.py b/tests/test_validator.py index 3d0c9f1..4c9e043 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -3,8 +3,6 @@ from datetime import date, datetime, timedelta from enum import Enum -from typing_extensions import TypedDict - from flask_inputfilter import InputFilter from flask_inputfilter.Enum import RegexEnum from flask_inputfilter.Exception import ValidationError @@ -1160,8 +1158,20 @@ def test_is_typed_dict_validator(self) -> None: Test IsTypedDictValidator. """ - class User(TypedDict): - id: int + # TODO: Readd when Python 3.7 support is dropped + # class User(TypedDict): + # id: int + + class User: + __annotations__ = {"id": int} + + def __init__(self, id: int): + self.id = id + + def __eq__(self, other): + if isinstance(other, dict): + return other == {"id": self.id} + return NotImplemented self.inputFilter.add("data", validators=[IsTypedDictValidator(User)])