diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..8fe0d3c --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,87 @@ +version: 2.1 + +jobs: + lint: + docker: + # When upgrading the Docker image, make sure the cache key is updated too. + - image: cimg/python:3.11.9 + resource_class: small + steps: + - checkout + - run: + name: Install system Python packages + command: pipx install uv pre-commit + # Installing packages into a virtualenv is useful as it provides an easier target to cache. + # Note that this step needs including in all jobs that install Python packages. + - run: + name: Create virtualenv + command: | + uv venv /home/circleci/venv/ + echo "source /home/circleci/venv/bin/activate" >> $BASH_ENV + - restore_cache: + keys: + - &cache-key python-3.11.9-packages-v1-{{ checksum "requirements/development.txt" }} + - &cache-key-prefix python-3.11.9-packages-v1- + - run: + name: Install dependencies + command: make dev + - save_cache: + key: *cache-key + paths: + - "~/venv/" + - "~/.cache/pip" + - run: + name: Run ruff formatter + command: make ruff_format + when: always + - run: + name: Run ruff linter + command: make ruff_lint + when: always + # - run: + # name: Run Mypy + # command: make mypy + # when: always + - run: + name: Run pre-commit hooks + environment: + # Don't run pre-commit checks which have a dedicated CI step. + # Also, don't complain about commits on the main branch in CI. + SKIP: ruff-lint,ruff-format,no-commit-to-branch + command: pre-commit run --all-files + when: always + - store_test_results: + path: test-results + + test: + docker: + # When upgrading the Docker image, make sure the cache key is updated too. + - image: cimg/python:3.11.9 + resource_class: small + steps: + - checkout + - run: + name: Install additional Python versions and system Python packages + command: | + # Install additional Python versions that Nox needs. Note the + # `cimg/python:3.11` image already has Python 3.10 installed. + pyenv install 3.12.3 + pyenv global 3.11.9 3.12.3 + pipx install uv nox + when: always + - run: + name: Run tests + # To start with, it's cost-effective to run all matrix tests in one + # CI job. But as the test suite grows, it will make more sense to + # split the matrix sessions across multiple CI jobs. This can be done + # using CircleCI's matrix jobs functionality to pass in the Nox session name to run. + command: make matrix_test + when: always + - store_test_results: + path: test-results + +workflows: + test-build: + jobs: + - lint + - test diff --git a/.github/workflows/CODEOWNERS b/.github/workflows/CODEOWNERS new file mode 100644 index 0000000..48795db --- /dev/null +++ b/.github/workflows/CODEOWNERS @@ -0,0 +1,4 @@ +# Example of a CODEOWNERS file + +# Specify owners for the entire repository +* @nicholasbunn @isabellanorris diff --git a/.github/workflows/build_and_publish.yaml b/.github/workflows/build_and_publish.yaml new file mode 100644 index 0000000..cbdf672 --- /dev/null +++ b/.github/workflows/build_and_publish.yaml @@ -0,0 +1,54 @@ +name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI + +on: + release: + types: [created] + +jobs: + build: + name: Build distribution 📦 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install pypa/build + run: >- + python3 -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: >- + Publish Python 🐍 distribution 📦 to PyPI + if: github.repository_owner == 'kraken-tech' && github.ref_type == 'tag' + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/drf-recursive + timeout-minutes: 5 + + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index ae73f83..b077a81 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,29 @@ -*.pyc -*.db -*~ -.* +# Python byte-code +__pycache__/ +*.py[cod] -html/ -htmlcov/ -coverage/ +# Distribution / packaging build/ dist/ *.egg-info/ MANIFEST +*.whl +# Linting +.mypy_cache/ +.import_linter_cache/ + +# Testing +.pytest_cache/ + +# Environments +.venv/ +.direnv/ +.envrc bin/ include/ lib/ local/ -!.gitignore -!.travis.yml +# Environment variables +.env diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d7c0b75 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +# This file contains configuration for the pre-commit (https://pre-commit.com/) tool. + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-added-large-files # Prevent giant files from being committed + - id: check-case-conflict # Checks for files that conflict in case-insensitive filesystems + - id: check-json # Attempts to load all json files to verify syntax + - id: check-merge-conflict # Check for files that contain merge conflict strings + - id: check-symlinks # Checks for symlinks which do not point to anything + - id: check-xml # Attempts to load all xml files to verify syntax + - id: check-yaml # Attempts to load all yaml files to verify syntax + - id: debug-statements # Check for debugger imports and breakpoint() calls in python code + - id: end-of-file-fixer # Makes sure files end in a newline and only a newline + - id: no-commit-to-branch + # Protect 'main' branch from direct commits and also ensure branch names are lowercase to + # avoid clashes on case-insensitive filesystems + args: ['-p', '.*[^0-9a-z-_/.=].*'] + - id: trailing-whitespace # Trims trailing whitespace + + - repo: local + # We prefer to use local hooks as much as possible for formatting and linting checks. We + # install these tools locally anyway so editors can run them on a pre-save hook. Using local + # tools here ensures the versions used by the editor, pre-commit and CI all stay in sync. + hooks: + - id: ruff-lint + name: "lint Python code with ruff" + entry: "ruff check" + language: system + types: [python] + require_serial: true + + - id: ruff-format + name: "check Python formatting with ruff" + entry: "ruff format --check" + language: system + types: [python] + require_serial: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index e53508b..0000000 --- a/.travis.yml +++ /dev/null @@ -1,40 +0,0 @@ -language: python -python: - - "3.6" - -sudo: false - -env: - - TOX_ENV=py27-django1.8-drf3.3 - - TOX_ENV=py27-django1.8-drf3.4 - - TOX_ENV=py27-django1.8-drf3.5 - - TOX_ENV=py27-django1.9-drf3.3 - - TOX_ENV=py27-django1.9-drf3.4 - - TOX_ENV=py27-django1.9-drf3.5 - - TOX_ENV=py27-django1.10-drf3.3 - - TOX_ENV=py27-django1.10-drf3.4 - - TOX_ENV=py27-django1.10-drf3.5 - - TOX_ENV=py36-django1.8-drf3.3 - - TOX_ENV=py36-django1.8-drf3.4 - - TOX_ENV=py36-django1.8-drf3.5 - - TOX_ENV=py36-django1.9-drf3.3 - - TOX_ENV=py36-django1.9-drf3.4 - - TOX_ENV=py36-django1.9-drf3.5 - - TOX_ENV=py36-django1.9-drf3.6 - - TOX_ENV=py36-django1.10-drf3.3 - - TOX_ENV=py36-django1.10-drf3.4 - - TOX_ENV=py36-django1.10-drf3.5 - - TOX_ENV=py36-django1.10-drf3.6 - - TOX_ENV=py36-django1.11-drf3.3 - - TOX_ENV=py36-django1.11-drf3.4 - - TOX_ENV=py36-django1.11-drf3.5 - - TOX_ENV=py36-django1.11-drf3.6 - -matrix: - fast_finish: true - -install: - - pip install tox - -script: - - tox -e $TOX_ENV diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..791ad45 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,63 @@ +# Changelog and Versioning + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.4.0] - 2024-10-03 + +### Added + +- Tested support for Python 3.12 +- Tested support for Django versions 5.1 + +### Changed + +### Removed + +## [0.3.1] - 2024-09-03 + +### Added + +### Changed + +- Moved the dependencies for test packages (`pytest`, `pytest-django`, `pytest-cov`) into dev dependencies. + +### Removed + +## [0.3.0] - 2024-08-28 + +### Added + +### Changed + +- The package import semantics from `kraken.django_rest_framework_recursive` to `django_rest_framework_recursive`. +- Links in the README.md file to be to the correct files in the repository. +- The `pytest-django` version to be *minimum* `4.7.0` rather than *exactly* `4.7.0`. + +### Removed + +## [0.2.0] - 2024-08-22 + +### Added + +- Tested support for Python 3.10 -> 3.11 +- Tested support for Django versions 3.2 -> 5.0 +- Tested support for Django REST Framework versions 3.12 -> 3.15 +- Development support using MyPy, Ruff, and UV +- Pre-commit set-up to format before code is pushed + +### Changed + +- Formatting to follow Kraken open-source conventions +- Matrix testing runner from Tox to Nox + +### Removed + +- Tested support for all Python versions < 3.10. +- Tested support for all Django versions < 3.2 +- Tested support for all Django REST Framework versions < 3.13. +- CI configuration on Travis diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index ca446cc..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -recursive-exclude * __pycache__ -recursive-exclude * *.py[co] diff --git a/README.md b/README.md index cf5adf0..d8c5976 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,237 @@ -# djangorestframework-recursive +# Django REST Framework Recursive (Fork) -[![build-status-image]][travis] -[![pypi-version]][pypi] +This repository is the friendly fork of the original [django-rest-framework-recursive](https://github.com/heywbj/django-rest-framework-recursive) by [heywbj](https://github.com/heywbj). As the original repo is no longer being actively maintained, we've friendly forked it here to undertake maintenance for modern versions of Python, Django, and Django Rest Framework. -## Overview +This **package** provides a `RecursiveField` that enables you to serailize a tree, linked list, or even a directed acyclic graph. It also supports validation, deserialization, ModeSerializer, and multi-step recursive structures. -Recursive Serialization for Django REST framework +### Example Usage -This package provides a `RecursiveField` that enables you to serialize a tree, -linked list, or even a directed acyclic graph. Also supports validation, -deserialization, ModelSerializers, and multi-step recursive structures. - - -## Example +#### Tree Recursion ```python from rest_framework import serializers -from rest_framework_recursive.fields import RecursiveField +from django_rest_framework_recursive.fields import RecursiveField class TreeSerializer(serializers.Serializer): name = serializers.CharField() children = serializers.ListField(child=RecursiveField()) ``` -see [**here**][tests] for more usage examples +### Linked List Recursion +```python +from rest_framework import serializers +from django_rest_framework_recursive.fields import RecursiveField + +class LinkSerializer(serializers.Serializer): + name = serializers.CharField(max_length=25) + next = RecursiveField(allow_null=True) +``` + +Further use cases are documented in the tests, see [**here**][tests] for more usage examples + + +## Prerequisites + +This package supports: +- Python 3.10, 3.11, 3.12 +- Django 3.2, 4.0, 4.1, 4.2, 5.0, 5.1 +- Django Rest Framework 3.12, 3.13, 3.14, 3.15 -## Requirements +For an exact list of tested version combinations, see the `valid_version_combinations` set in the [noxfile](https://github.com/kraken-tech/django-rest-framework-recursive/blob/master/noxfile.py) -* Python (Tested on 2.7, 3.4, 3.6) -* Django (Tested on 1.8, 1.9, 2.0) -* Django REST Framework (Tested on 3.3, 3.7) +During development you will also need: +- `uv` installed as a system package. +- pre-commit 3 _(Optional, but strongly recommended)_ ## Installation Install using `pip`... -```bash -$ pip install djangorestframework-recursive ``` +pip install drf-recursive +``` + +## Local development + +When making changes please remember to update the `CHANGELOG.md`, which follows the guidelines at +[keepachangelog]. Add your changes to the `[Unreleased]` section when you create your PR. + +[keepachangelog]: https://keepachangelog.com/ + +### Installation -## Release notes +Ensure one of the above Pythons is installed and used by the `python` executable:**** -### 0.1.2 -* This is the first release to include release notes. -* Use inspect.signature when available. This avoids emitting deprecation warnings on Python 3. -* Updated CI versions. djangorestframework-recursive is now tested against DRF - 3.3-3.6, Python 2.7 and 3.6 and Django 1.8-1.11. +```sh +python --version +Python 3.10.13 # or any of the supported versions +``` -## Testing +Ensure `uv` is installed as a system package. This can be done with `pipx` or Homebrew. -Install testing requirements. +Then create and activate a virtual environment. If you don't have any other way of managing virtual +environments this can be done by running: -```bash -$ pip install -r requirements.txt +```sh +uv venv +source .venv/bin/activate ``` -Run with runtests. +You could also use [virtualenvwrapper], [direnv] or any similar tool to help manage your virtual +environments. + +Once you are in an active virtual environment run -```bash -$ ./runtests.py +```sh +make dev ``` -You can also use the excellent [tox](http://tox.readthedocs.org/en/latest/) testing tool to run the tests against all supported versions of Python and Django. Install tox globally, and then simply run: +This will set up your local development environment, installing all development dependencies. + +[virtualenvwrapper]: https://virtualenvwrapper.readthedocs.io/ +[direnv]: https://direnv.net + +### Testing (single Python version) + +To run the test suite using the Python version of your virtual environment, run: -```bash -$ tox +```sh +make test ``` +### Testing (all supported Python versions) + +To test against multiple Python (and package) versions, we need to: + +- Have [`nox`][nox] installed outside of the virtualenv. This is best done using `pipx`: + + ```sh + pipx install nox + ``` + +- Ensure that all supported Python versions are installed and available on your system (as e.g. + `python3.10`, `python3.11` etc). This can be done with `pyenv`. + +Then run `nox` with: + +```sh +nox +``` + +Nox will create a separate virtual environment for each combination of Python and package versions +defined in `noxfile.py`. + +To list the available sessions, run: + +```sh +nox --list-sessions +``` + +To run the test suite in a specific Nox session, use: + +```sh +nox -s $SESSION_NAME +``` + +[nox]: https://nox.thea.codes/en/stable/ + +### Static analysis + +Run all static analysis tools with: + +```sh +make lint +``` + +### Auto formatting + +Reformat code to conform with our conventions using: + +```sh +make format +``` + +### Dependencies + +Package dependencies are declared in `pyproject.toml`. + +- _package_ dependencies in the `dependencies` array in the `[project]` section. +- _development_ dependencies in the `dev` array in the `[project.optional-dependencies]` section. + +For local development, the dependencies declared in `pyproject.toml` are pinned to specific +versions using the `requirements/development.txt` lock file. + +#### Adding a new dependency + +To install a new Python dependency add it to the appropriate section in `pyproject.toml` and then +run: + +```sh +make dev +``` + +This will: + +1. Build a new version of the `requirements/development.txt` lock file containing the newly added + package. +2. Sync your installed packages with those pinned in `requirements/development.txt`. + +This will not change the pinned versions of any packages already in any requirements file unless +needed by the new packages, even if there are updated versions of those packages available. + +Remember to commit your changed `requirements/development.txt` files alongside the changed +`pyproject.toml`. + +#### Removing a dependency + +Removing Python dependencies works exactly the same way: edit `pyproject.toml` and then run +`make dev`. + +#### Updating all Python packages + +To update the pinned versions of all packages simply run: + +```sh +make update +``` + +This will update the pinned versions of every package in the `requirements/development.txt` lock +file to the latest version which is compatible with the constraints in `pyproject.toml`. + +You can then run: + +```sh +make dev +``` + +to sync your installed packages with the updated versions pinned in `requirements/development.txt`. + +#### Updating individual Python packages + +Upgrade a single development dependency with: + +```sh +uv pip compile -P $PACKAGE==$VERSION pyproject.toml --extra=dev --output-file=requirements/development.txt +``` + +You can then run: + +```sh +make dev +``` + +to sync your installed packages with the updated versions pinned in `requirements/development.txt`. + +## Versioning + +This project uses [SemVer] for versioning with no additional suffix after the version number. When +it is time for a new release, run the command `make version_{type}` where `{type}` should be +replaced with one of `major`, `minor`, `patch` depending on the type of changes in the release. + +The command will update the version in `pyproject.toml` and move the changes from the "Unreleased" +section of the changelog to a versioned section and create a new "Unreleased" section for future +improvements. -[build-status-image]: https://secure.travis-ci.org/heywbj/django-rest-framework-recursive.png?branch=master -[travis]: http://travis-ci.org/heywbj/django-rest-framework-recursive?branch=master -[pypi-version]: https://img.shields.io/pypi/v/djangorestframework-recursive.svg -[pypi]: https://pypi.python.org/pypi/djangorestframework-recursive -[tests]: https://github.com/heywbj/django-rest-framework-recursive/blob/master/tests/test_recursive.py +[semver]: https://semver.org/ diff --git a/makefile b/makefile new file mode 100644 index 0000000..eeb8ecc --- /dev/null +++ b/makefile @@ -0,0 +1,91 @@ +PIP_VERSION=24.0 +SHELL=/bin/bash + +# If we're running in CI then store Pytest output in a format which CircleCI can parse +ifdef CIRCLECI +MYPY_ARGS=--junit-xml=test-results/mypy.xml +endif + +# Standard entry points +# ===================== + +.PHONY:dev +dev: install_python_packages .git/hooks/pre-commit + +.PHONY:test +test: + pytest + +.PHONY:matrix_test +matrix_test: + nox + +.PHONY:lint +lint: ruff_format ruff_lint mypy + +.PHONY:ruff_format +ruff_format: + ruff format --check . + +.PHONY:ruff_lint +ruff_lint: + ruff check . + +.PHONY:mypy +mypy: + mypy $(MYPY_ARGS) + +.PHONY:format +format: + ruff format . + ruff check --fix . + +.PHONY:update +update: + uv pip compile pyproject.toml -q --upgrade --extra=dev --output-file=requirements/development.txt + +.PHONY:package +package: + python -m build + +.PHONY:version_major +version_major: + bump-my-version bump major + +.PHONY:version_minor +version_minor: + bump-my-version bump minor + +.PHONY:version_patch +version_patch: + bump-my-version bump patch + + +# Implementation details +# ====================== + +# Pip install all required Python packages +.PHONY:install_python_packages +install_python_packages: install_prerequisites requirements/development.txt + uv pip sync requirements/development.txt requirements/firstparty.txt + +# This target _could_ run `uv pip install` unconditionally because `uv pip +# install` is idempotent if versions have not changed. The benefits of checking +# the version number before installing are that if there's nothing to do then +# (a) it's faster and (b) it produces less noisy output. +.PHONY:install_prerequisites +install_prerequisites: + @if [ `uv pip show pip 2>/dev/null | awk '/^Version:/ {print $$2}'` != "$(PIP_VERSION)" ]; then \ + uv pip install pip==$(PIP_VERSION); \ + fi + +# Add new dependencies to requirements/development.txt whenever pyproject.toml changes +requirements/development.txt: pyproject.toml + uv pip compile pyproject.toml -q --extra=dev --output-file=requirements/development.txt + +.git/hooks/pre-commit: + @if type pre-commit >/dev/null 2>&1; then \ + pre-commit install; \ + else \ + echo "WARNING: pre-commit not installed." > /dev/stderr; \ + fi diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..27450eb --- /dev/null +++ b/noxfile.py @@ -0,0 +1,121 @@ +""" +This `noxfile.py` is configured to run the test suite with multiple versions of Python and multiple +versions of Django (used as an example). +""" + +import contextlib +import os +import tempfile +from typing import IO, Generator + +import nox + +# Use uv to manage venvs. +nox.options.default_venv_backend = "uv" + + +@contextlib.contextmanager +def temp_constraints_file() -> Generator[IO[str], None, None]: + with tempfile.NamedTemporaryFile(mode="w", prefix="constraints.", suffix=".txt") as f: + yield f + + +@contextlib.contextmanager +def temp_lock_file() -> Generator[IO[str], None, None]: + with tempfile.NamedTemporaryFile(mode="w", prefix="packages.", suffix=".txt") as f: + yield f + + +valid_version_combinations = [ + # Python 3.10 + ("3.10", "django>=3.2,<3.3", "djangorestframework>=3.12,<3.13"), + ("3.10", "django>=3.2,<3.3", "djangorestframework>=3.13,<3.14"), + ("3.10", "django>=3.2,<3.3", "djangorestframework>=3.14,<3.15"), + ("3.10", "django>=4.0,<4.1", "djangorestframework>=3.13,<3.14"), + ("3.10", "django>=4.0,<4.1", "djangorestframework>=3.14,<3.15"), + ("3.10", "django>=4.1,<4.2", "djangorestframework>=3.14,<3.15"), + ("3.10", "django>=4.2,<4.3", "djangorestframework>=3.14,<3.15"), + ("3.10", "django>=4.2,<4.3", "djangorestframework>=3.15,<3.16"), + # Python 3.11 + ("3.11", "django>=4.1,<4.2", "djangorestframework>=3.14,<3.15"), + ("3.11", "django>=4.1,<4.2", "djangorestframework>=3.15,<3.16"), + ("3.11", "django>=4.2,<4.3", "djangorestframework>=3.14,<3.15"), + ("3.11", "django>=4.2,<4.3", "djangorestframework>=3.15,<3.16"), + ("3.11", "django>=5.0,<5.1", "djangorestframework>=3.14,<3.15"), + ("3.11", "django>=5.0,<5.1", "djangorestframework>=3.15,<3.16"), + ("3.11", "django>=5.1,<5.2", "djangorestframework>=3.15,<3.16"), + ("3.11", "django>=5.1,<5.2", "djangorestframework>=3.16,<3.17"), + ("3.11", "django>=5.2,<5.3", "djangorestframework>=3.15,<3.16"), + ("3.11", "django>=5.2,<5.3", "djangorestframework>=3.16,<3.17"), + # Python 3.12 + ("3.12", "django>=4.1,<4.2", "djangorestframework>=3.14,<3.15"), + ("3.12", "django>=4.1,<4.2", "djangorestframework>=3.15,<3.16"), + ("3.12", "django>=4.2,<4.3", "djangorestframework>=3.14,<3.15"), + ("3.12", "django>=4.2,<4.3", "djangorestframework>=3.15,<3.16"), + ("3.12", "django>=5.0,<5.1", "djangorestframework>=3.14,<3.15"), + ("3.12", "django>=5.0,<5.1", "djangorestframework>=3.15,<3.16"), + ("3.12", "django>=5.1,<5.2", "djangorestframework>=3.14,<3.15"), + ("3.12", "django>=5.1,<5.2", "djangorestframework>=3.15,<3.16"), + ("3.12", "django>=5.1,<5.2", "djangorestframework>=3.16,<3.17"), + ("3.12", "django>=5.2,<5.3", "djangorestframework>=3.14,<3.15"), + ("3.12", "django>=5.2,<5.3", "djangorestframework>=3.15,<3.16"), + ("3.12", "django>=5.2,<5.3", "djangorestframework>=3.16,<3.17"), + # Python 3.13 + ("3.13", "django>=4.1,<4.2", "djangorestframework>=3.14,<3.15"), + ("3.13", "django>=4.1,<4.2", "djangorestframework>=3.15,<3.16"), + ("3.13", "django>=4.2,<4.3", "djangorestframework>=3.14,<3.15"), + ("3.13", "django>=4.2,<4.3", "djangorestframework>=3.15,<3.16"), + ("3.13", "django>=5.0,<5.1", "djangorestframework>=3.14,<3.15"), + ("3.13", "django>=5.0,<5.1", "djangorestframework>=3.15,<3.16"), + ("3.13", "django>=5.1,<5.2", "djangorestframework>=3.14,<3.15"), + ("3.13", "django>=5.1,<5.2", "djangorestframework>=3.15,<3.16"), + ("3.13", "django>=5.1,<5.2", "djangorestframework>=3.16,<3.17"), + ("3.13", "django>=5.2,<5.3", "djangorestframework>=3.14,<3.15"), + ("3.13", "django>=5.2,<5.3", "djangorestframework>=3.15,<3.16"), + ("3.13", "django>=5.2,<5.3", "djangorestframework>=3.16,<3.17"), +] + + +@nox.session() +@nox.parametrize("python, django_version, drf_version", valid_version_combinations) +def tests(session: nox.Session, django_version: str, drf_version: str) -> None: + """ + Run the test suite. + """ + with temp_constraints_file() as constraints_file, temp_lock_file() as lock_file: + # Create a constraints file with the parameterized package versions. + # It's easy to add more constraints here if needed. + constraints_file.write(f"{django_version}\n") + constraints_file.write(f"{drf_version}\n") + constraints_file.write("pytest-django>=4.7.0\n") + constraints_file.write("pytest>=8.3.2\n") + constraints_file.flush() + + # Compile a new development lock file with the additional package constraints from this + # session. Use a unique lock file name to avoid session pollution. + session.run( + "uv", + "pip", + "compile", + "--quiet", + "--strip-extras", + "--extra=dev", + "pyproject.toml", + "--constraint", + constraints_file.name, + "--output-file", + lock_file.name, + ) + + # Install the dependencies from the newly compiled lockfile and main package. + session.install("-r", lock_file.name, ".") + + # When run in CircleCI, create JUnit XML test results. + commands = ["pytest"] + if "CIRCLECI" in os.environ: + commands.append(f"--junitxml=test-results/junit.{session.name}.xml") + + session.run( + *commands, + *session.posargs, + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dee76e3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,163 @@ +# Packaging +# --------- + +[build-system] +requires = ["setuptools>=67.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +# This is the default but we include it to be explicit. +include-package-data = true + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +# Include the root-package `py.typed` file so Mypy uses inline type annotations. +"django_rest_framework_recursive" = ["django_rest_framework_recursive/py.typed"] + +# Project +# ------- + +[project] +name = "drf-recursive" +readme = "README.md" + +# Do not manually edit the version, use `make version_{type}` instead. +# This should match the version in the [tool.bumpversion] section. +version = "0.4.0" +dependencies = ["django>=3.2", "djangorestframework>=3.12.0"] + +[project.urls] +# See https://daniel.feldroy.com/posts/2023-08-pypi-project-urls-cheatsheet for +# additional URLs that can be included here. +repository = "https://github.com/kraken-tech/django-rest-framework-recursive" +changelog = "https://github.com/kraken-tech/django-rest-framework-recursive/blob/master/CHANGELOG.md" + +[project.optional-dependencies] +dev = [ + # Testing + "pytest", + "pytest-django>=4.7.0", + "pytest-cov", + "nox", # Install in virtualenv so Mypy has access to the package types. + + # Linting + "ruff", + "mypy", + + # Packaging + "build", + + # Versioning + "bump-my-version", +] + +# Ruff +# ---- + +[tool.ruff] +line-length = 99 + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # pyflakes + "F", + # isort + "I", +] +ignore = [ + # Ruff's formatter will try to respect the `line-length` setting + # but doesn't guarantee it - so we ignore the possible line length + # errors that the checker might raise. + "E501", +] + +[tool.ruff.lint.per-file-ignores] +# Allow unused imports in `__init__.py` files as these are convenience imports. +"**/__init__.py" = ["F401"] + +[tool.ruff.lint.isort] +known-first-party = ["kraken"] +lines-after-imports = 2 +section-order = [ + "future", + "standard-library", + "third-party", + "first-party", + "project", + "local-folder", +] + +[tool.ruff.lint.isort.sections] +"project" = ["django_rest_framework_recursive", "tests"] + +# Mypy +# ---- + +[tool.mypy] +files = "." +exclude = "build/" +# Mypy doesn't understand how to handle namespace packages unless we run it with +# the `explicit_package_bases` flag and specify where all the packages are +# located. The "$MYPY_CONFIG_FILE_DIR/src" path allows Mypy to find the "kraken" +# namespace package. The "$MYPY_CONFIG_FILE_DIR" path allows Mypy to find the +# "tests" package and noxfile.py. +explicit_package_bases = true +mypy_path = ["$MYPY_CONFIG_FILE_DIR/src", "$MYPY_CONFIG_FILE_DIR"] + +# Use strict defaults +strict = true +warn_unreachable = true +warn_no_return = true + +[[tool.mypy.overrides]] +# Don't require test functions to include types +module = "tests.*" +allow_untyped_defs = true +disable_error_code = "attr-defined" + +# Pytest +# ------ + +[tool.pytest.ini_options] +# Ensure error warnings are converted into test errors. +filterwarnings = "error" + +# Bump My Version +# --------------- + +[tool.bumpversion] +# Do not manually edit the version, use `make version_{type}` instead. +# This should match the version in the [project] section. +current_version = "0.4.0" +parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" +serialize = ["{major}.{minor}.{patch}"] +search = "{current_version}" +replace = "{new_version}" +regex = false +ignore_missing_version = false +tag = false +sign_tags = false +tag_name = "v{new_version}" +tag_message = "Bump version: {current_version} → {new_version}" +allow_dirty = false +commit = false +message = "Bump version: {current_version} → {new_version}" +commit_args = "" + +# Relabel the Unreleased section of the changelog and add a new unreleased section +# as a reminder to add to it. +[[tool.bumpversion.files]] +filename = "CHANGELOG.md" +search = "## [Unreleased]" +replace = "## [Unreleased]\n\n## [{new_version}] - {now:%Y-%m-%d}" + +# Update the project version. +[[tool.bumpversion.files]] +filename = "pyproject.toml" +regex = true +search = "^version = \"{current_version}\"" +replace = "version = \"{new_version}\"" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e766772..0000000 --- a/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -# Minimum Django and REST framework version -Django>=1.6 -djangorestframework>=3.0.0 - -# Test requirements -pytest-django==2.9.1 -pytest==2.8.5 -pytest-cov==2.2.0 - -# wheel for PyPI installs -wheel==0.24.0 diff --git a/requirements/development.txt b/requirements/development.txt new file mode 100644 index 0000000..0b26c7c --- /dev/null +++ b/requirements/development.txt @@ -0,0 +1,114 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile pyproject.toml --extra=dev --output-file=requirements/development.txt +annotated-types==0.7.0 + # via pydantic +argcomplete==3.5.0 + # via nox +asgiref==3.8.1 + # via django +bracex==2.5 + # via wcmatch +build==1.2.1 + # via drf-recursive (pyproject.toml) +bump-my-version==0.26.0 + # via drf-recursive (pyproject.toml) +click==8.1.7 + # via + # bump-my-version + # rich-click +colorlog==6.8.2 + # via nox +coverage==7.6.1 + # via pytest-cov +distlib==0.3.8 + # via virtualenv +django==5.1 + # via + # drf-recursive (pyproject.toml) + # djangorestframework +djangorestframework==3.15.2 + # via drf-recursive (pyproject.toml) +exceptiongroup==1.2.2 + # via pytest +filelock==3.15.4 + # via virtualenv +iniconfig==2.0.0 + # via pytest +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +mypy==1.11.1 + # via drf-recursive (pyproject.toml) +mypy-extensions==1.0.0 + # via mypy +nox==2024.4.15 + # via drf-recursive (pyproject.toml) +packaging==24.1 + # via + # build + # nox + # pytest +platformdirs==4.2.2 + # via virtualenv +pluggy==1.5.0 + # via pytest +prompt-toolkit==3.0.36 + # via questionary +pydantic==2.8.2 + # via + # bump-my-version + # pydantic-settings +pydantic-core==2.20.1 + # via pydantic +pydantic-settings==2.4.0 + # via bump-my-version +pygments==2.18.0 + # via rich +pyproject-hooks==1.1.0 + # via build +pytest==8.3.2 + # via + # drf-recursive (pyproject.toml) + # pytest-cov + # pytest-django +pytest-cov==5.0.0 + # via drf-recursive (pyproject.toml) +pytest-django==4.7.0 + # via drf-recursive (pyproject.toml) +python-dotenv==1.0.1 + # via pydantic-settings +questionary==2.0.1 + # via bump-my-version +rich==13.7.1 + # via + # bump-my-version + # rich-click +rich-click==1.8.3 + # via bump-my-version +ruff==0.6.1 + # via drf-recursive (pyproject.toml) +sqlparse==0.5.1 + # via django +tomli==2.0.1 + # via + # build + # coverage + # mypy + # nox + # pytest +tomlkit==0.13.2 + # via bump-my-version +typing-extensions==4.12.2 + # via + # asgiref + # mypy + # pydantic + # pydantic-core + # rich-click +virtualenv==20.26.3 + # via nox +wcmatch==9.0 + # via bump-my-version +wcwidth==0.2.13 + # via prompt-toolkit diff --git a/requirements/firstparty.txt b/requirements/firstparty.txt new file mode 100644 index 0000000..872af65 --- /dev/null +++ b/requirements/firstparty.txt @@ -0,0 +1,2 @@ +# Install this package in editable mode. +-e . diff --git a/rest_framework_recursive/__init__.py b/rest_framework_recursive/__init__.py deleted file mode 100644 index 10939f0..0000000 --- a/rest_framework_recursive/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '0.1.2' diff --git a/runtests.py b/runtests.py deleted file mode 100755 index e0067a9..0000000 --- a/runtests.py +++ /dev/null @@ -1,71 +0,0 @@ -#! /usr/bin/env python -from __future__ import print_function - -import pytest -import sys -import os -import subprocess - - -PYTEST_ARGS = { - 'default': ['tests'], - 'fast': ['tests', '-q'], -} - -sys.path.append(os.path.dirname(__file__)) - - -def exit_on_failure(ret, message=None): - if ret: - sys.exit(ret) - - -def split_class_and_function(string): - class_string, function_string = string.split('.', 1) - return "%s and %s" % (class_string, function_string) - - -def is_function(string): - # `True` if it looks like a test function is included in the string. - return string.startswith('test_') or '.test_' in string - - -def is_class(string): - # `True` if first character is uppercase - assume it's a class name. - return string[0] == string[0].upper() - - -if __name__ == "__main__": - try: - sys.argv.remove('--lintonly') - except ValueError: - run_tests = True - else: - run_tests = False - - try: - sys.argv.remove('--fast') - except ValueError: - style = 'default' - else: - style = 'fast' - - if len(sys.argv) > 1: - pytest_args = sys.argv[1:] - first_arg = pytest_args[0] - if first_arg.startswith('-'): - # `runtests.py [flags]` - pytest_args = ['tests'] + pytest_args - elif is_class(first_arg) and is_function(first_arg): - # `runtests.py TestCase.test_function [flags]` - expression = split_class_and_function(first_arg) - pytest_args = ['tests', '-k', expression] + pytest_args[1:] - elif is_class(first_arg) or is_function(first_arg): - # `runtests.py TestCase [flags]` - # `runtests.py test_function [flags]` - pytest_args = ['tests', '-k', pytest_args[0]] + pytest_args[1:] - else: - pytest_args = PYTEST_ARGS[style] - - if run_tests: - exit_on_failure(pytest.main(pytest_args)) diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 5e40900..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[wheel] -universal = 1 diff --git a/setup.py b/setup.py deleted file mode 100644 index f3d4982..0000000 --- a/setup.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -import re -import os -import sys -from setuptools import setup - - -name = 'djangorestframework-recursive' -package = 'rest_framework_recursive' -description = 'Recursive Serialization for Django REST framework' -url = 'https://github.com/heywbj/django-rest-framework-recursive' -author = 'Warren Jin' -author_email = 'jinwarren@gmail.com' -license = 'BSD' - - -def get_version(package): - """ - Return package version as listed in `__version__` in `init.py`. - """ - init_py = open(os.path.join(package, '__init__.py')).read() - return re.search("^__version__ = ['\"]([^'\"]+)['\"]", - init_py, re.MULTILINE).group(1) - - -def get_packages(package): - """ - Return root package and all sub-packages. - """ - return [dirpath - for dirpath, dirnames, filenames in os.walk(package) - if os.path.exists(os.path.join(dirpath, '__init__.py'))] - - -def get_package_data(package): - """ - Return all files under the root package, that are not in a - package themselves. - """ - walk = [(dirpath.replace(package + os.sep, '', 1), filenames) - for dirpath, dirnames, filenames in os.walk(package) - if not os.path.exists(os.path.join(dirpath, '__init__.py'))] - - filepaths = [] - for base, filenames in walk: - filepaths.extend([os.path.join(base, filename) - for filename in filenames]) - return {package: filepaths} - - -version = get_version(package) - - -if sys.argv[-1] == 'publish': - if os.system("pip freeze | grep wheel"): - print("wheel not installed.\nUse `pip install wheel`.\nExiting.") - sys.exit() - os.system("python setup.py sdist upload") - os.system("python setup.py bdist_wheel upload") - print("You probably want to also tag the version now:") - print(" git tag -a {0} -m 'version {0}'".format(version)) - print(" git push --tags") - sys.exit() - - -setup( - name=name, - version=version, - url=url, - license=license, - description=description, - author=author, - author_email=author_email, - packages=get_packages(package), - package_data=get_package_data(package), - install_requires=[ - 'Django', - 'djangorestframework >= 3.0' - ], - classifiers=[ - 'Development Status :: 2 - Pre-Alpha', - 'Environment :: Web Environment', - 'Framework :: Django', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Natural Language :: English', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Topic :: Internet :: WWW/HTTP', - ] -) diff --git a/src/django_rest_framework_recursive/__init__.py b/src/django_rest_framework_recursive/__init__.py new file mode 100644 index 0000000..df89e9b --- /dev/null +++ b/src/django_rest_framework_recursive/__init__.py @@ -0,0 +1,6 @@ +""" +This package provides a `RecursiveField` that allows you to serialize a tree, +linked list, or even a directed acyclic graph, where the children may be the +same type as the parent. It also supports validation, deserialization, +ModelSerializers, and multi-step recursive structures. +""" diff --git a/rest_framework_recursive/fields.py b/src/django_rest_framework_recursive/fields.py similarity index 81% rename from rest_framework_recursive/fields.py rename to src/django_rest_framework_recursive/fields.py index 6d717f2..2f15944 100644 --- a/rest_framework_recursive/fields.py +++ b/src/django_rest_framework_recursive/fields.py @@ -1,5 +1,6 @@ -import inspect import importlib +import inspect + from rest_framework.fields import Field from rest_framework.serializers import BaseSerializer @@ -39,19 +40,18 @@ class ListSerializer(self): # `rest_framework.serializers` calls to on a field object PROXIED_ATTRS = ( # methods - 'get_value', - 'get_initial', - 'run_validation', - 'get_attribute', - 'to_representation', - + "get_value", + "get_initial", + "run_validation", + "get_attribute", + "to_representation", # attributes - 'field_name', - 'source', - 'read_only', - 'default', - 'source_attrs', - 'write_only', + "field_name", + "source", + "read_only", + "default", + "source_attrs", + "write_only", ) def __init__(self, to=None, **kwargs): @@ -68,9 +68,7 @@ def __init__(self, to=None, **kwargs): # need to call super-constructor to support ModelSerializer super_kwargs = dict( - (key, kwargs[key]) - for key in kwargs - if key in _signature_parameters(Field.__init__) + (key, kwargs[key]) for key in kwargs if key in _signature_parameters(Field.__init__) ) super(RecursiveField, self).__init__(**super_kwargs) @@ -85,7 +83,7 @@ def proxied(self): if self.bind_args: field_name, parent = self.bind_args - if hasattr(parent, 'child') and parent.child is self: + if hasattr(parent, "child") and parent.child is self: # RecursiveField nested inside of a ListField parent_class = parent.parent.__class__ else: @@ -98,28 +96,26 @@ def proxied(self): proxied_class = parent_class else: try: - module_name, class_name = self.to.rsplit('.', 1) + module_name, class_name = self.to.rsplit(".", 1) except ValueError: module_name, class_name = parent_class.__module__, self.to try: - proxied_class = getattr( - importlib.import_module(module_name), class_name) + proxied_class = getattr(importlib.import_module(module_name), class_name) except Exception as e: - raise ImportError( - 'could not locate serializer %s' % self.to, e) + raise ImportError("could not locate serializer %s" % self.to, e) # Create a new serializer instance and proxy it proxied = proxied_class(**self.init_kwargs) proxied.bind(field_name, parent) self._proxied = proxied - + return self._proxied def __getattribute__(self, name): if name in RecursiveField.PROXIED_ATTRS: try: - proxied = object.__getattribute__(self, 'proxied') + proxied = object.__getattribute__(self, "proxied") return getattr(proxied, name) except AttributeError: pass diff --git a/tests/models.py b/src/django_rest_framework_recursive/py.typed similarity index 100% rename from tests/models.py rename to src/django_rest_framework_recursive/py.typed diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 31142ea..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,86 +0,0 @@ -def pytest_configure(): - from django.conf import settings - - settings.configure( - DEBUG_PROPAGATE_EXCEPTIONS=True, - DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:'}}, - SITE_ID=1, - SECRET_KEY='not very secret in tests', - USE_I18N=True, - USE_L10N=True, - STATIC_URL='/static/', - ROOT_URLCONF='tests.urls', - TEMPLATE_LOADERS=( - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', - ), - MIDDLEWARE_CLASSES=( - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - ), - INSTALLED_APPS=( - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.staticfiles', - - 'rest_framework', - 'rest_framework.authtoken', - 'tests', - ), - PASSWORD_HASHERS=( - 'django.contrib.auth.hashers.SHA1PasswordHasher', - 'django.contrib.auth.hashers.PBKDF2PasswordHasher', - 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', - 'django.contrib.auth.hashers.BCryptPasswordHasher', - 'django.contrib.auth.hashers.MD5PasswordHasher', - 'django.contrib.auth.hashers.CryptPasswordHasher', - ), - ) - - try: - import oauth_provider # NOQA - import oauth2 # NOQA - except ImportError: - pass - else: - settings.INSTALLED_APPS += ( - 'oauth_provider', - ) - - try: - import provider # NOQA - except ImportError: - pass - else: - settings.INSTALLED_APPS += ( - 'provider', - 'provider.oauth2', - ) - - # guardian is optional - try: - import guardian # NOQA - except ImportError: - pass - else: - settings.ANONYMOUS_USER_ID = -1 - settings.AUTHENTICATION_BACKENDS = ( - 'django.contrib.auth.backends.ModelBackend', - 'guardian.backends.ObjectPermissionBackend', - ) - settings.INSTALLED_APPS += ( - 'guardian', - ) - - try: - import django - django.setup() - except AttributeError: - pass diff --git a/tests/django_rest_framework_recursive/__init__.py b/tests/django_rest_framework_recursive/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/django_rest_framework_recursive/conftest.py b/tests/django_rest_framework_recursive/conftest.py new file mode 100644 index 0000000..965da5a --- /dev/null +++ b/tests/django_rest_framework_recursive/conftest.py @@ -0,0 +1,85 @@ +def pytest_configure(): + import django + from django.conf import settings + + settings.configure( + DEBUG_PROPAGATE_EXCEPTIONS=True, + DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}, + SITE_ID=1, + SECRET_KEY="not very secret in tests", + USE_I18N=True, + STATIC_URL="/static/", + ROOT_URLCONF="tests.urls", + TEMPLATE_LOADERS=( + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ), + MIDDLEWARE_CLASSES=( + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + ), + INSTALLED_APPS=( + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "rest_framework.authtoken", + "tests", + ), + PASSWORD_HASHERS=( + "django.contrib.auth.hashers.SHA1PasswordHasher", + "django.contrib.auth.hashers.PBKDF2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", + "django.contrib.auth.hashers.BCryptPasswordHasher", + "django.contrib.auth.hashers.MD5PasswordHasher", + "django.contrib.auth.hashers.CryptPasswordHasher", + ), + ) + + if django.get_version() < "4.0": + # This setting has been deprecated for Django 4.0 + settings.USE_L10N = True + + try: + import oauth_provider # NOQA + import oauth2 # NOQA + except ImportError: + pass + else: + settings.INSTALLED_APPS += ("oauth_provider",) + + try: + import provider # NOQA + except ImportError: + pass + else: + settings.INSTALLED_APPS += ( + "provider", + "provider.oauth2", + ) + + # guardian is optional + try: + import guardian # NOQA + except ImportError: + pass + else: + settings.ANONYMOUS_USER_ID = -1 + settings.AUTHENTICATION_BACKENDS = ( + "django.contrib.auth.backends.ModelBackend", + "guardian.backends.ObjectPermissionBackend", + ) + settings.INSTALLED_APPS += ("guardian",) + + try: + import django + + django.setup() + except AttributeError: + pass diff --git a/tests/django_rest_framework_recursive/test_package.py b/tests/django_rest_framework_recursive/test_package.py new file mode 100644 index 0000000..5da465a --- /dev/null +++ b/tests/django_rest_framework_recursive/test_package.py @@ -0,0 +1,247 @@ +from copy import deepcopy + +from django.db import models +from rest_framework import serializers + +from django_rest_framework_recursive.fields import RecursiveField + + +class LinkSerializer(serializers.Serializer): + name = serializers.CharField(max_length=25) + next = RecursiveField(allow_null=True) + + +class NodeSerializer(serializers.Serializer): + name = serializers.CharField() + children = serializers.ListField(child=RecursiveField()) + + +class ManyNullSerializer(serializers.Serializer): + name = serializers.CharField() + children = RecursiveField(required=False, allow_null=True, many=True) + + +class PingSerializer(serializers.Serializer): + ping_id = serializers.IntegerField() + pong = RecursiveField("PongSerializer", required=False) + + +class PongSerializer(serializers.Serializer): + pong_id = serializers.IntegerField() + ping = PingSerializer() + + +class SillySerializer(serializers.Serializer): + name = RecursiveField("rest_framework.fields.CharField", max_length=5) + blankable = RecursiveField("rest_framework.fields.CharField", allow_blank=True) + nullable = RecursiveField("rest_framework.fields.CharField", allow_null=True) + links = RecursiveField("LinkSerializer") + self = RecursiveField(required=False) + + +class RecursiveModel(models.Model): + name = models.CharField(max_length=255) + parent = models.ForeignKey("self", null=True, on_delete=models.CASCADE) + + +class RecursiveModelSerializer(serializers.ModelSerializer): + parent = RecursiveField(allow_null=True) + + class Meta: + model = RecursiveModel + fields = ("name", "parent") + + +class TestRecursiveField: + @staticmethod + def serialize(serializer_class, value): + def _recursively_populate_nullable_fields(serializer, instance_type): + """ + Recursively populate the nullable fields with None, this allows + us to test for newer versions of DRF. + """ + for key, val in instance_type().get_fields().items(): + # If the field is recursive, step further + if isinstance(val, RecursiveField): + if val.to is not None: + pass + elif serializer.get(key) is None: + # If the field is not set, explicitly set it to None + serializer[key] = None + elif isinstance(serializer[key], list): + serializer[key] = [ + _recursively_populate_nullable_fields(serializer_item, instance_type) + for serializer_item in serializer[key] + ] + else: + # Step further until we get to a leaf + serializer[key] = _recursively_populate_nullable_fields( + serializer[key], instance_type + ) + + return serializer + + serializer = serializer_class(value) + + updated_payload = _recursively_populate_nullable_fields(value, serializer_class) + assert serializer.data == updated_payload, "serialized data does not match input" + + @staticmethod + def deserialize(serializer_class, data): + serializer = serializer_class(data=data) + + assert serializer.is_valid(), "cannot validate on deserialization: %s" % dict( + serializer.errors + ) + assert serializer.validated_data == data, "deserialized data does not match input" + + def test_link_serializer(self): + value = { + "name": "first", + "next": { + "name": "second", + "next": { + "name": "third", + "next": None, + }, + }, + } + + self.serialize(LinkSerializer, value) + self.deserialize(LinkSerializer, value) + + def test_node_serializer(self): + value = { + "name": "root", + "children": [ + { + "name": "first child", + "children": [], + }, + { + "name": "second child", + "children": [], + }, + ], + } + + self.serialize(NodeSerializer, value) + self.deserialize(NodeSerializer, value) + + def test_many_null_serializer(self): + """Test that allow_null is propagated when many=True""" + + # Children is omitted from the root node + value = {"name": "root"} + + self.serialize(ManyNullSerializer, value) + self.deserialize(ManyNullSerializer, value) + + # Children is omitted from the child nodes + value2 = { + "name": "root", + "children": [ + {"name": "child1"}, + {"name": "child2"}, + ], + } + + self.serialize(ManyNullSerializer, value2) + self.deserialize(ManyNullSerializer, value2) + + def test_ping_pong(self): + pong = { + "pong_id": 4, + "ping": { + "ping_id": 3, + "pong": { + "pong_id": 2, + "ping": { + "ping_id": 1, + }, + }, + }, + } + self.serialize(PongSerializer, pong) + self.deserialize(PongSerializer, pong) + + def test_validation(self): + value = { + "name": "good", + "blankable": "", + "nullable": None, + "links": { + "name": "something", + "next": { + "name": "inner something", + "next": None, + }, + }, + } + self.serialize(SillySerializer, deepcopy(value)) + self.deserialize(SillySerializer, value) + + max_length = { + "name": "too long", + "blankable": "not blank", + "nullable": "not null", + "links": { + "name": "something", + "next": None, + }, + } + serializer = SillySerializer(data=max_length) + assert not serializer.is_valid(), "validation should fail due to name too long" + + nulled_out = { + "name": "good", + "blankable": None, + "nullable": "not null", + "links": { + "name": "something", + "next": None, + }, + } + serializer = SillySerializer(data=nulled_out) + assert not serializer.is_valid(), "validation should fail due to null field" + + way_too_long = { + "name": "good", + "blankable": "", + "nullable": None, + "links": { + "name": "something", + "next": { + "name": "inner something that is much too long", + "next": None, + }, + }, + } + serializer = SillySerializer(data=way_too_long) + assert not serializer.is_valid(), "validation should fail on inner link validation" + + def test_model_serializer(self): + one = RecursiveModel(name="one") + two = RecursiveModel(name="two", parent=one) + + # serialization + representation = { + "name": "two", + "parent": { + "name": "one", + "parent": None, + }, + } + + s = RecursiveModelSerializer(two) + assert s.data == representation + + # deserialization + self.deserialize(RecursiveModelSerializer, representation) + + def test_super_kwargs(self): + """RecursiveField.__init__ introspect the parent constructor to pass + kwargs properly. default is used used here to verify that the + argument is properly passed to the super Field.""" + field = RecursiveField(default="a default value") + assert field.default == "a default value" diff --git a/tests/test_recursive.py b/tests/test_recursive.py deleted file mode 100644 index ec7d530..0000000 --- a/tests/test_recursive.py +++ /dev/null @@ -1,223 +0,0 @@ -from django.db import models -from rest_framework import serializers -from rest_framework_recursive.fields import RecursiveField - - -class LinkSerializer(serializers.Serializer): - name = serializers.CharField(max_length=25) - next = RecursiveField(allow_null=True) - - -class NodeSerializer(serializers.Serializer): - name = serializers.CharField() - children = serializers.ListField(child=RecursiveField()) - - -class ManyNullSerializer(serializers.Serializer): - name = serializers.CharField() - children = RecursiveField(required=False, allow_null=True, many=True) - - -class PingSerializer(serializers.Serializer): - ping_id = serializers.IntegerField() - pong = RecursiveField('PongSerializer', required=False) - - -class PongSerializer(serializers.Serializer): - pong_id = serializers.IntegerField() - ping = PingSerializer() - - -class SillySerializer(serializers.Serializer): - name = RecursiveField( - 'rest_framework.fields.CharField', max_length=5) - blankable = RecursiveField( - 'rest_framework.fields.CharField', allow_blank=True) - nullable = RecursiveField( - 'rest_framework.fields.CharField', allow_null=True) - links = RecursiveField('LinkSerializer') - self = RecursiveField(required=False) - - -class RecursiveModel(models.Model): - name = models.CharField(max_length=255) - parent = models.ForeignKey('self', null=True, on_delete=models.CASCADE) - - -class RecursiveModelSerializer(serializers.ModelSerializer): - parent = RecursiveField(allow_null=True) - - class Meta: - model = RecursiveModel - fields = ('name', 'parent') - - -class TestRecursiveField: - @staticmethod - def serialize(serializer_class, value): - serializer = serializer_class(value) - - assert serializer.data == value, \ - 'serialized data does not match input' - - @staticmethod - def deserialize(serializer_class, data): - serializer = serializer_class(data=data) - - assert serializer.is_valid(), \ - 'cannot validate on deserialization: %s' % dict(serializer.errors) - assert serializer.validated_data == data, \ - 'deserialized data does not match input' - - def test_link_serializer(self): - value = { - 'name': 'first', - 'next': { - 'name': 'second', - 'next': { - 'name': 'third', - 'next': None, - } - } - } - - self.serialize(LinkSerializer, value) - self.deserialize(LinkSerializer, value) - - def test_node_serializer(self): - value = { - 'name': 'root', - 'children': [{ - 'name': 'first child', - 'children': [], - }, { - 'name': 'second child', - 'children': [], - }] - } - - self.serialize(NodeSerializer, value) - self.deserialize(NodeSerializer, value) - - def test_many_null_serializer(self): - """Test that allow_null is propagated when many=True""" - - # Children is omitted from the root node - value = { - 'name': 'root' - } - - self.serialize(ManyNullSerializer, value) - self.deserialize(ManyNullSerializer, value) - - # Children is omitted from the child nodes - value2 = { - 'name': 'root', - 'children':[ - {'name': 'child1'}, - {'name': 'child2'}, - ] - } - - self.serialize(ManyNullSerializer, value2) - self.deserialize(ManyNullSerializer, value2) - - def test_ping_pong(self): - pong = { - 'pong_id': 4, - 'ping': { - 'ping_id': 3, - 'pong': { - 'pong_id': 2, - 'ping': { - 'ping_id': 1, - }, - }, - }, - } - self.serialize(PongSerializer, pong) - self.deserialize(PongSerializer, pong) - - def test_validation(self): - value = { - 'name': 'good', - 'blankable': '', - 'nullable': None, - 'links': { - 'name': 'something', - 'next': { - 'name': 'inner something', - 'next': None, - } - } - } - self.serialize(SillySerializer, value) - self.deserialize(SillySerializer, value) - - max_length = { - 'name': 'too long', - 'blankable': 'not blank', - 'nullable': 'not null', - 'links': { - 'name': 'something', - 'next': None, - } - } - serializer = SillySerializer(data=max_length) - assert not serializer.is_valid(), \ - 'validation should fail due to name too long' - - nulled_out = { - 'name': 'good', - 'blankable': None, - 'nullable': 'not null', - 'links': { - 'name': 'something', - 'next': None, - } - } - serializer = SillySerializer(data=nulled_out) - assert not serializer.is_valid(), \ - 'validation should fail due to null field' - - way_too_long = { - 'name': 'good', - 'blankable': '', - 'nullable': None, - 'links': { - 'name': 'something', - 'next': { - 'name': 'inner something that is much too long', - 'next': None, - } - } - } - serializer = SillySerializer(data=way_too_long) - assert not serializer.is_valid(), \ - 'validation should fail on inner link validation' - - def test_model_serializer(self): - one = RecursiveModel(name='one') - two = RecursiveModel(name='two', parent=one) - - # serialization - representation = { - 'name': 'two', - 'parent': { - 'name': 'one', - 'parent': None, - } - } - - s = RecursiveModelSerializer(two) - assert s.data == representation - - # deserialization - self.deserialize(RecursiveModelSerializer, representation) - - def test_super_kwargs(self): - """RecursiveField.__init__ introspect the parent constructor to pass - kwargs properly. default is used used here to verify that the - argument is properly passed to the super Field.""" - field = RecursiveField(default='a default value') - assert field.default == 'a default value' diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 190f159..0000000 --- a/tox.ini +++ /dev/null @@ -1,18 +0,0 @@ -[tox] -envlist = - {py27,py34,py35,py36}-django{1.8,1.9,1.10}-drf{3.3,3.4,3.5,3.6} - -[testenv] -commands = ./runtests.py --fast -setenv = - PYTHONDONTWRITEBYTECODE=1 -deps = - django1.8: Django>=1.8,<1.9 - django1.9: Django>=1.9,<1.10 - django1.10: Django>=1.10,<1.11 - django1.11: Django>=1.11,<2.0 - drf3.3: djangorestframework>=3.3,<3.4 - drf3.4: djangorestframework>=3.4,<3.5 - drf3.5: djangorestframework>=3.5,<3.6 - drf3.6: djangorestframework>=3.6,<3.7 - pytest-django==3.1.2