├── tests ├── __init__.py ├── test_parser_assign.py ├── test_parser_separator.py ├── test_parser.py ├── test_mixed_separator.py ├── test_mixed_dot_separator.py └── test_drf.py ├── requirements ├── common.txt └── dev.txt ├── setup.cfg ├── .vscode ├── settings.json └── launch.json ├── nested_multipart_parser ├── __init__.py ├── drf.py ├── temp_element.py ├── parser.py └── options.py ├── .github ├── ISSUE_TEMPLATE │ ├── documentation-report.md │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── main.yml ├── LICENSE ├── setup.py ├── bench └── bench.py ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements/common.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description_file = README.md 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.defaultInterpreterPath": "./venv/bin/python" 3 | } -------------------------------------------------------------------------------- /nested_multipart_parser/__init__.py: -------------------------------------------------------------------------------- 1 | from .parser import NestedParser 2 | 3 | __all__ = ["NestedParser"] 4 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r common.txt 2 | Django 3 | djangorestframework 4 | 5 | pytest 6 | pytest-cov 7 | flake8 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation report 3 | about: Changes documentation 4 | title: '' 5 | labels: documentation 6 | assignees: '' 7 | 8 | --- 9 | 10 | Additional context 11 | Add any other context or screenshots about the feature request here. 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Current File", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 rgermain 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /nested_multipart_parser/drf.py: -------------------------------------------------------------------------------- 1 | from .parser import NestedParser as NestPars 2 | from rest_framework.parsers import MultiPartParser 3 | from rest_framework.exceptions import ParseError 4 | from django.http import QueryDict 5 | from django.conf import settings 6 | 7 | DRF_OPTIONS = {"querydict": True} 8 | 9 | 10 | class NestedParser(NestPars): 11 | def __init__(self, data): 12 | # merge django settings to default DRF_OPTIONS ( special parser options in on parser) 13 | options = { 14 | **DRF_OPTIONS, 15 | **getattr(settings, "DRF_NESTED_MULTIPART_PARSER", {}), 16 | } 17 | super().__init__(data, options) 18 | 19 | def convert_value(self, value): 20 | if isinstance(value, list) and len(value) > 0: 21 | return value[0] 22 | return value 23 | 24 | @property 25 | def validate_data(self): 26 | data = super().validate_data 27 | 28 | # return dict ( not conver to querydict) 29 | if not self._options["querydict"]: 30 | return data 31 | 32 | dtc = QueryDict(mutable=True) 33 | dtc.update(data) 34 | dtc.mutable = False 35 | return dtc 36 | 37 | 38 | class DrfNestedParser(MultiPartParser): 39 | def parse(self, stream, media_type=None, parser_context=None): 40 | clsDataAndFile = super().parse(stream, media_type, parser_context) 41 | 42 | data = clsDataAndFile.data.dict() 43 | data.update(clsDataAndFile.files.dict()) # add files to data 44 | 45 | parser = NestedParser(data) 46 | if parser.is_valid(): 47 | return parser.validate_data 48 | raise ParseError(parser.errors) 49 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | name: Python ${{ matrix.python-version }} 11 | # https://stackoverflow.com/questions/70959954/error-waiting-for-a-runner-to-pick-up-this-job-using-github-actions 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | python-version: 17 | - "3.9" 18 | - "3.11" 19 | - "3.12" 20 | - "3.13" 21 | 22 | steps: 23 | - uses: actions/checkout@v5 24 | 25 | - uses: actions/setup-python@v6 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - uses: actions/cache@v4 30 | with: 31 | path: ~/.cache/pip 32 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements/dev.txt') }} 33 | restore-keys: | 34 | ${{ runner.os }}-pip- 35 | 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | if [ -f requirements/dev.txt ]; then pip install -r requirements/dev.txt; fi 40 | 41 | - name: Lint with flake8 42 | run: | 43 | # stop the build if there are Python syntax errors or undefined names 44 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 45 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 46 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 47 | 48 | - name: Test with pytest 49 | run: | 50 | python -m pytest -v -s --cov=nested_multipart_parser --cov-report=xml --capture=tee-sys ./tests 51 | python -m coverage report -m 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import subprocess 5 | import sys 6 | 7 | import setuptools 8 | 9 | version = "1.6.0" 10 | 11 | if sys.argv[-1] == "publish": 12 | if os.system("pip freeze | grep twine"): 13 | print("twine not installed.\nUse `pip install twine`.\nExiting.") 14 | sys.exit() 15 | os.system("rm -rf dist nested_multipart_parser.egg-info") 16 | os.system("python setup.py sdist") 17 | if os.system("twine check dist/*"): 18 | print("twine check failed. Packages might be outdated.") 19 | print("Try using `pip install -U twine wheel`.\nExiting.") 20 | sys.exit() 21 | os.system("twine upload dist/*") 22 | sys.exit() 23 | 24 | 25 | with open("README.md", encoding="utf-8") as fh: 26 | long_description = fh.read() 27 | 28 | setuptools.setup( 29 | name="nested-multipart-parser", 30 | version=version, 31 | author="rgermain", 32 | license="MIT", 33 | author_email="contact@germainremi.fr", 34 | description="A parser for nested data in multipart form", 35 | long_description=long_description, 36 | long_description_content_type="text/markdown", 37 | url="https://github.com/remigermain/nested-multipart-parser", 38 | project_urls={ 39 | "Bug Tracker": "https://github.com/remigermain/nested-multipart-parser/issues" 40 | }, 41 | classifiers=[ 42 | "Development Status :: 5 - Production/Stable", 43 | "Environment :: Web Environment", 44 | "Framework :: Django", 45 | "Framework :: Django :: 2.2", 46 | "Framework :: Django :: 3.0", 47 | "Framework :: Django :: 3.1", 48 | "Framework :: Django :: 3.2", 49 | "Intended Audience :: Developers", 50 | "License :: OSI Approved :: BSD License", 51 | "Operating System :: OS Independent", 52 | "Programming Language :: Python", 53 | "Programming Language :: Python :: 3.6", 54 | "Programming Language :: Python :: 3.7", 55 | "Programming Language :: Python :: 3.8", 56 | "Programming Language :: Python :: 3.9", 57 | "Programming Language :: Python :: 3 :: Only", 58 | "Topic :: Internet :: WWW/HTTP", 59 | ], 60 | packages=["nested_multipart_parser"], 61 | python_requires=">=3.6", 62 | ) 63 | -------------------------------------------------------------------------------- /nested_multipart_parser/temp_element.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any 3 | 4 | 5 | class TempElement(abc.ABC): 6 | @abc.abstractclassmethod 7 | def __setitem__(self, key, val): 8 | """method to set element""" 9 | 10 | def check(self, key, value): 11 | if key in self._elements: 12 | # same instance like templist to templist, we ignore it 13 | if isinstance(self._elements[key], type(value)): 14 | return 15 | 16 | if self._options.get("raise_duplicate"): 17 | raise ValueError("key is already set") 18 | 19 | if not self._options.get("assign_duplicate"): 20 | return 21 | 22 | self._elements[key] = value 23 | 24 | def __getitem__(self, key): 25 | if key not in self._elements: 26 | self[key] = type(self)(options=self._options) 27 | return self._elements[key] 28 | 29 | def conv_value(self, value: Any) -> Any: 30 | if isinstance(value, TempElement): 31 | value = value.convert() 32 | return value 33 | 34 | @abc.abstractmethod 35 | def convert(self): 36 | """method to convert tempoary element to real python element""" 37 | 38 | 39 | class TempList(TempElement): 40 | def __init__(self, options=None): 41 | self._options = options or {} 42 | self._elements = {} 43 | 44 | def __setitem__(self, key: int, value: Any): 45 | assert isinstance(key, int), ( 46 | f"Invalid key for list, need to be int, type={type(key)}" 47 | ) 48 | self.check(key, value) 49 | 50 | def convert(self) -> list: 51 | keys = sorted(self._elements.keys()) 52 | # check if index start to 0 and end to number of elements 53 | if any((keys[0] != 0, keys[-1] != (len(self._elements) - 1))): 54 | raise ValueError("invalid format list keys") 55 | 56 | return [self.conv_value(self._elements[key]) for key in keys] 57 | 58 | 59 | class TempDict(TempElement): 60 | def __init__(self, options=None): 61 | self._options = options or {} 62 | self._elements = {} 63 | 64 | def __setitem__(self, key: str, value: Any): 65 | assert isinstance(key, str), ( 66 | f"Invalid key for dict, need to be str, type={type(key)}" 67 | ) 68 | self.check(key, value) 69 | 70 | def convert(self) -> dict: 71 | return { 72 | key: self.conv_value(value) for key, value in self._elements.items() 73 | } 74 | -------------------------------------------------------------------------------- /nested_multipart_parser/parser.py: -------------------------------------------------------------------------------- 1 | from nested_multipart_parser.options import ( 2 | NestedParserOptionsBracket, 3 | NestedParserOptionsDot, 4 | NestedParserOptionsMixed, 5 | NestedParserOptionsMixedDot, 6 | ) 7 | from nested_multipart_parser.temp_element import TempDict, TempList 8 | 9 | DEFAULT_OPTIONS = { 10 | "separator": "mixed-dot", 11 | "raise_duplicate": True, 12 | "assign_duplicate": False, 13 | } 14 | 15 | REGEX_SEPARATOR = { 16 | "bracket": NestedParserOptionsBracket, 17 | "dot": NestedParserOptionsDot, 18 | "mixed": NestedParserOptionsMixed, 19 | "mixed-dot": NestedParserOptionsMixedDot, 20 | } 21 | 22 | 23 | class NestedParser: 24 | _valid = None 25 | errors = None 26 | 27 | def __init__(self, data, options=None): 28 | self.data = data 29 | self._options = {**DEFAULT_OPTIONS, **(options or {})} 30 | 31 | assert self._options["separator"] in [ 32 | "dot", 33 | "bracket", 34 | "mixed", 35 | "mixed-dot", 36 | ] 37 | assert isinstance(self._options["raise_duplicate"], bool) 38 | assert isinstance(self._options["assign_duplicate"], bool) 39 | 40 | self._cls_options = REGEX_SEPARATOR[self._options["separator"]] 41 | 42 | def _split_keys(self, data): 43 | checker = self._cls_options() 44 | for key, value in data.items(): 45 | keys, value = checker.sanitize(key, value) 46 | checker.check(key, keys) 47 | 48 | yield keys, value 49 | 50 | def convert_value(self, value): 51 | return value 52 | 53 | def construct(self, data): 54 | dictionary = TempDict(self._options) 55 | 56 | for keys, value in self._split_keys(data): 57 | tmp = dictionary 58 | 59 | for actual_key, next_key in zip(keys, keys[1:]): 60 | if isinstance(next_key, int): 61 | tmp[actual_key] = TempList(self._options) 62 | else: 63 | tmp[actual_key] = TempDict(self._options) 64 | tmp = tmp[actual_key] 65 | 66 | tmp[keys[-1]] = self.convert_value(value) 67 | return dictionary.convert() 68 | 69 | def is_valid(self): 70 | self._valid = False 71 | try: 72 | self.__validate_data = self.construct(self.data) 73 | self._valid = True 74 | except Exception as err: 75 | self.errors = err 76 | return self._valid 77 | 78 | @property 79 | def validate_data(self): 80 | if self._valid is None: 81 | raise ValueError( 82 | "You need to be call is_valid() before access validate_data" 83 | ) 84 | if self._valid is False: 85 | raise ValueError("You can't get validate data") 86 | return self.__validate_data 87 | -------------------------------------------------------------------------------- /bench/bench.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from nested_multipart_parser import NestedParser 4 | 5 | 6 | def bench(data, count): 7 | v = [] 8 | for _ in range(count): 9 | start = time.perf_counter() 10 | parser = NestedParser(data) 11 | parser.is_valid() 12 | validate_data = parser.validate_data 13 | end = time.perf_counter() 14 | v.append(end - start) 15 | 16 | return sum(v) / len(v) 17 | 18 | 19 | def big(count): 20 | data = { 21 | "title": "title", 22 | "date": "time", 23 | "langs[0].id": "id", 24 | "langs[0].title": "title", 25 | "langs[0].description": "description", 26 | "langs[0].language": "language", 27 | "langs[1].id": "id1", 28 | "langs[1].title": "title1", 29 | "langs[1].description": "description1", 30 | "langs[1].language": "language1", 31 | "test.langs[0].id": "id", 32 | "test.langs[0].title": "title", 33 | "test.langs[0].description": "description", 34 | "test.langs[0].language": "language", 35 | "test.langs[1].id": "id1", 36 | "test.langs[1].title": "title1", 37 | "test.langs[1].description": "description1", 38 | "test.langs[1].language": "language1", 39 | "deep.nested.dict.test.langs[0].id": "id", 40 | "deep.nested.dict.test.langs[0].title": "title", 41 | "deep.nested.dict.test.langs[0].description": "description", 42 | "deep.nested.dict.test.langs[0].language": "language", 43 | "deep.nested.dict.test.langs[1].id": "id1", 44 | "deep.nested.dict.test.langs[1].title": "title1", 45 | "deep.nested.dict.test.langs[1].description": "description1", 46 | "deep.nested.dict.test.langs[1].language": "language1", 47 | "deep.nested.dict.with.list[0].test.langs[0].id": "id", 48 | "deep.nested.dict.with.list[0].test.langs[0].title": "title", 49 | "deep.nested.dict.with.list[1].test.langs[0].description": "description", 50 | "deep.nested.dict.with.list[1].test.langs[0].language": "language", 51 | "deep.nested.dict.with.list[1].test.langs[1].id": "id1", 52 | "deep.nested.dict.with.list[1].test.langs[1].title": "title1", 53 | "deep.nested.dict.with.list[0].test.langs[1].description": "description1", 54 | "deep.nested.dict.with.list[0].test.langs[1].language": "language1", 55 | } 56 | return bench(data, count) 57 | 58 | 59 | def small(count): 60 | data = { 61 | "title": "title", 62 | "date": "time", 63 | "langs[0].id": "id", 64 | "langs[0].title": "title", 65 | "langs[0].description": "description", 66 | "langs[0].language": "language", 67 | "langs[1].id": "id1", 68 | "langs[1].title": "title1", 69 | "langs[1].description": "description1", 70 | "langs[1].language": "language1", 71 | } 72 | return bench(data, count) 73 | 74 | 75 | count = 10_000 76 | print(f"{small(count)=}") 77 | print(f"{big(count)=}") 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 103 | __pypackages__/ 104 | 105 | # Celery stuff 106 | celerybeat-schedule 107 | celerybeat.pid 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # Environments 113 | .env 114 | .venv 115 | env/ 116 | venv/ 117 | ENV/ 118 | env.bak/ 119 | venv.bak/ 120 | 121 | # Spyder project settings 122 | .spyderproject 123 | .spyproject 124 | 125 | # Rope project settings 126 | .ropeproject 127 | 128 | # mkdocs documentation 129 | /site 130 | 131 | # mypy 132 | .mypy_cache/ 133 | .dmypy.json 134 | dmypy.json 135 | 136 | # Pyre type checker 137 | .pyre/ 138 | 139 | # pytype static type analyzer 140 | .pytype/ 141 | 142 | # Cython debug symbols 143 | cython_debug/ 144 | 145 | # End of https://www.toptal.com/developers/gitignore/api/python 146 | 147 | debug.py -------------------------------------------------------------------------------- /tests/test_parser_assign.py: -------------------------------------------------------------------------------- 1 | from nested_multipart_parser import NestedParser 2 | from unittest import TestCase 3 | 4 | 5 | class TestSettingsSeparator(TestCase): 6 | def test_assign_duplicate_list(self): 7 | data = {"title": 42, "title[0]": 101} 8 | p = NestedParser( 9 | data, 10 | { 11 | "raise_duplicate": False, 12 | "assign_duplicate": True, 13 | "separator": "bracket", 14 | }, 15 | ) 16 | self.assertTrue(p.is_valid()) 17 | expected = {"title": [101]} 18 | self.assertEqual(p.validate_data, expected) 19 | 20 | def test_assign_duplicate_number_after_list(self): 21 | data = { 22 | "title[0]": 101, 23 | "title": 42, 24 | } 25 | p = NestedParser( 26 | data, 27 | { 28 | "raise_duplicate": False, 29 | "assign_duplicate": True, 30 | "separator": "bracket", 31 | }, 32 | ) 33 | self.assertTrue(p.is_valid()) 34 | expected = {"title": 42} 35 | self.assertEqual(p.validate_data, expected) 36 | 37 | def test_assign_nested_duplicate_number_after_list(self): 38 | data = { 39 | "title[0][sub][0]": 101, 40 | "title[0][sub]": 42, 41 | } 42 | p = NestedParser( 43 | data, 44 | { 45 | "raise_duplicate": False, 46 | "assign_duplicate": True, 47 | "separator": "bracket", 48 | }, 49 | ) 50 | self.assertTrue(p.is_valid()) 51 | expected = {"title": [{"sub": 42}]} 52 | self.assertEqual(p.validate_data, expected) 53 | 54 | def test_assign_nested_duplicate_number_after_list2(self): 55 | data = { 56 | "title[0][sub]": 42, 57 | "title[0][sub][0]": 101, 58 | } 59 | p = NestedParser( 60 | data, 61 | { 62 | "raise_duplicate": False, 63 | "assign_duplicate": True, 64 | "separator": "bracket", 65 | }, 66 | ) 67 | self.assertTrue(p.is_valid()) 68 | expected = {"title": [{"sub": [101]}]} 69 | self.assertEqual(p.validate_data, expected) 70 | 71 | def test_assign_nested_duplicate_number_after_dict(self): 72 | data = { 73 | "title[0][sub]": 42, 74 | "title[0][sub][title]": 101, 75 | } 76 | p = NestedParser( 77 | data, 78 | { 79 | "raise_duplicate": False, 80 | "assign_duplicate": True, 81 | "separator": "bracket", 82 | }, 83 | ) 84 | self.assertTrue(p.is_valid()) 85 | expected = {"title": [{"sub": {"title": 101}}]} 86 | self.assertEqual(p.validate_data, expected) 87 | 88 | def test_assign_nested_duplicate_number_after_dict2(self): 89 | data = { 90 | "title[0][sub][title]": 101, 91 | "title[0][sub]": 42, 92 | } 93 | p = NestedParser( 94 | data, 95 | { 96 | "raise_duplicate": False, 97 | "assign_duplicate": True, 98 | "separator": "bracket", 99 | }, 100 | ) 101 | self.assertTrue(p.is_valid()) 102 | expected = {"title": [{"sub": 42}]} 103 | self.assertEqual(p.validate_data, expected) 104 | -------------------------------------------------------------------------------- /nested_multipart_parser/options.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | # compatibilty python < 3.9 4 | try: 5 | from functools import cache 6 | except ImportError: 7 | from functools import lru_cache as cache 8 | 9 | 10 | @cache 11 | def cache_regex_compile(*ar, **kw): 12 | return re.compile(*ar, **kw) 13 | 14 | 15 | class InvalidFormat(Exception): 16 | """key is invalid formated""" 17 | 18 | def __init__(self, key): 19 | super().__init__(f"invaid key format: {key}") 20 | 21 | 22 | class NestedParserOptionsType(type): 23 | def __new__(cls, cls_name, ns, childs): 24 | if cls_name != "NestedParserOptionsAbstract" and cls_name: 25 | if "sanitize" not in childs: 26 | raise ValueError("you need to define sanitize methods") 27 | return super().__new__(cls, cls_name, ns, childs) 28 | 29 | 30 | INVALID_TOKEN_PARSER = ("[", "]", ".") 31 | 32 | 33 | class NestedParserOptionsAbstract(metaclass=NestedParserOptionsType): 34 | def check(self, key, keys): 35 | if len(keys) == 0: 36 | raise InvalidFormat(key) 37 | 38 | first = keys[0] 39 | for token in INVALID_TOKEN_PARSER: 40 | if token in first: 41 | raise InvalidFormat(key) 42 | 43 | for key in keys: 44 | if not isinstance(key, str): 45 | continue 46 | for c in key: 47 | if c.isspace(): 48 | raise InvalidFormat(key) 49 | 50 | def split(self, key): 51 | contents = [v for v in self._reg_spliter.split(key) if v] 52 | if not contents: 53 | raise ValueError(f"invalid form key: {key}") 54 | 55 | lst = [contents[0]] 56 | if len(contents) >= 2: 57 | lst.extend(self._reg_options.split(contents[1])) 58 | if len(contents) == 3: 59 | lst.append(contents[2]) 60 | 61 | return [v for v in lst if v] 62 | 63 | 64 | class NestedParserOptionsDot(NestedParserOptionsAbstract): 65 | def __init__(self): 66 | self._reg_spliter = cache_regex_compile(r"^([^\.]+)(.*?)(\.)?$") 67 | self._reg_options = cache_regex_compile(r"(\.[^\.]+)") 68 | 69 | def sanitize(self, key, value): 70 | contents = self.split(key) 71 | lst = contents[1:] 72 | keys = [contents[0]] 73 | for idx, k in enumerate(lst): 74 | if k.startswith("."): 75 | k = k[1:] 76 | if not k: 77 | if len(lst) != idx + 1: 78 | raise InvalidFormat(key) 79 | value = {} 80 | break 81 | try: 82 | k = int(k) 83 | except Exception: 84 | pass 85 | else: 86 | raise InvalidFormat(key) 87 | keys.append(k) 88 | 89 | return keys, value 90 | 91 | 92 | class NestedParserOptionsBracket(NestedParserOptionsAbstract): 93 | def __init__(self): 94 | self._reg_spliter = cache_regex_compile(r"^([^\[\]]+)(.*?)(\[\])?$") 95 | self._reg_options = cache_regex_compile(r"(\[[^\[\]]+\])") 96 | 97 | def sanitize(self, key, value): 98 | first, *lst = self.split(key) 99 | keys = [first] 100 | 101 | for idx, k in enumerate(lst): 102 | if k.startswith("[") or k.endswith("]"): 103 | if not k.startswith("[") or not k.endswith("]"): 104 | raise InvalidFormat(key) 105 | k = k[1:-1] 106 | if not k: 107 | if len(lst) != idx + 1: 108 | raise InvalidFormat(key) 109 | value = [] 110 | break 111 | try: 112 | k = int(k) 113 | except Exception: 114 | pass 115 | else: 116 | raise InvalidFormat(key) 117 | keys.append(k) 118 | return keys, value 119 | 120 | 121 | class NestedParserOptionsMixedDot(NestedParserOptionsAbstract): 122 | def __init__(self): 123 | self._reg_spliter = cache_regex_compile( 124 | r"^([^\[\]\.]+)(.*?)((?:\.)|(?:\[\]))?$" 125 | ) 126 | self._reg_options = cache_regex_compile(r"(\[\d+\])|(\.[^\[\]\.]+)") 127 | 128 | def sanitize(self, key, value): 129 | first, *lst = self.split(key) 130 | keys = [first] 131 | 132 | for idx, k in enumerate(lst): 133 | if k.startswith("."): 134 | k = k[1:] 135 | # empty dict 136 | if not k: 137 | if len(lst) != idx + 1: 138 | raise InvalidFormat(key) 139 | value = {} 140 | break 141 | elif k.startswith("[") or k.endswith("]"): 142 | if not k.startswith("[") or not k.endswith("]"): 143 | raise InvalidFormat(key) 144 | k = k[1:-1] 145 | if not k: 146 | if len(lst) != idx + 1: 147 | raise InvalidFormat(key) 148 | value = [] 149 | break 150 | k = int(k) 151 | else: 152 | raise InvalidFormat(key) 153 | keys.append(k) 154 | 155 | return keys, value 156 | 157 | 158 | class NestedParserOptionsMixed(NestedParserOptionsMixedDot): 159 | def __init__(self): 160 | self._reg_spliter = cache_regex_compile( 161 | r"^([^\[\]\.]+)(.*?)((?:\.)|(?:\[\]))?$" 162 | ) 163 | self._reg_options = cache_regex_compile(r"(\[\d+\])|(\.?[^\[\]\.]+)") 164 | 165 | def sanitize(self, key, value): 166 | first, *lst = self.split(key) 167 | keys = [first] 168 | 169 | for idx, k in enumerate(lst): 170 | if k.startswith("."): 171 | k = k[1:] 172 | # empty dict 173 | if not k: 174 | if len(lst) != idx + 1: 175 | raise InvalidFormat(key) 176 | value = {} 177 | break 178 | elif k.startswith("[") or k.endswith("]"): 179 | if not k.startswith("[") or not k.endswith("]"): 180 | raise InvalidFormat(key) 181 | k = k[1:-1] 182 | if not k: 183 | if len(lst) != idx + 1: 184 | raise InvalidFormat(key) 185 | value = [] 186 | break 187 | k = int(k) 188 | keys.append(k) 189 | 190 | return keys, value 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nested-multipart-parser 2 | 3 | 4 | [![CI](https://github.com/remigermain/nested-multipart-parser/actions/workflows/main.yml/badge.svg)](https://github.com/remigermain/nested-multipart-parser/actions/workflows/main.yml) 5 | [![pypi](https://img.shields.io/pypi/v/nested-multipart-parser)](https://pypi.org/project/nested-multipart-parser/) 6 | [![PyPI - Downloads](https://img.shields.io/pypi/dm/Nested-multipart-parser)](https://pypistats.org/packages/nested-multipart-parser) 7 | 8 | Parser for nested data for *multipart/form*, usable in any Python project or via the [Django Rest Framework integration](https://www.django-rest-framework.org/community/third-party-packages/#parsers).. 9 | # Installation: 10 | 11 | ```bash 12 | pip install nested-multipart-parser 13 | ``` 14 | 15 | # Usage: 16 | 17 | ```python 18 | from nested_multipart_parser import NestedParser 19 | 20 | options = { 21 | "separator": "bracket" 22 | } 23 | 24 | def my_view(): 25 | # `options` is optional 26 | parser = NestedParser(data, options) 27 | if parser.is_valid(): 28 | validate_data = parser.validate_data 29 | ... 30 | else: 31 | print(parser.errors) 32 | 33 | ``` 34 | 35 | ### Django Rest Framework 36 | 37 | you can define parser for all view in settings.py 38 | ```python 39 | REST_FRAMEWORK = { 40 | "DEFAULT_PARSER_CLASSES": [ 41 | "nested_multipart_parser.drf.DrfNestedParser", 42 | ] 43 | } 44 | ``` 45 | or directly in your view 46 | 47 | ```python 48 | from nested_multipart_parser.drf import DrfNestedParser 49 | ... 50 | 51 | class YourViewSet(viewsets.ViewSet): 52 | parser_classes = (DrfNestedParser,) 53 | ``` 54 | 55 | 56 | ## What it does: 57 | 58 | The parser takes the request data and transforms it into a Python dictionary. 59 | 60 | example: 61 | 62 | ```python 63 | # input: 64 | { 65 | 'title': 'title', 66 | 'date': "time", 67 | 'simple_object.my_key': 'title' 68 | 'simple_object.my_list[0]': True, 69 | 'langs[0].id': 666, 70 | 'langs[0].title': 'title', 71 | 'langs[0].description': 'description', 72 | 'langs[0].language': "language", 73 | 'langs[1].id': 4566, 74 | 'langs[1].title': 'title1', 75 | 'langs[1].description': 'description1', 76 | 'langs[1].language': "language1" 77 | } 78 | 79 | # result: 80 | { 81 | 'title': 'title', 82 | 'date': "time", 83 | 'simple_object': { 84 | 'my_key': 'title', 85 | 'my_list': [ 86 | True 87 | ] 88 | }, 89 | 'langs': [ 90 | { 91 | 'id': 666, 92 | 'title': 'title', 93 | 'description': 'description', 94 | 'language': 'language' 95 | }, 96 | { 97 | 'id': 4566, 98 | 'title': 'title1', 99 | 'description': 'description1', 100 | 'language': 'language1' 101 | } 102 | ] 103 | } 104 | ``` 105 | 106 | ## How it works 107 | ### Lists 108 | 109 | Attributes whose sub‑keys are *only numbers* become Python lists: 110 | ```python 111 | data = { 112 | 'title[0]': 'my-value', 113 | 'title[1]': 'my-second-value' 114 | } 115 | output = { 116 | 'title': [ 117 | 'my-value', 118 | 'my-second-value' 119 | ] 120 | } 121 | ``` 122 | > Important notes 123 | 124 | - Indices must be contiguous and start at 0. 125 | - You cannot turn a primitive (int, bool, str) into a list later, e.g. 126 | ```python 127 | 'title': 42, 128 | 'title[object]': 42 # ❌ invalid 129 | ``` 130 | 131 | ### Dictionaries 132 | 133 | Attributes whose sub‑keys are *not pure numbers* become nested dictionaries: 134 | ```python 135 | data = { 136 | 'title.key0': 'my-value', 137 | 'title.key7': 'my-second-value' 138 | } 139 | output = { 140 | 'title': { 141 | 'key0': 'my-value', 142 | 'key7': 'my-second-value' 143 | } 144 | } 145 | ``` 146 | 147 | ### Chaining keys 148 | 149 | >Keys can be chained arbitrarily. Below are examples for each separator option: 150 | 151 | |Separator| Example key | Meaning| 152 | |-|-|-| 153 | |mixed‑dot| the[0].chained.key[0].are.awesome[0][0] |List → object → list → object …| 154 | |mixed| the[0]chained.key[0]are.awesome[0][0] | Same as mixed‑dot but without the dot after a list| 155 | |bracket| the[0][chained][key][0][are][awesome][0][0] | Every sub‑key is wrapped in brackets| 156 | |dot |the.0.chained.key.0.are.awesome.0.0 | Dots separate every level; numeric parts become lists| 157 | 158 | 159 | Rules to keep in mind 160 | - First key must exist – e.g. title[0] or just title. 161 | - For mixed / mixed‑dot, [] denotes a list and . denotes an object. 162 | - mixed‑dot behaves like mixed but inserts a dot when an object follows a list. 163 | - For bracket, each sub‑key must be surrounded by brackets ([ ]). 164 | - For bracket or dot, numeric sub‑keys become list elements; non‑numeric become objects. 165 | - No spaces between separators. 166 | - By default, duplicate keys are disallowed (see options). 167 | - Empty structures are supported: 168 | Empty list → "article.authors[]": None → {"article": {"authors": []}} 169 | Empty dict → "article.": None → {"article": {}} (available with dot, mixed, mixed‑dot) 170 | 171 | 172 | 173 | 174 | ## Options 175 | 176 | ```python 177 | { 178 | # Separator (default: 'mixed‑dot') 179 | # mixed‑dot : article[0].title.authors[0] -> "john doe" 180 | # mixed : article[0]title.authors[0] -> "john doe" 181 | # bracket : article[0][title][authors][0] -> "john doe" 182 | # dot : article.0.title.authors.0 -> "john doe" 183 | 'separator': 'bracket' | 'dot' | 'mixed' | 'mixed‑dot', 184 | 185 | # Raise an exception when duplicate keys are encountered 186 | # Example: 187 | # { 188 | # "article": 42, 189 | # "article[title]": 42, 190 | # } 191 | 'raise_duplicate': True, # default: True 192 | 193 | # Override duplicate keys (requires raise_duplicate=False) 194 | # Example: 195 | # { 196 | # "article": 42, 197 | # "article[title]": 42, 198 | # } 199 | # Result: 200 | # { 201 | # "article": { 202 | # "title": 42 203 | # } 204 | # } 205 | 'assign_duplicate': False, # default: False 206 | } 207 | ``` 208 | 209 | ## Options for Django Rest Framwork: 210 | ```python 211 | # settings.py 212 | DRF_NESTED_MULTIPART_PARSER = { 213 | "separator": "mixed‑dot", 214 | "raise_duplicate": True, 215 | "assign_duplicate": False, 216 | 217 | # If True, the parser’s output is converted to a QueryDict; 218 | # if False, a plain Python dict is returned. 219 | "querydict": True, 220 | } 221 | ``` 222 | 223 | ## JavaScript integration: 224 | A companion [multipart-object](https://github.com/remigermain/multipart-object) library exists to convert a JavaScript object into the flat, nested format expected by this parser. 225 | 226 | ## License 227 | 228 | [MIT](https://github.com/remigermain/multipart-object/blob/main/LICENSE) 229 | -------------------------------------------------------------------------------- /tests/test_parser_separator.py: -------------------------------------------------------------------------------- 1 | from nested_multipart_parser import NestedParser 2 | from unittest import TestCase 3 | 4 | 5 | class TestSettingsSeparator(TestCase): 6 | def test_parser_object(self): 7 | data = {"title.id.length": "lalal"} 8 | parser = NestedParser(data, {"separator": "dot"}) 9 | self.assertTrue(parser.is_valid()) 10 | expected = {"title": {"id": {"length": "lalal"}}} 11 | self.assertEqual(expected, parser.validate_data) 12 | 13 | def test_parser_object2(self): 14 | data = {"title.id.length": "lalal", "title.id.value": "lalal"} 15 | parser = NestedParser(data, {"separator": "dot"}) 16 | self.assertTrue(parser.is_valid()) 17 | expected = {"title": {"id": {"length": "lalal", "value": "lalal"}}} 18 | self.assertEqual(expected, parser.validate_data) 19 | 20 | def test_parser_object3(self): 21 | data = { 22 | "title.id.length": "lalal", 23 | "title.id.value": "lalal", 24 | "title.id.value": "lalal", 25 | "title.value": "lalal", 26 | } 27 | parser = NestedParser(data, {"separator": "dot"}) 28 | self.assertTrue(parser.is_valid()) 29 | expected = { 30 | "title": {"id": {"length": "lalal", "value": "lalal"}, "value": "lalal"} 31 | } 32 | self.assertEqual(expected, parser.validate_data) 33 | 34 | def test_parser_object4(self): 35 | data = { 36 | "title.id.length": "lalal", 37 | "title.id.value": "lalal", 38 | "title.id.value": "lalal", 39 | "title.value": "lalal", 40 | "sub": "lalal", 41 | "title.id.recusrive.only.field": "icci", 42 | } 43 | parser = NestedParser(data, {"separator": "dot"}) 44 | self.assertTrue(parser.is_valid()) 45 | expected = { 46 | "title": { 47 | "id": { 48 | "length": "lalal", 49 | "value": "lalal", 50 | "recusrive": {"only": {"field": "icci"}}, 51 | }, 52 | "value": "lalal", 53 | }, 54 | "sub": "lalal", 55 | } 56 | self.assertEqual(expected, parser.validate_data) 57 | 58 | def test_parser_object_reasing2(self): 59 | data = { 60 | "title.id.length": "lalal", 61 | "title.value": "lalal", 62 | "sub": "lalal", 63 | "title.id.recusrive.only.field": "icci", 64 | } 65 | parser = NestedParser(data, {"separator": "dot"}) 66 | self.assertTrue(parser.is_valid()) 67 | expected = { 68 | "title": { 69 | "id": { 70 | "length": "lalal", 71 | "recusrive": { 72 | "only": {"field": "icci"}, 73 | }, 74 | }, 75 | "value": "lalal", 76 | }, 77 | "sub": "lalal", 78 | } 79 | self.assertEqual(expected, parser.validate_data) 80 | 81 | def test_parser_classic(self): 82 | data = {"title": "lalal"} 83 | parser = NestedParser(data, {"separator": "dot"}) 84 | self.assertTrue(parser.is_valid()) 85 | expected = {"title": "lalal"} 86 | self.assertDictEqual(expected, parser.validate_data) 87 | 88 | def test_parser_list_out_index(self): 89 | data = { 90 | "title": "dddddddddddddd", 91 | "tist.0": "lalal", 92 | "tist.2": "lalal", 93 | } 94 | parser = NestedParser(data, {"separator": "dot"}) 95 | self.assertFalse(parser.is_valid()) 96 | 97 | def test_parser_empty_list_out_index(self): 98 | data = { 99 | "title": "dddddddddddddd", 100 | "tist.0": "lalal", 101 | "tist.": "lalal", 102 | } 103 | parser = NestedParser(data, {"separator": "dot"}) 104 | self.assertFalse(parser.is_valid()) 105 | 106 | def test_parser_list(self): 107 | data = {"title": "lalal", "list.0": "icicici"} 108 | parser = NestedParser(data, {"separator": "dot"}) 109 | expected = {"title": "lalal", "list": ["icicici"]} 110 | self.assertTrue(parser.is_valid()) 111 | self.assertEqual(expected, parser.validate_data) 112 | 113 | def test_parser_list_index_out_of_range(self): 114 | data = {"title": "lalal", "list.0": "icicici"} 115 | parser = NestedParser(data, {"separator": "dot"}) 116 | self.assertTrue(parser.is_valid()) 117 | expected = {"title": "lalal", "list": ["icicici"]} 118 | self.assertEqual(expected, parser.validate_data) 119 | 120 | def test_parser_list_object_index(self): 121 | data = {"title": "lalal", "list.length.0": "icicici"} 122 | parser = NestedParser(data, {"separator": "dot"}) 123 | expected = {"title": "lalal", "list": {"length": ["icicici"]}} 124 | self.assertTrue(parser.is_valid()) 125 | self.assertEqual(expected, parser.validate_data) 126 | 127 | def test_parser_space_key(self): 128 | data = { 129 | "title ": "lalal", 130 | } 131 | parser = NestedParser(data, {"separator": "dot"}) 132 | self.assertFalse(parser.is_valid()) 133 | 134 | def test_real(self): 135 | data = { 136 | "title": "title", 137 | "date": "time", 138 | "langs.0.id": "id", 139 | "langs.0.title": "title", 140 | "langs.0.description": "description", 141 | "langs.0.language": "language", 142 | "langs.1.id": "id1", 143 | "langs.1.title": "title1", 144 | "langs.1.description": "description1", 145 | "langs.1.language": "language1", 146 | } 147 | parser = NestedParser(data, {"separator": "dot"}) 148 | self.assertTrue(parser.is_valid()) 149 | expected = { 150 | "title": "title", 151 | "date": "time", 152 | "langs": [ 153 | { 154 | "id": "id", 155 | "title": "title", 156 | "description": "description", 157 | "language": "language", 158 | }, 159 | { 160 | "id": "id1", 161 | "title": "title1", 162 | "description": "description1", 163 | "language": "language1", 164 | }, 165 | ], 166 | } 167 | self.assertDictEqual(parser.validate_data, expected) 168 | 169 | def test_parser_rewrite_key_list(self): 170 | data = { 171 | "title": "lalal", 172 | "title.0": "lalal", 173 | } 174 | parser = NestedParser(data, {"separator": "dot"}) 175 | self.assertFalse(parser.is_valid()) 176 | 177 | def test_parser_rewrite_key_boject(self): 178 | data = { 179 | "title": "lalal", 180 | "title.object": "lalal", 181 | } 182 | parser = NestedParser(data, {"separator": "dot"}) 183 | self.assertFalse(parser.is_valid()) 184 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | from nested_multipart_parser import NestedParser 2 | from unittest import TestCase 3 | 4 | 5 | class TestParser(TestCase): 6 | def setUp(self): 7 | self.parser = NestedParser("") 8 | 9 | def test_is_valid_no_call(self): 10 | parser = NestedParser({"key": "value"}) 11 | with self.assertRaises(Exception) as ctx: 12 | parser.validate_data 13 | self.assertIsInstance(ctx.exception, ValueError) 14 | 15 | def test_is_valid_wrong(self): 16 | parser = NestedParser({"key[]]]": "value"}) 17 | self.assertFalse(parser.is_valid()) 18 | with self.assertRaises(Exception) as ctx: 19 | parser.validate_data 20 | self.assertIsInstance(ctx.exception, ValueError) 21 | 22 | def test_parser_object(self): 23 | data = {"title[id][length]": "lalal"} 24 | parser = NestedParser(data, {"separator": "bracket"}) 25 | self.assertTrue(parser.is_valid()) 26 | expected = {"title": {"id": {"length": "lalal"}}} 27 | self.assertEqual(expected, parser.validate_data) 28 | 29 | def test_parser_object2(self): 30 | data = {"title[id][length]": "lalal", "title[id][value]": "lalal"} 31 | parser = NestedParser(data, {"separator": "bracket"}) 32 | self.assertTrue(parser.is_valid()) 33 | expected = {"title": {"id": {"length": "lalal", "value": "lalal"}}} 34 | self.assertEqual(expected, parser.validate_data) 35 | 36 | def test_parser_object3(self): 37 | data = { 38 | "title[id][length]": "lalal", 39 | "title[id][value]": "lalal", 40 | "title[id][value]": "lalal", 41 | "title[value]": "lalal", 42 | } 43 | parser = NestedParser(data, {"separator": "bracket"}) 44 | self.assertTrue(parser.is_valid()) 45 | expected = { 46 | "title": {"id": {"length": "lalal", "value": "lalal"}, "value": "lalal"} 47 | } 48 | self.assertEqual(expected, parser.validate_data) 49 | 50 | def test_parser_object4(self): 51 | data = { 52 | "title[id][length]": "lalal", 53 | "title[id][value]": "lalal", 54 | "title[id][value]": "lalal", 55 | "title[value]": "lalal", 56 | "sub": "lalal", 57 | "title[id][recusrive][only][field]": "icci", 58 | } 59 | parser = NestedParser(data, {"separator": "bracket"}) 60 | self.assertTrue(parser.is_valid()) 61 | expected = { 62 | "title": { 63 | "id": { 64 | "length": "lalal", 65 | "value": "lalal", 66 | "recusrive": {"only": {"field": "icci"}}, 67 | }, 68 | "value": "lalal", 69 | }, 70 | "sub": "lalal", 71 | } 72 | self.assertEqual(expected, parser.validate_data) 73 | 74 | def test_parser_empty_object(self): 75 | data = {"title[id][]": "lalal"} 76 | parser = NestedParser(data, {"separator": "bracket"}) 77 | self.assertTrue(parser.is_valid()) 78 | expected = {"title": {"id": []}} 79 | self.assertEqual(expected, parser.validate_data) 80 | 81 | def test_parser_object_reasing2(self): 82 | data = { 83 | "title[id][length]": "lalal", 84 | "title[value]": "lalal", 85 | "sub": "lalal", 86 | "title[id][recusrive][only][field]": "icci", 87 | } 88 | parser = NestedParser(data, {"separator": "bracket"}) 89 | self.assertTrue(parser.is_valid()) 90 | expected = { 91 | "title": { 92 | "id": { 93 | "length": "lalal", 94 | "recusrive": { 95 | "only": {"field": "icci"}, 96 | }, 97 | }, 98 | "value": "lalal", 99 | }, 100 | "sub": "lalal", 101 | } 102 | self.assertEqual(expected, parser.validate_data) 103 | 104 | def test_parser_classic(self): 105 | data = {"title": "lalal"} 106 | parser = NestedParser(data) 107 | self.assertTrue(parser.is_valid()) 108 | expected = {"title": "lalal"} 109 | self.assertDictEqual(expected, parser.validate_data) 110 | 111 | def test_parser_list_out_index(self): 112 | data = { 113 | "title": "dddddddddddddd", 114 | "tist[0]": "lalal", 115 | "tist[2]": "lalal", 116 | } 117 | parser = NestedParser(data) 118 | self.assertFalse(parser.is_valid()) 119 | 120 | def test_parser_empty_list_out_index(self): 121 | data = { 122 | "title": "dddddddddddddd", 123 | "tist[0]": "lalal", 124 | "tist[]": "lalal", 125 | } 126 | parser = NestedParser(data) 127 | self.assertFalse(parser.is_valid()) 128 | 129 | def test_parser_list(self): 130 | data = {"title": "lalal", "list[0]": "icicici"} 131 | parser = NestedParser(data) 132 | expected = {"title": "lalal", "list": ["icicici"]} 133 | self.assertTrue(parser.is_valid()) 134 | self.assertEqual(expected, parser.validate_data) 135 | 136 | def test_parser_list_index_out_of_range(self): 137 | data = {"title": "lalal", "list[0]": "icicici"} 138 | parser = NestedParser(data) 139 | self.assertTrue(parser.is_valid()) 140 | expected = {"title": "lalal", "list": ["icicici"]} 141 | self.assertEqual(expected, parser.validate_data) 142 | 143 | def test_parser_list_object_index(self): 144 | data = {"title": "lalal", "list[length][0]": "icicici"} 145 | parser = NestedParser(data, {"separator": "bracket"}) 146 | expected = {"title": "lalal", "list": {"length": ["icicici"]}} 147 | self.assertTrue(parser.is_valid()) 148 | self.assertEqual(expected, parser.validate_data) 149 | 150 | def test_real(self): 151 | data = { 152 | "title": "title", 153 | "date": "time", 154 | "langs[0][id]": "id", 155 | "langs[0][title]": "title", 156 | "langs[0][description]": "description", 157 | "langs[0][language]": "language", 158 | "langs[1][id]": "id1", 159 | "langs[1][title]": "title1", 160 | "langs[1][description]": "description1", 161 | "langs[1][language]": "language1", 162 | } 163 | parser = NestedParser(data, {"separator": "bracket"}) 164 | self.assertTrue(parser.is_valid()) 165 | expected = { 166 | "title": "title", 167 | "date": "time", 168 | "langs": [ 169 | { 170 | "id": "id", 171 | "title": "title", 172 | "description": "description", 173 | "language": "language", 174 | }, 175 | { 176 | "id": "id1", 177 | "title": "title1", 178 | "description": "description1", 179 | "language": "language1", 180 | }, 181 | ], 182 | } 183 | self.assertDictEqual(parser.validate_data, expected) 184 | 185 | def test_parser_rewrite_key_list(self): 186 | data = { 187 | "title": "lalal", 188 | "title[0]": "lalal", 189 | } 190 | parser = NestedParser(data) 191 | self.assertFalse(parser.is_valid()) 192 | 193 | def test_parser_rewrite_key_boject(self): 194 | data = { 195 | "title": "lalal", 196 | "title[object]": "lalal", 197 | } 198 | parser = NestedParser(data) 199 | self.assertFalse(parser.is_valid()) 200 | 201 | def test_wrong_settings(self): 202 | 203 | data = {"data": "data"} 204 | 205 | with self.assertRaises(AssertionError): 206 | NestedParser(data, options={"separator": "worng"}) 207 | with self.assertRaises(AssertionError): 208 | NestedParser(data, options={"raise_duplicate": "need_boolean"}) 209 | with self.assertRaises(AssertionError): 210 | NestedParser(data, options={"assign_duplicate": "need_boolean"}) 211 | -------------------------------------------------------------------------------- /tests/test_mixed_separator.py: -------------------------------------------------------------------------------- 1 | from nested_multipart_parser import NestedParser 2 | from unittest import TestCase 3 | 4 | 5 | class TestSettingsSeparatorMixed(TestCase): 6 | def test_assign_duplicate_list(self): 7 | data = {"title": 42, "title[0]": 101} 8 | p = NestedParser( 9 | data, 10 | {"raise_duplicate": False, "assign_duplicate": True, "separator": "mixed"}, 11 | ) 12 | self.assertTrue(p.is_valid()) 13 | expected = {"title": [101]} 14 | self.assertEqual(p.validate_data, expected) 15 | 16 | def test_assign_duplicate_number_after_list(self): 17 | data = { 18 | "title[0]": 101, 19 | "title": 42, 20 | } 21 | p = NestedParser( 22 | data, 23 | {"raise_duplicate": False, "assign_duplicate": True, "separator": "mixed"}, 24 | ) 25 | self.assertTrue(p.is_valid()) 26 | expected = {"title": 42} 27 | self.assertEqual(p.validate_data, expected) 28 | 29 | def test_assign_nested_duplicate_number_after_list(self): 30 | data = { 31 | "title[0]sub[0]": 101, 32 | "title[0]sub": 42, 33 | } 34 | p = NestedParser( 35 | data, 36 | {"raise_duplicate": False, "assign_duplicate": True, "separator": "mixed"}, 37 | ) 38 | self.assertTrue(p.is_valid()) 39 | expected = {"title": [{"sub": 42}]} 40 | self.assertEqual(p.validate_data, expected) 41 | 42 | def test_assign_nested_duplicate_number_after_list2(self): 43 | data = { 44 | "title[0]sub": 42, 45 | "title[0]sub[0]": 101, 46 | } 47 | p = NestedParser( 48 | data, 49 | {"raise_duplicate": False, "assign_duplicate": True, "separator": "mixed"}, 50 | ) 51 | self.assertTrue(p.is_valid()) 52 | expected = {"title": [{"sub": [101]}]} 53 | self.assertEqual(p.validate_data, expected) 54 | 55 | def test_assign_nested_duplicate_number_after_dict(self): 56 | data = { 57 | "title[0]sub": 42, 58 | "title[0]sub.title": 101, 59 | } 60 | p = NestedParser( 61 | data, 62 | {"raise_duplicate": False, "assign_duplicate": True, "separator": "mixed"}, 63 | ) 64 | self.assertTrue(p.is_valid()) 65 | expected = {"title": [{"sub": {"title": 101}}]} 66 | self.assertEqual(p.validate_data, expected) 67 | 68 | def test_assign_nested_duplicate_number_after_dict2(self): 69 | data = { 70 | "title[0]sub.title": 101, 71 | "title[0]sub": 42, 72 | } 73 | p = NestedParser( 74 | data, 75 | {"raise_duplicate": False, "assign_duplicate": True, "separator": "mixed"}, 76 | ) 77 | self.assertTrue(p.is_valid()) 78 | expected = {"title": [{"sub": 42}]} 79 | self.assertEqual(p.validate_data, expected) 80 | 81 | def test_mixed_spearator(self): 82 | data = { 83 | "title": "lalal", 84 | "article.object": "lalal", 85 | } 86 | parser = NestedParser(data, {"separator": "mixed"}) 87 | self.assertTrue(parser.is_valid()) 88 | expected = {"title": "lalal", "article": {"object": "lalal"}} 89 | self.assertEqual(expected, parser.validate_data) 90 | 91 | def test_mixed_int_object(self): 92 | data = { 93 | "title": "lalal", 94 | "article.0": "lalal", 95 | } 96 | parser = NestedParser(data, {"separator": "mixed"}) 97 | self.assertTrue(parser.is_valid()) 98 | expected = {"title": "lalal", "article": {"0": "lalal"}} 99 | self.assertEqual(expected, parser.validate_data) 100 | 101 | def test_mixed_int_list(self): 102 | data = { 103 | "title": "lalal", 104 | "article[0]": "lalal", 105 | } 106 | parser = NestedParser(data, {"separator": "mixed"}) 107 | self.assertTrue(parser.is_valid()) 108 | expected = {"title": "lalal", "article": ["lalal"]} 109 | self.assertEqual(expected, parser.validate_data) 110 | 111 | def test_real(self): 112 | data = { 113 | "title": "title", 114 | "date": "time", 115 | "langs[0]id": "id", 116 | "langs[0]title": "title", 117 | "langs[0]description": "description", 118 | "langs[0]language": "language", 119 | "langs[1]id": "id1", 120 | "langs[1]title": "title1", 121 | "langs[1]description": "description1", 122 | "langs[1]language": "language1", 123 | } 124 | parser = NestedParser(data, {"separator": "mixed"}) 125 | self.assertTrue(parser.is_valid()) 126 | expected = { 127 | "title": "title", 128 | "date": "time", 129 | "langs": [ 130 | { 131 | "id": "id", 132 | "title": "title", 133 | "description": "description", 134 | "language": "language", 135 | }, 136 | { 137 | "id": "id1", 138 | "title": "title1", 139 | "description": "description1", 140 | "language": "language1", 141 | }, 142 | ], 143 | } 144 | self.assertDictEqual(parser.validate_data, expected) 145 | 146 | def test_mixed_invalid_list_index(self): 147 | data = { 148 | "title": "lalal", 149 | "article[0f]": "lalal", 150 | } 151 | parser = NestedParser(data, {"separator": "mixed"}) 152 | self.assertFalse(parser.is_valid()) 153 | 154 | def test_mixed_list_empty_index(self): 155 | data = { 156 | "title": "lalal", 157 | "article[]": "lalal", 158 | } 159 | parser = NestedParser(data, {"separator": "mixed"}) 160 | self.assertTrue(parser.is_valid()) 161 | expected = {"title": "lalal", "article": []} 162 | self.assertDictEqual(parser.validate_data, expected) 163 | 164 | def test_mixed_invalid_bracket(self): 165 | data = { 166 | "title": "lalal", 167 | "article[": "lalal", 168 | } 169 | parser = NestedParser(data, {"separator": "mixed"}) 170 | self.assertFalse(parser.is_valid()) 171 | 172 | def test_mixed_invalid_bracket2(self): 173 | data = { 174 | "title": "lalal", 175 | "article]": "lalal", 176 | } 177 | parser = NestedParser(data, {"separator": "mixed"}) 178 | self.assertFalse(parser.is_valid()) 179 | 180 | def test_mixed_invalid_list_dot(self): 181 | data = { 182 | "title": "lalal", 183 | "article[3.]": "lalal", 184 | } 185 | parser = NestedParser(data, {"separator": "mixed"}) 186 | self.assertFalse(parser.is_valid()) 187 | 188 | def test_mixed_invalid_list_negative_index(self): 189 | data = { 190 | "title": "lalal", 191 | "article[-3]": "lalal", 192 | } 193 | parser = NestedParser(data, {"separator": "mixed"}) 194 | self.assertFalse(parser.is_valid()) 195 | 196 | def test_mixed_invalid_object(self): 197 | data = { 198 | "title": "lalal", 199 | "article..op": "lalal", 200 | } 201 | parser = NestedParser(data, {"separator": "mixed"}) 202 | self.assertFalse(parser.is_valid()) 203 | 204 | def test_mixed_empty_obj(self): 205 | data = { 206 | "title": "lalal", 207 | "article.op.": "lalal", 208 | } 209 | parser = NestedParser(data, {"separator": "mixed"}) 210 | self.assertTrue(parser.is_valid()) 211 | expected = {"title": "lalal", "article": {"op": {}}} 212 | self.assertDictEqual(parser.validate_data, expected) 213 | 214 | def test_mixed_empty_obj_2(self): 215 | data = { 216 | "title": "lalal", 217 | "article[0].op": "lalal", 218 | } 219 | parser = NestedParser(data, {"separator": "mixed"}) 220 | self.assertTrue(parser.is_valid()) 221 | expected = {"title": "lalal", "article": [{"op": "lalal"}]} 222 | self.assertDictEqual(parser.validate_data, expected) 223 | 224 | def test_mixed_invalid_object4(self): 225 | data = { 226 | "title": "lalal", 227 | "article.op..": "lalal", 228 | } 229 | parser = NestedParser(data, {"separator": "mixed"}) 230 | self.assertFalse(parser.is_valid()) 231 | 232 | def test_mixed_invalid_list_with_object_dot(self): 233 | data = { 234 | "title": "lalal", 235 | "article[0].op..": "lalal", 236 | } 237 | parser = NestedParser(data, {"separator": "mixed"}) 238 | self.assertFalse(parser.is_valid()) 239 | 240 | def test_mixed_empty_object_dot2(self): 241 | data = { 242 | "title": "lalal", 243 | "article[0]op[0]e.": "lalal", 244 | } 245 | parser = NestedParser(data, {"separator": "mixed"}) 246 | self.assertTrue(parser.is_valid()) 247 | expected = {"title": "lalal", "article": [{"op": [{"e": {}}]}]} 248 | self.assertDictEqual(parser.validate_data, expected) 249 | 250 | def test_mixed_invalid_list_with_object_dot3(self): 251 | data = { 252 | "title": "lalal", 253 | "article.op.[0]": "lalal", 254 | } 255 | parser = NestedParser(data, {"separator": "mixed"}) 256 | self.assertFalse(parser.is_valid()) 257 | -------------------------------------------------------------------------------- /tests/test_mixed_dot_separator.py: -------------------------------------------------------------------------------- 1 | from nested_multipart_parser import NestedParser 2 | from unittest import TestCase 3 | 4 | 5 | class TestSettingsSeparatorMixedDot(TestCase): 6 | def test_assign_duplicate_list(self): 7 | data = {"title": 42, "title[0]": 101} 8 | p = NestedParser( 9 | data, 10 | { 11 | "raise_duplicate": False, 12 | "assign_duplicate": True, 13 | "separator": "mixed-dot", 14 | }, 15 | ) 16 | self.assertTrue(p.is_valid()) 17 | expected = {"title": [101]} 18 | self.assertEqual(p.validate_data, expected) 19 | 20 | def test_assign_duplicate_number_after_list(self): 21 | data = { 22 | "title[0]": 101, 23 | "title": 42, 24 | } 25 | p = NestedParser( 26 | data, 27 | { 28 | "raise_duplicate": False, 29 | "assign_duplicate": True, 30 | "separator": "mixed-dot", 31 | }, 32 | ) 33 | self.assertTrue(p.is_valid()) 34 | expected = {"title": 42} 35 | self.assertEqual(p.validate_data, expected) 36 | 37 | def test_assign_nested_duplicate_number_after_list(self): 38 | data = { 39 | "title[0].sub[0]": 101, 40 | "title[0].sub": 42, 41 | } 42 | p = NestedParser( 43 | data, 44 | { 45 | "raise_duplicate": False, 46 | "assign_duplicate": True, 47 | "separator": "mixed-dot", 48 | }, 49 | ) 50 | self.assertTrue(p.is_valid()) 51 | expected = {"title": [{"sub": 42}]} 52 | self.assertEqual(p.validate_data, expected) 53 | 54 | def test_assign_nested_duplicate_number_after_list2(self): 55 | data = { 56 | "title[0].sub": 42, 57 | "title[0].sub[0]": 101, 58 | } 59 | p = NestedParser( 60 | data, 61 | { 62 | "raise_duplicate": False, 63 | "assign_duplicate": True, 64 | "separator": "mixed-dot", 65 | }, 66 | ) 67 | self.assertTrue(p.is_valid()) 68 | expected = {"title": [{"sub": [101]}]} 69 | self.assertEqual(p.validate_data, expected) 70 | 71 | def test_assign_nested_duplicate_number_after_dict(self): 72 | data = { 73 | "title[0].sub": 42, 74 | "title[0].sub.title": 101, 75 | } 76 | p = NestedParser( 77 | data, 78 | { 79 | "raise_duplicate": False, 80 | "assign_duplicate": True, 81 | "separator": "mixed-dot", 82 | }, 83 | ) 84 | self.assertTrue(p.is_valid()) 85 | expected = {"title": [{"sub": {"title": 101}}]} 86 | self.assertEqual(p.validate_data, expected) 87 | 88 | def test_assign_nested_duplicate_number_after_dict2(self): 89 | data = { 90 | "title[0].sub.title": 101, 91 | "title[0].sub": 42, 92 | } 93 | p = NestedParser( 94 | data, 95 | { 96 | "raise_duplicate": False, 97 | "assign_duplicate": True, 98 | "separator": "mixed-dot", 99 | }, 100 | ) 101 | self.assertTrue(p.is_valid()) 102 | expected = {"title": [{"sub": 42}]} 103 | self.assertEqual(p.validate_data, expected) 104 | 105 | def test_mixed_spearator(self): 106 | data = { 107 | "title": "lalal", 108 | "article.object": "lalal", 109 | } 110 | parser = NestedParser(data, {"separator": "mixed-dot"}) 111 | self.assertTrue(parser.is_valid()) 112 | expected = {"title": "lalal", "article": {"object": "lalal"}} 113 | self.assertEqual(expected, parser.validate_data) 114 | 115 | def test_mixed_int_object(self): 116 | data = { 117 | "title": "lalal", 118 | "article.0": "lalal", 119 | } 120 | parser = NestedParser(data, {"separator": "mixed-dot"}) 121 | self.assertTrue(parser.is_valid()) 122 | expected = {"title": "lalal", "article": {"0": "lalal"}} 123 | self.assertEqual(expected, parser.validate_data) 124 | 125 | def test_mixed_int_list(self): 126 | data = { 127 | "title": "lalal", 128 | "article[0]": "lalal", 129 | } 130 | parser = NestedParser(data, {"separator": "mixed-dot"}) 131 | self.assertTrue(parser.is_valid()) 132 | expected = {"title": "lalal", "article": ["lalal"]} 133 | self.assertEqual(expected, parser.validate_data) 134 | 135 | def test_real(self): 136 | data = { 137 | "title": "title", 138 | "date": "time", 139 | "langs[0].id": "id", 140 | "langs[0].title": "title", 141 | "langs[0].description": "description", 142 | "langs[0].language": "language", 143 | "langs[1].id": "id1", 144 | "langs[1].title": "title1", 145 | "langs[1].description": "description1", 146 | "langs[1].language": "language1", 147 | } 148 | parser = NestedParser(data, {"separator": "mixed-dot"}) 149 | self.assertTrue(parser.is_valid()) 150 | expected = { 151 | "title": "title", 152 | "date": "time", 153 | "langs": [ 154 | { 155 | "id": "id", 156 | "title": "title", 157 | "description": "description", 158 | "language": "language", 159 | }, 160 | { 161 | "id": "id1", 162 | "title": "title1", 163 | "description": "description1", 164 | "language": "language1", 165 | }, 166 | ], 167 | } 168 | self.assertDictEqual(parser.validate_data, expected) 169 | 170 | def test_mixed_invalid_list_index(self): 171 | data = { 172 | "title": "lalal", 173 | "article[0f]": "lalal", 174 | } 175 | parser = NestedParser(data, {"separator": "mixed-dot"}) 176 | self.assertFalse(parser.is_valid()) 177 | 178 | def test_mixed_invalid_list_empty_index(self): 179 | data = { 180 | "title": "lalal", 181 | "article[]": None, 182 | } 183 | parser = NestedParser(data, {"separator": "mixed-dot"}) 184 | self.assertTrue(parser.is_valid()) 185 | expected = {"title": "lalal", "article": []} 186 | self.assertDictEqual(parser.validate_data, expected) 187 | 188 | def test_mixed_invalid_bracket(self): 189 | data = { 190 | "title": "lalal", 191 | "article[": "lalal", 192 | } 193 | parser = NestedParser(data, {"separator": "mixed-dot"}) 194 | self.assertFalse(parser.is_valid()) 195 | 196 | def test_mixed_invalid_bracket2(self): 197 | data = { 198 | "title": "lalal", 199 | "article]": "lalal", 200 | } 201 | parser = NestedParser(data, {"separator": "mixed-dot"}) 202 | self.assertFalse(parser.is_valid()) 203 | 204 | def test_mixed_invalid_list_dot(self): 205 | data = { 206 | "title": "lalal", 207 | "article[3.]": "lalal", 208 | } 209 | parser = NestedParser(data, {"separator": "mixed-dot"}) 210 | self.assertFalse(parser.is_valid()) 211 | 212 | def test_mixed_invalid_list_negative_index(self): 213 | data = { 214 | "title": "lalal", 215 | "article[-3]": "lalal", 216 | } 217 | parser = NestedParser(data, {"separator": "mixed-dot"}) 218 | self.assertFalse(parser.is_valid()) 219 | 220 | def test_mixed_invalid_object(self): 221 | data = { 222 | "title": "lalal", 223 | "article..op": "lalal", 224 | } 225 | parser = NestedParser(data, {"separator": "mixed-dot"}) 226 | self.assertFalse(parser.is_valid()) 227 | 228 | def test_mixed_invalid_object2(self): 229 | data = { 230 | "title": "lalal", 231 | "article.op.": "lalal", 232 | } 233 | parser = NestedParser(data, {"separator": "mixed-dot"}) 234 | self.assertTrue(parser.is_valid()) 235 | expected = {"title": "lalal", "article": {"op": {}}} 236 | self.assertDictEqual(parser.validate_data, expected) 237 | 238 | def test_mixed_invalid_object3(self): 239 | data = { 240 | "title": "lalal", 241 | "article.op..": "lalal", 242 | } 243 | parser = NestedParser(data, {"separator": "mixed-dot"}) 244 | self.assertFalse(parser.is_valid()) 245 | 246 | def test_mixed_invalid_object4(self): 247 | data = { 248 | "title": "lalal", 249 | "article[0]op": "lalal", 250 | } 251 | parser = NestedParser(data, {"separator": "mixed-dot"}) 252 | self.assertFalse(parser.is_valid()) 253 | 254 | def test_mixed_invalid_list_with_object_dot(self): 255 | data = { 256 | "title": "lalal", 257 | "article[0].op..": "lalal", 258 | } 259 | parser = NestedParser(data, {"separator": "mixed-dot"}) 260 | self.assertFalse(parser.is_valid()) 261 | 262 | def test_mixed_list_with_object_dot2(self): 263 | data = { 264 | "title": "lalal", 265 | "article[0]op[0]e.": "lalal", 266 | } 267 | parser = NestedParser(data, {"separator": "mixed-dot"}) 268 | self.assertFalse(parser.is_valid()) 269 | 270 | def test_mixed_invalid_list_with_object_dot3(self): 271 | data = { 272 | "title": "lalal", 273 | "article.op.[0]": "lalal", 274 | } 275 | parser = NestedParser(data, {"separator": "mixed-dot"}) 276 | self.assertFalse(parser.is_valid()) 277 | -------------------------------------------------------------------------------- /tests/test_drf.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from django.conf import settings 4 | from django.http import QueryDict 5 | 6 | settings.configure() 7 | 8 | from django.core.files.uploadedfile import (InMemoryUploadedFile, 9 | SimpleUploadedFile) 10 | from django.test.client import encode_multipart # noqa: E402 11 | from rest_framework.exceptions import ParseError # noqa: E402 12 | from rest_framework.request import Request # noqa: E402 13 | # need to be after settings configure 14 | from rest_framework.test import APIRequestFactory # noqa: E402 15 | 16 | from nested_multipart_parser.drf import (DrfNestedParser, # noqa: E402 17 | NestedParser) 18 | 19 | 20 | def toQueryDict(data): 21 | q = QueryDict(mutable=True) 22 | q.update(data) 23 | q._mutable = False 24 | return q 25 | 26 | 27 | class TestDrfParser(unittest.TestCase): 28 | def setUp(self): 29 | # reset settings 30 | setattr(settings, "DRF_NESTED_MULTIPART_PARSER", {}) 31 | 32 | def test_querydict_mutable(self): 33 | parser = NestedParser( 34 | { 35 | "dtc.key": "value", 36 | "dtc.vla": "value2", 37 | "list[0]": "value1", 38 | "list[1]": "value2", 39 | "string": "value", 40 | "dtc.hh.oo": "sub", 41 | "dtc.hh.aa": "sub2", 42 | }, 43 | ) 44 | self.assertTrue(parser.is_valid()) 45 | expected = toQueryDict( 46 | { 47 | "dtc": { 48 | "key": "value", 49 | "vla": "value2", 50 | "hh": {"oo": "sub", "aa": "sub2"}, 51 | }, 52 | "list": [ 53 | "value1", 54 | "value2", 55 | ], 56 | "string": "value", 57 | } 58 | ) 59 | self.assertEqual(parser.validate_data, expected) 60 | self.assertFalse(parser.validate_data.mutable) 61 | 62 | def test_settings(self): 63 | from nested_multipart_parser.drf import NestedParser 64 | 65 | data = {"article.title": "youpi"} 66 | p = NestedParser(data) 67 | self.assertTrue(p.is_valid()) 68 | expected = toQueryDict({"article": {"title": "youpi"}}) 69 | self.assertEqual(p.validate_data, expected) 70 | 71 | # set settings 72 | from django.conf import settings 73 | 74 | options = {"separator": "dot"} 75 | setattr(settings, "DRF_NESTED_MULTIPART_PARSER", options) 76 | 77 | p = NestedParser(data) 78 | self.assertTrue(p.is_valid()) 79 | expected = toQueryDict({"article": {"title": "youpi"}}) 80 | self.assertEqual(p.validate_data, expected) 81 | 82 | def parser_boundary(self, data): 83 | factory = APIRequestFactory() 84 | content = encode_multipart("BoUnDaRyStRiNg", data) 85 | content_type = "multipart/form-data; boundary=BoUnDaRyStRiNg" 86 | request = factory.put("/notes/547/", content, content_type=content_type) 87 | return Request(request, parsers=[DrfNestedParser()]) 88 | 89 | def test_views(self): 90 | setattr(settings, "DRF_NESTED_MULTIPART_PARSER", {"separator": "bracket"}) 91 | data = { 92 | "dtc[key]": "value", 93 | "dtc[vla]": "value2", 94 | "list[0]": "value1", 95 | "list[1]": "value2", 96 | "string": "value", 97 | "dtc[hh][oo]": "sub", 98 | "dtc[hh][aa]": "sub2", 99 | } 100 | results = self.parser_boundary(data) 101 | expected = toQueryDict( 102 | { 103 | "dtc": { 104 | "key": "value", 105 | "vla": "value2", 106 | "hh": {"oo": "sub", "aa": "sub2"}, 107 | }, 108 | "list": [ 109 | "value1", 110 | "value2", 111 | ], 112 | "string": "value", 113 | } 114 | ) 115 | self.assertEqual(results.data, expected) 116 | self.assertFalse(results.data.mutable) 117 | 118 | def test_views_options(self): 119 | setattr(settings, "DRF_NESTED_MULTIPART_PARSER", {"separator": "dot"}) 120 | data = { 121 | "dtc.key": "value", 122 | "dtc.vla": "value2", 123 | "list.0": "value1", 124 | "list.1": "value2", 125 | "string": "value", 126 | "dtc.hh.oo": "sub", 127 | "dtc.hh.aa": "sub2", 128 | } 129 | results = self.parser_boundary(data) 130 | expected = toQueryDict( 131 | { 132 | "dtc": { 133 | "key": "value", 134 | "vla": "value2", 135 | "hh": {"oo": "sub", "aa": "sub2"}, 136 | }, 137 | "list": [ 138 | "value1", 139 | "value2", 140 | ], 141 | "string": "value", 142 | } 143 | ) 144 | self.assertEqual(results.data, expected) 145 | self.assertFalse(results.data.mutable) 146 | 147 | def test_views_invalid(self): 148 | setattr(settings, "DRF_NESTED_MULTIPART_PARSER", {"separator": "bracket"}) 149 | data = {"dtc[key": "value", "dtc[hh][oo]": "sub", "dtc[hh][aa]": "sub2"} 150 | results = self.parser_boundary(data) 151 | 152 | with self.assertRaises(ParseError): 153 | results.data 154 | 155 | def test_views_invalid_options(self): 156 | setattr(settings, "DRF_NESTED_MULTIPART_PARSER", {"separator": "invalid"}) 157 | data = {"dtc[key]": "value", "dtc[hh][oo]": "sub", "dtc[hh][aa]": "sub2"} 158 | results = self.parser_boundary(data) 159 | 160 | with self.assertRaises(AssertionError): 161 | results.data 162 | 163 | def test_views_options_mixed_invalid(self): 164 | setattr(settings, "DRF_NESTED_MULTIPART_PARSER", {"separator": "mixed"}) 165 | data = {"dtc[key]": "value", "dtc[hh][oo]": "sub", "dtc[hh][aa]": "sub2"} 166 | results = self.parser_boundary(data) 167 | 168 | with self.assertRaises(ParseError): 169 | results.data 170 | 171 | def test_views_options_mixed_valid(self): 172 | setattr(settings, "DRF_NESTED_MULTIPART_PARSER", {"separator": "mixed"}) 173 | data = {"dtc.key": "value", "dtc.hh.oo": "sub", "dtc.hh.aa": "sub2"} 174 | results = self.parser_boundary(data) 175 | 176 | expected = {"dtc": {"key": "value", "hh": {"aa": "sub2", "oo": "sub"}}} 177 | 178 | self.assertEqual(results.data, toQueryDict(expected)) 179 | 180 | def test_output_querydict(self): 181 | setattr( 182 | settings, 183 | "DRF_NESTED_MULTIPART_PARSER", 184 | {"separator": "mixed", "querydict": False}, 185 | ) 186 | data = {"dtc.key": "value", "dtc.hh.oo": "sub", "dtc.hh.aa": "sub2"} 187 | results = self.parser_boundary(data) 188 | 189 | expected = {"dtc": {"key": "value", "hh": {"aa": "sub2", "oo": "sub"}}} 190 | 191 | self.assertDictEqual(results.data, expected) 192 | 193 | def test_nested_files(self): 194 | file = SimpleUploadedFile("file.png", b"file_content", content_type="image/png") 195 | file1 = SimpleUploadedFile( 196 | "file.pdf", b"file_content", content_type="application/pdf" 197 | ) 198 | 199 | data = { 200 | "file": file, 201 | "title": "title", 202 | "files[0].description": "description", 203 | "files[1].file": file1, 204 | "files[1].description": "description2", 205 | } 206 | results = self.parser_boundary(data) 207 | 208 | # files is not in 209 | expected = { 210 | "file": file, 211 | "title": "title", 212 | "files": [ 213 | { 214 | "description": "description", 215 | }, 216 | { 217 | "file": file1, 218 | "description": "description2", 219 | }, 220 | ], 221 | } 222 | data = results.data.dict() 223 | self.assertEqual(len(data), 3) 224 | 225 | self.assertIsInstance(data["file"], InMemoryUploadedFile) 226 | self.assertEqual(data["title"], expected["title"]) 227 | 228 | self.assertEqual(len(data["files"]), 2) 229 | self.assertIsInstance(data["files"], list) 230 | 231 | self.assertIsInstance(data["files"][0], dict) 232 | self.assertEqual(len(data["files"][0]), 1) 233 | self.assertEqual(data["files"][0]["description"], "description") 234 | 235 | self.assertIsInstance(data["files"][1], dict) 236 | self.assertEqual(len(data["files"][1]), 2) 237 | self.assertEqual(data["files"][1]["description"], "description2") 238 | self.assertIsInstance(data["files"][1]["file"], InMemoryUploadedFile) 239 | 240 | def test_nested_files_index_not_order(self): 241 | file = SimpleUploadedFile("file.png", b"file_content", content_type="image/png") 242 | file1 = SimpleUploadedFile("file.pdf", b"file_content", content_type="application/pdf") 243 | 244 | data = { 245 | "files[2]": file1, 246 | "files[1].description": "description2", 247 | "files[1].file": file, 248 | "files[0].description": "description", 249 | } 250 | results = self.parser_boundary(data) 251 | 252 | data = results.data.dict() 253 | self.assertEqual(len(data), 1) 254 | 255 | self.assertEqual(len(data["files"]), 3) 256 | self.assertIsInstance(data["files"], list) 257 | 258 | self.assertIsInstance(data["files"][0], dict) 259 | self.assertEqual(len(data["files"][0]), 1) 260 | self.assertEqual(data["files"][0]["description"], "description") 261 | 262 | self.assertIsInstance(data["files"][1], dict) 263 | self.assertEqual(len(data["files"][1]), 2) 264 | self.assertEqual(data["files"][1]["description"], "description2") 265 | self.assertIsInstance(data["files"][1]["file"], InMemoryUploadedFile) 266 | 267 | self.assertIsInstance(data["files"][2], InMemoryUploadedFile) --------------------------------------------------------------------------------