diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 1c3f2ab..48d4d1a 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -15,13 +15,15 @@ jobs: uses: astral-sh/setup-uv@v6 with: python-version: "3.10" - - run: uv sync + - run: uv sync --extra docs - name: Lint with Ruff run: uv run ./lint - name: Test with pytest run: uv run ./test - name: Typecheck with ty run: uv run ./typecheck + - name: Build docs (MkDocs) + run: uv run ./docs-build - name: Check project version uses: maybe-hello-world/pyproject-check-version@v4 id: versioncheck diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 3f7b31d..caf3305 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -5,8 +5,12 @@ build: tools: python: "3.10" -sphinx: - configuration: docs/conf.py +mkdocs: + configuration: mkdocs.yml -formats: - - pdf +python: + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/README.md b/README.md index 972ebec..731c751 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,16 @@ ![status](https://img.shields.io/pypi/status/pydanja) ![uv-managed](https://img.shields.io/badge/uv-managed-blueviolet) +**Documentation:** [pydanja.readthedocs.io](https://pydanja.readthedocs.io/en/latest/) + # PyDANJA **PyDAN**tic **J**SON**A**PI -[JSON:API (or JSONAPI)](https://jsonapi.org/format/) Suport for [Pydantic](https://docs.pydantic.dev/latest/) +[JSON:API (or JSONAPI)](https://jsonapi.org/format/) support for [Pydantic](https://docs.pydantic.dev/latest/) -Output [JSONAPI](https://jsonapi.org/format/) from your [FastAPI](https://fastapi.tiangolo.com/) or [PyDantic](https://docs.pydantic.dev/latest/) based application with very little code. +Output [JSONAPI](https://jsonapi.org/format/) from your [FastAPI](https://fastapi.tiangolo.com/) or [Pydantic](https://docs.pydantic.dev/latest/) based application with very little code. This is a series of classes that can be included into your [Pydantic](https://docs.pydantic.dev/latest/) project that act as a container format for outputting and verifying [JSON:API](https://jsonapi.org/format/) compliant content. @@ -25,11 +27,44 @@ This library makes use of BaseModel generics to contain either a single resource This will support the oldest non-EOL Python (3.10 as of the writing of this document) +## Quickstart + +Import the core types and build JSON:API responses from your Pydantic models. + +```python +from pydantic import BaseModel, Field +from pydanja import DANJAResource, DANJAResourceList +``` + +## API Reference + +### Primary containers + +- `DANJAResource[T]` - single-resource JSON:API document (`data` is one resource) +- `DANJAResourceList[T]` - collection JSON:API document (`data` is a list of resources) +- `DANJASingleResource[T]` - internal resource object used in `data`/`included` +- `DANJAError` and `DANJAErrorList` - JSON:API error payloads +- `DANJALink`, `DANJARelationship`, `DANJAResourceIdentifier`, `DANJASource` - JSON:API support types + +### Helper methods + +- `DANJAResource.from_basemodel(resource, resource_name=None, resource_id=None)` + - wraps a `BaseModel` as JSON:API + - auto-resolves resource type and id field when not provided +- `DANJAResourceList.from_basemodel_list(resources, resource_name=None, resource_id=None)` + - wraps a list of `BaseModel` instances as JSON:API +- `include_from_basemodels(includes)` + - attaches related resources in `included` +- `resource` and `resources` properties + - return the original wrapped model(s) +- `danja_openapi(schema)` + - rewrites generated OpenAPI schema names to cleaner JSON:API model names + ## Usage With pydantic -``` +```python from pydanja import DANJAResource @@ -60,7 +95,7 @@ resource = resource_container.resource This basic example shows a [Pydantic](https://docs.pydantic.dev/latest/) BaseModel being contained within a `DANJAResource` object. The `model_dump_json` will output [JSON:API](https://jsonapi.org/format/): -``` +```json { "data": { "id": "1", @@ -83,9 +118,39 @@ This basic example shows a [Pydantic](https://docs.pydantic.dev/latest/) BaseMod Note that all [JSON:API](https://jsonapi.org/format/) fields are included in the output of the model dump. If you are using an API framework like [FastAPI](https://fastapi.tiangolo.com/), you use the `response_model_exclude_none` to suppress fields with no values. -### FastAPI example +### Pydantic v2 notes + +`DANJAResource` and `DANJAResourceList` use wrap validators internally. On current releases this is compatible with Pydantic v2 validation semantics (the validators return the validated model instance), so you should not see the warning: +`A custom validator is returning a value other than self` + +`included` resources are intentionally excluded from generic type validation so a response can include related resource types that differ from the top-level `data` resource type. + +### More examples + +Use an explicit resource type/id field (if auto detection is not desired): + +```python +resource = DANJAResource.from_basemodel( + my_model, + resource_name="articles", + resource_id="article_id", +) +``` + +Add `included` resources: + +```python +response = DANJAResource.from_basemodel(article) +response.include_from_basemodels([ + {"type": "people", "id": "1", "attributes": {"name": "Ada"}}, + {"type": "comments", "id": "99", "attributes": {"body": "Nice post"}}, +]) ``` + +### FastAPI example + +```python from typing import Optional, Union from pydantic import BaseModel, Field, ConfigDict from fastapi import FastAPI @@ -155,14 +220,7 @@ async def test_get() -> Union[DANJAResourceList[TestType], DANJAError]: return DANJAResourceList.from_basemodel_list(values) ``` -This library supports: - -* Single resources (`DANJAResource`) -* Lists of resources (`DANJAResourceList`) -* Error objects (`DANJAErrorList`/`DANJAError`) -* Link objects (`DANJALink`) - -There are more examples, including [FastAPI](https://fastapi.tiangolo.com/) code in the `src/examples` directory. +There are more runnable examples, including [FastAPI](https://fastapi.tiangolo.com/) usage, in `src/examples`. ### Contributing @@ -183,3 +241,9 @@ These can be run through `uv` by using: * `uv run ./test` * `uv run ./typecheck` * `uv run ./all` + +Documentation site (MkDocs Material) — install extras and preview locally: + +* `uv sync --extra docs` +* `uv run mkdocs serve` +* `uv run ./docs-build` (static output in `site/`) diff --git a/docs-build b/docs-build new file mode 100755 index 0000000..f82b4ad --- /dev/null +++ b/docs-build @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +uv sync --extra docs +mkdocs build --strict "$@" diff --git a/docs/mkdocs/api-reference.md b/docs/mkdocs/api-reference.md new file mode 100644 index 0000000..1fecde3 --- /dev/null +++ b/docs/mkdocs/api-reference.md @@ -0,0 +1,20 @@ +# API reference + +This page documents the public surface of the `pydanja` package (same symbols as `from pydanja import ...`). + +::: pydanja + options: + members: + - DANJALink + - DANJASource + - DANJAResourceIdentifier + - DANJARelationship + - DANJAError + - DANJAErrorList + - DANJASingleResource + - DANJAResource + - DANJAResourceList + - danja_openapi + show_category_heading: true + show_root_heading: true + heading_level: 2 diff --git a/docs/mkdocs/contributing.md b/docs/mkdocs/contributing.md new file mode 100644 index 0000000..6d10cfb --- /dev/null +++ b/docs/mkdocs/contributing.md @@ -0,0 +1,45 @@ +# Contributing + +The project uses [uv](https://github.com/astral-sh/uv) for dependencies and local workflows. + +## Checks + +From the repository root: + +```bash +uv sync +uv run ./lint +uv run ./test +uv run ./typecheck +``` + +Or run everything: + +```bash +uv run ./all +``` + +## Documentation site (this site) + +Install documentation dependencies and serve locally: + +```bash +uv sync --extra docs +uv run mkdocs serve +``` + +Build a static site (same command CI uses): + +```bash +uv run ./docs-build +``` + +The built output is written to `site/` (gitignored). + +## Hosting + +Published docs are intended for [Read the Docs](https://readthedocs.org/) using `mkdocs.yml` (see `.readthedocs.yaml`). Maintainers can alternatively deploy the `site/` output to GitHub Pages or another static host. + +## Legacy Sphinx files + +The `docs/conf.py` tree is an older Sphinx scaffold kept in-repo for reference; the canonical doc build for Read the Docs is MkDocs as configured in `mkdocs.yml`. diff --git a/docs/mkdocs/fastapi.md b/docs/mkdocs/fastapi.md new file mode 100644 index 0000000..f4534d2 --- /dev/null +++ b/docs/mkdocs/fastapi.md @@ -0,0 +1,15 @@ +# FastAPI + +PyDANJA types work well as FastAPI `response_model` / body types: annotate handlers with `DANJAResource[YourModel]` or `DANJAResourceList[YourModel]` so inbound JSON:API payloads are validated. + +## Response shaping + +Use FastAPI’s model exclusion helpers so empty JSON:API members do not clutter responses, for example `response_model_exclude_none=True` on routes. + +## OpenAPI schema cleanup + +Large generic models can make OpenAPI noisy. The library provides `danja_openapi` to simplify schema names for DANJA-related components after you build the base OpenAPI dict (see the project README and `src/examples/fastapi.py`). + +## Example code + +Runnable examples live under `src/examples/` in the repository — start with `fastapi.py` alongside `basic.py` for non-framework usage. diff --git a/docs/mkdocs/getting-started.md b/docs/mkdocs/getting-started.md new file mode 100644 index 0000000..82df6b1 --- /dev/null +++ b/docs/mkdocs/getting-started.md @@ -0,0 +1,46 @@ +# Getting started + +## Installation + +```bash +pip install pydanja +``` + +Requires **Python 3.10+** and **Pydantic v2** (see `pyproject.toml` on the repository for the declared minimum versions). + +## Minimal example + +Define a Pydantic model, mark which field should map to the JSON:API resource id (via `json_schema_extra`), then wrap it with `DANJAResource`: + +```python +from typing import Optional + +from pydantic import BaseModel, Field +from pydanja import DANJAResource + + +class Article(BaseModel): + article_id: Optional[int] = Field( + alias="id", + default=None, + json_schema_extra={"resource_id": True}, + ) + title: str + + +doc = DANJAResource.from_basemodel( + Article(id=1, title="Hello JSON:API"), +) +print(doc.model_dump_json(indent=2)) +``` + +You can read the wrapped model back via `doc.resource`. + +## Lists + +Use `DANJAResourceList.from_basemodel_list([...])` for collection documents. Access inner models with `.resources`. + +## Related reading + +- [API reference](api-reference.md) — all exported types and `danja_openapi` +- [FastAPI](fastapi.md) — response models and OpenAPI helpers diff --git a/docs/mkdocs/index.md b/docs/mkdocs/index.md new file mode 100644 index 0000000..be4b6e5 --- /dev/null +++ b/docs/mkdocs/index.md @@ -0,0 +1,14 @@ +# PyDANJA + +**PyDAN**tic **J**SON**A**PI — lightweight [JSON:API](https://jsonapi.org/format/)-shaped containers for [Pydantic](https://docs.pydantic.dev/latest/) models, with optional helpers for [FastAPI](https://fastapi.tiangolo.com/) and OpenAPI cleanup. + +Use this library when you want JSON:API-style request and response documents without committing your entire stack to a single framework-specific JSON:API server implementation. + +## Where to go next + +- [Getting started](getting-started.md) — install and your first resource document +- [API reference](api-reference.md) — public types and helpers +- [FastAPI](fastapi.md) — routing and OpenAPI integration patterns +- [Contributing](contributing.md) — build these docs locally + +The package README on GitHub and PyPI stays the quick overview; this site is the place for structured guides and API detail. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..c597902 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,62 @@ +# MkDocs Material — https://squidfunk.github.io/mkdocs-material/ +site_name: PyDANJA +site_description: JSON:API support for Pydantic +site_url: https://pydanja.readthedocs.io/ +docs_dir: docs/mkdocs + +repo_url: https://github.com/Centurix/pydanja +repo_name: Centurix/pydanja +edit_uri: edit/master/docs/mkdocs/ + +strict: true + +theme: + name: material + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.instant + - navigation.tracking + - content.code.copy + +plugins: + - search + - mkdocstrings: + handlers: + python: + paths: [src] + options: + docstring_style: google + members_order: source + show_root_heading: true + show_symbol_type_heading: true + show_signature_annotations: true + +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + +nav: + - Home: index.md + - Getting started: getting-started.md + - API reference: api-reference.md + - FastAPI: fastapi.md + - Contributing: contributing.md diff --git a/pyproject.toml b/pyproject.toml index 3c04771..3b847e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,12 @@ classifiers = [ "Topic :: Software Development :: Libraries" ] +[project.optional-dependencies] +docs = [ + "mkdocs-material>=9.5.0", + "mkdocstrings[python]>=0.26.0", +] + [project.urls] homepage = "https://github.com/Centurix/pydanja" repository = "https://github.com/Centurix/pydanja" diff --git a/src/pydanja/__init__.py b/src/pydanja/__init__.py index 84e381e..d900690 100644 --- a/src/pydanja/__init__.py +++ b/src/pydanja/__init__.py @@ -18,6 +18,30 @@ ] ResourceType = TypeVar("ResourceType") +ModelType = TypeVar("ModelType", bound=BaseModel) + + +def _validate_ignoring_included(data: Any, handler: ModelWrapValidatorHandler[ModelType]) -> ModelType: + """ + Validate a resource container while bypassing validation for `included`. + """ + data_copy = deepcopy(data) + included = None + + # dict payloads (e.g. DANJAResource(...)) + if isinstance(data_copy, dict): + included = data_copy.pop("included", None) + # model payloads (e.g. DANJAResource.model_validate(existing_model)) + elif hasattr(data_copy, "included"): + included = getattr(data_copy, "included") + delattr(data_copy, "included") + + validated = handler(data_copy) + + if included is not None: + setattr(validated, "included", included) + + return validated class DANJALink(BaseModel): @@ -105,7 +129,7 @@ def resolve_resource_name(cls, resource) -> str: @classmethod def resolve_resource_id(cls, resource) -> Optional[str]: - for field_name, field in resource.model_fields.items(): + for field_name, field in resource.__class__.model_fields.items(): if ( hasattr(field, "primary_key") and isinstance(field.primary_key, bool) and field.primary_key ): # Latest SQLMode @@ -170,7 +194,7 @@ def include_from_basemodels(self, includes: list[Any]) -> None: self.included = [] for include in includes: # Convert these to resource types - self.included.append(DANJASingleResource(**include)) # ty: ignore + self.included.append(DANJASingleResource(**include)) @model_validator(mode="wrap") @classmethod @@ -181,14 +205,7 @@ def ignore_included(cls, data: Any, handler: ModelWrapValidatorHandler[Self]) -> resource type as the top level data block. So in the meantime, we exclude `included` resources from the validation process. """ - data_copy = deepcopy(data) - - # Exclude included resource types - if hasattr(data_copy, "included"): - delattr(data_copy, "included") - - handler(data_copy) - return data + return _validate_ignoring_included(data, handler) class DANJAResourceList(BaseModel, ResourceResolver, Generic[ResourceType]): @@ -246,7 +263,7 @@ def include_from_basemodels(self, includes: list[Any]) -> None: self.included = [] for include in includes: # Convert these to resource types - self.included.append(DANJASingleResource(**include)) # ty: ignore + self.included.append(DANJASingleResource(**include)) @model_validator(mode="wrap") @classmethod @@ -257,11 +274,4 @@ def ignore_included(cls, data: Any, handler: ModelWrapValidatorHandler[Self]) -> resource type as the top level data block. So in the meantime, we exclude `included` resources from the validation process. """ - data_copy = deepcopy(data) - - # Exclude included resource types - if hasattr(data_copy, "included"): - delattr(data_copy, "included") - - handler(data_copy) - return data + return _validate_ignoring_included(data, handler) diff --git a/tests/test_resource_creation.py b/tests/test_resource_creation.py index 5b2d006..1e371b4 100644 --- a/tests/test_resource_creation.py +++ b/tests/test_resource_creation.py @@ -1,5 +1,6 @@ import pytest import json +import warnings from typing import Optional from pathlib import Path from src.pydanja import DANJAResource, DANJAResourceList @@ -137,3 +138,58 @@ def test_it_creates_a_container_list_from_base_model_without_id(resource): # Check resource data assert(len(new_resources.resources) == len(basemodel_instances)) + + +def test_it_does_not_warn_for_single_resource_with_included(): + payload = { + "data": { + "id": "1", + "type": "fixturetesttype", + "attributes": {"id": 1, "name": "Stuff!", "description": "This is desc!"}, + }, + "included": [ + { + "id": "200", + "type": "other", + "attributes": {"name": "Included model"}, + } + ], + } + + with warnings.catch_warnings(record=True) as warning_records: + warnings.simplefilter("always") + resource = DANJAResource[FixtureTestType](**payload) + + assert resource.included == payload["included"] + assert not any("Returning anything other than `self`" in str(w.message) for w in warning_records) + + +def test_it_does_not_warn_for_resource_list_with_included(): + payload = { + "data": [ + { + "id": "1", + "type": "fixturetesttype", + "attributes": {"id": 1, "name": "Stuff!", "description": "This is desc!"}, + }, + { + "id": "2", + "type": "fixturetesttype", + "attributes": {"id": 2, "name": "More Stuff!", "description": "This is more desc!"}, + }, + ], + "included": [ + { + "id": "200", + "type": "other", + "attributes": {"name": "Included model"}, + } + ], + } + + with warnings.catch_warnings(record=True) as warning_records: + warnings.simplefilter("always") + resource_list = DANJAResourceList[FixtureTestType](**payload) + + assert resource_list.included == payload["included"] + assert not any("Returning anything other than `self`" in str(w.message) for w in warning_records)