├── .python-version ├── src └── pydantic_rdf │ ├── py.typed │ ├── exceptions.py │ ├── __init__.py │ ├── types.py │ ├── annotation.py │ └── model.py ├── .cursor └── rules │ └── python │ ├── general.mdc │ ├── testing.mdc │ ├── documentation.mdc │ └── uv.mdc ├── tests ├── conftest.py ├── test_schema.py ├── test_serialize.py └── test_deserialize.py ├── .pre-commit-config.yaml ├── .github └── workflows │ ├── publish.yml │ ├── lint.yml │ ├── tests.yml │ └── docs.yml ├── CHANGELOG.md ├── LICENSE ├── docs ├── README.md ├── index.md └── quickstart.md ├── CLAUDE.md ├── pyproject.toml ├── README.md ├── examples ├── basic_usage.py ├── schema_generation.py ├── sparql_integration.py └── advanced_usage.py ├── mkdocs.yml └── .gitignore /.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /src/pydantic_rdf/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.cursor/rules/python/general.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: *.py 4 | alwaysApply: false 5 | --- 6 | You SHOULD assume that we are using Python 3.11 as a minimum for this project. 7 | You SHOULD try and make sure that the code you suggest utilizes Python 3.11 features where possible -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rdflib import Graph, Namespace 3 | 4 | 5 | @pytest.fixture 6 | def graph(EX: Namespace) -> Graph: 7 | g = Graph() 8 | g.bind("ex", EX) 9 | return g 10 | 11 | 12 | @pytest.fixture 13 | def EX() -> Namespace: 14 | return Namespace("http://example.com/") 15 | -------------------------------------------------------------------------------- /.cursor/rules/python/testing.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Guidance on how to define tests 3 | globs: tests/**/*.py 4 | alwaysApply: false 5 | --- 6 | 7 | You SHOULD ensure that each test follows the Single Responsibility Principle. 8 | You SHOULD implement any tests before modifying code anyhere else and then run the specific test. -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/uv-pre-commit 3 | # uv version. 4 | rev: 0.6.14 5 | hooks: 6 | - id: uv-lock 7 | - repo: https://github.com/astral-sh/ruff-pre-commit 8 | # Ruff version. 9 | rev: v0.11.5 10 | hooks: 11 | # Run the linter. 12 | - id: ruff 13 | args: [ --fix ] 14 | # Run the formatter. 15 | - id: ruff-format 16 | - repo: https://github.com/pre-commit/mirrors-mypy 17 | rev: "v1.15.0" 18 | hooks: 19 | - id: mypy 20 | files: ^(src)/ 21 | additional_dependencies: 22 | - pydantic -------------------------------------------------------------------------------- /src/pydantic_rdf/exceptions.py: -------------------------------------------------------------------------------- 1 | from rdflib import URIRef 2 | 3 | 4 | class CircularReferenceError(Exception): 5 | """Raised when a circular reference is detected during RDF parsing.""" 6 | 7 | def __init__(self, value: URIRef): 8 | message = f"Circular reference detected for {value}" 9 | super().__init__(message) 10 | 11 | 12 | class UnsupportedFieldTypeError(Exception): 13 | """Raised when an unsupported field type is encountered during RDF parsing.""" 14 | 15 | def __init__(self, field_type: object, field_name: str): 16 | message = f"Unsupported field type: {field_type} for field {field_name}" 17 | super().__init__(message) 18 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | environment: pypi 12 | permissions: 13 | id-token: write # Required for trusted publishing 14 | contents: read 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | - name: Install uv and set the python version 19 | uses: astral-sh/setup-uv@v5 20 | with: 21 | enable-cache: true 22 | python-version: "3.11" 23 | - name: Build the package 24 | run: uv build 25 | - name: Publish to PyPI 26 | run: uv publish 27 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Install uv and set the python version 16 | uses: astral-sh/setup-uv@v5 17 | with: 18 | enable-cache: true 19 | python-version: "3.11" 20 | - name: Install the project 21 | run: uv sync --locked --all-extras --dev 22 | - name: Lint with ruff 23 | run: uv run ruff check . 24 | - name: Format with ruff 25 | run: uv run ruff format --check . 26 | - name: Type check with mypy 27 | run: uv run mypy src -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: 17 | - "3.11" 18 | - "3.12" 19 | - "3.13" 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Install uv and set the python version 23 | uses: astral-sh/setup-uv@v5 24 | with: 25 | enable-cache: true 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install the project 28 | run: uv sync --locked --all-extras --dev 29 | - name: Run tests 30 | run: uv run pytest tests -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to the pydantic-rdf project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.2.0] - 2025-05-02 9 | 10 | ### Added 11 | - Support for JSON schema generation with proper handling of URIRef fields 12 | - New `PydanticURIRef` type using Pydantic's annotation system 13 | - Tests for schema generation and validation 14 | 15 | ### Changed 16 | - Updated `BaseRdfModel` to use `PydanticURIRef` instead of raw `URIRef` for better schema support 17 | - Improved type checking across the library 18 | 19 | ## [0.1.0] - Initial Release 20 | 21 | ### Added 22 | - Initial implementation of `BaseRdfModel` for RDF graph integration 23 | - Support for serializing Pydantic models to RDF graphs 24 | - Support for deserializing RDF graphs to Pydantic models 25 | - Field annotations (`WithPredicate`, `WithDataType`) for custom RDF mapping 26 | - Support for nested models and list fields 27 | - Comprehensive test suite -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Omegaice 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 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # PydanticRDF Documentation 2 | 3 | This directory contains the documentation for PydanticRDF, a library that bridges Pydantic V2 models and RDF graphs. 4 | 5 | ## Building the Documentation 6 | 7 | The documentation is written in Markdown and can be built using [MkDocs](https://www.mkdocs.org/) with the [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/) theme. 8 | 9 | To build the documentation: 10 | 11 | 1. Install the required packages: 12 | 13 | ```bash 14 | uv sync --group docs 15 | ``` 16 | 17 | 2. Build the documentation: 18 | 19 | ```bash 20 | mkdocs build 21 | ``` 22 | 23 | 3. Or serve the documentation locally: 24 | 25 | ```bash 26 | mkdocs serve 27 | ``` 28 | 29 | ## Documentation Structure 30 | 31 | - `index.md`: Overview and introduction 32 | - `quickstart.md`: Quick start guide 33 | 34 | ## Contributing to Documentation 35 | 36 | When contributing to the documentation, please follow these guidelines: 37 | 38 | 1. Write in clear, concise language 39 | 2. Use code examples to illustrate concepts 40 | 3. Keep the API reference up-to-date with the codebase 41 | 4. Add examples for new features -------------------------------------------------------------------------------- /src/pydantic_rdf/__init__.py: -------------------------------------------------------------------------------- 1 | """PydanticRDF - Bridge between Pydantic V2 models and RDF graphs. 2 | 3 | This library allows you to define data models with Pydantic's powerful validation 4 | while working with semantic web technologies using rdflib. It enables seamless 5 | serialization between Pydantic models and RDF graphs, and vice versa. 6 | 7 | Example: 8 | ```python 9 | from rdflib import Namespace 10 | from pydantic_rdf import BaseRdfModel, WithPredicate 11 | from typing import Annotated 12 | 13 | # Define a namespace 14 | EX = Namespace("http://example.org/") 15 | 16 | # Define your model 17 | class Person(BaseRdfModel): 18 | rdf_type = EX.Person # RDF type for this model 19 | _rdf_namespace = EX # Default namespace for properties 20 | 21 | name: str 22 | age: int 23 | email: Annotated[str, WithPredicate(EX.emailAddress)] # Custom predicate 24 | ``` 25 | """ 26 | 27 | from pydantic_rdf.annotation import WithDataType, WithPredicate 28 | from pydantic_rdf.model import BaseRdfModel 29 | 30 | __version__ = "0.2.0" 31 | __all__ = ["BaseRdfModel", "WithDataType", "WithPredicate"] 32 | -------------------------------------------------------------------------------- /.cursor/rules/python/documentation.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Guidance for Python code documentation 3 | globs: **/*.py 4 | alwaysApply: false 5 | --- 6 | 7 | You SHOULD follow the Google Docstring style. 8 | You SHOULD provide a concise single sentance summary. 9 | You SHOULD include a longer description where appropriate 10 | You SHOULD include a Raises section if appropriate 11 | You SHOULD include a Args section, only if the arguments's name is not descriptive enough to convey it's purpose. 12 | You SHOULD include a Parameters section, only if the parameter's name is not descriptive enough to convey it's purpose. 13 | You SHOULD include a Returns section, only if the function's name is not descriptive enough to convey what is returned. 14 | You SHOULD include a Warns section if appropriate 15 | 16 | When documenting public functionality: 17 | * You SHOULD provide a valid Example section where appropriate wrapped in markdown python codeblocks. 18 | * You SHOULD provide a valid Raises section where appropriate which takes into account private function calls. 19 | * You SHOULD provide a valid Warns section where appropriate which takes into account private function calls. -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | A Python library that bridges Pydantic version 2 and rdflib to support serializing and deserializing Pydantic models into RDF graphs. 8 | 9 | ## Build, Test, and Lint Commands 10 | 11 | - Install dependencies: `uv sync --all-groups` 12 | - Install dev dependencies: `uv sync --group dev` 13 | - Run all tests: `uv run pytest` 14 | - Run a single test: `uv run pytest tests/test_file.py::test_function` 15 | - Run with verbosity: `uv run pytest -v` 16 | - Lint code: `uv run ruff check --fix .` 17 | - Format code: `uv run ruff format .` 18 | - Type check: `uv run mypy src/` 19 | - Add dependencies: `uv add ` 20 | - Add dev dependencies: `uv add --dev` 21 | - Remove dependencies: `uv remove ` 22 | - Update dependencies: `uv sync --upgrade` 23 | 24 | ## Code Style Guidelines 25 | 26 | - Python 3.11+ required 27 | - Use strict type hints with mypy, with `Annotated` types for RDF predicates 28 | - Use ruff for linting and formatting with 120 character line length 29 | - Imports: sorted with isort, grouped by stdlib, third-party, local 30 | - Naming: snake_case for functions/variables, PascalCase for classes 31 | - Error handling: Use custom exception classes (CircularReferenceError, UnsupportedFieldTypeError) 32 | - Documentation: Add docstrings with Args, Returns, and Raises sections 33 | - Models: Inherit from BaseRdfModel with required rdf_type and _rdf_namespace class variables 34 | 35 | ## Context Management 36 | 37 | - Read all files in .cursor/rules to enhance your context. 38 | - Use context7 to retrieve documentation on library dependencies -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | # Sample workflow for building and deploying a Jekyll site to GitHub Pages 2 | name: Deploy MKDocs with GitHub Pages dependencies preinstalled 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["master"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | build_documentation: 26 | name: Build Documentation 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 0 33 | sparse-checkout: | 34 | docs 35 | src 36 | - name: Setup Pages 37 | uses: actions/configure-pages@v5 38 | - name: Install uv 39 | uses: astral-sh/setup-uv@v5 40 | with: 41 | python-version: 3.11 42 | enable-cache: true 43 | - name: Install the project 44 | run: uv sync --group docs 45 | - name: Build and upload documentation 46 | run: uv run mkdocs build -d _site 47 | - name: Upload artifact 48 | uses: actions/upload-pages-artifact@v3 49 | 50 | # Deployment job 51 | deploy: 52 | environment: 53 | name: github-pages 54 | url: ${{ steps.deployment.outputs.page_url }} 55 | runs-on: ubuntu-latest 56 | needs: build_documentation 57 | steps: 58 | - name: Deploy to GitHub Pages 59 | id: deployment 60 | uses: actions/deploy-pages@v4 61 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # PydanticRDF Documentation 2 | 3 | Welcome to the PydanticRDF documentation! This library bridges Pydantic V2 models and RDF graphs, allowing seamless serialization and deserialization between them. 4 | 5 | ## Table of Contents 6 | 7 | - [Quick Start](quickstart.md) 8 | - [API Reference](reference/pydantic_rdf/index.md) 9 | 10 | ## Overview 11 | 12 | PydanticRDF enables you to define your data models with Pydantic's powerful validation while working with semantic web technologies using rdflib. It makes it easy to: 13 | 14 | - Map Pydantic models to RDF types 15 | - Serialize model instances to RDF graphs 16 | - Deserialize RDF data into typed, validated Pydantic models 17 | - Handle nested models, circular references, and complex types 18 | 19 | The library is designed to be simple and intuitive, following Pydantic's design principles. 20 | 21 | ## Key Features 22 | 23 | - **Type Safety**: Full type checking and validation powered by Pydantic 24 | - **Simple API**: Intuitive interfaces for serialization and deserialization 25 | - **Seamless Integration**: Works with existing Pydantic and RDF workflows 26 | - **Flexible Mapping**: Custom predicate and datatype annotations 27 | - **Comprehensive Support**: Handles nested models, lists, and circular references 28 | - **JSON Schema**: Generate valid JSON schemas with proper URI field handling 29 | 30 | ## Example 31 | 32 | ```python 33 | from rdflib import SDO 34 | from pydantic_rdf import BaseRdfModel, WithPredicate 35 | from typing import Annotated 36 | 37 | # Define a model using Schema.org types 38 | class Person(BaseRdfModel): 39 | rdf_type = SDO.Person 40 | _rdf_namespace = SDO 41 | 42 | name: str 43 | email: str 44 | job_title: Annotated[str, WithPredicate(SDO.jobTitle)] 45 | 46 | # Create an instance 47 | person = Person( 48 | uri=SDO.Person_1, 49 | name="John Doe", 50 | email="john.doe@example.com", 51 | job_title="Software Engineer" 52 | ) 53 | 54 | # Serialize to RDF 55 | graph = person.model_dump_rdf() 56 | 57 | # Deserialize from RDF 58 | loaded_person = Person.parse_graph(graph, SDO.Person_1) 59 | ``` 60 | 61 | For more detailed examples and guides, check out the rest of the documentation! 62 | -------------------------------------------------------------------------------- /src/pydantic_rdf/types.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Any, NamedTuple, Protocol 2 | 3 | from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler 4 | from pydantic.json_schema import JsonSchemaValue 5 | from pydantic_core import CoreSchema, core_schema 6 | from rdflib import URIRef 7 | 8 | 9 | class IsPrefixNamespace(Protocol): 10 | def __getitem__(self, key: str) -> URIRef: ... 11 | 12 | 13 | class IsDefinedNamespace(Protocol): 14 | def __getitem__(self, name: str, default: Any = None) -> URIRef: ... 15 | 16 | 17 | class TypeInfo(NamedTuple): 18 | """Type information for a field: whether it is a list and its item type.""" 19 | 20 | is_list: bool 21 | item_type: object 22 | 23 | 24 | class URIRefHandler: 25 | """Handler for URIRef type to work with Pydantic schema generation.""" 26 | 27 | @classmethod 28 | def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: GetCoreSchemaHandler) -> CoreSchema: 29 | """Generate a core schema for URIRef. 30 | 31 | Returns a core schema that validates URIRefs and strings, 32 | converting strings to URIRefs automatically. 33 | """ 34 | 35 | def validate_from_str(value: str) -> URIRef: 36 | return URIRef(value) 37 | 38 | from_str_schema = core_schema.chain_schema([ 39 | core_schema.str_schema(), 40 | core_schema.no_info_plain_validator_function(validate_from_str), 41 | ]) 42 | 43 | return core_schema.json_or_python_schema( 44 | json_schema=from_str_schema, 45 | python_schema=core_schema.union_schema([ 46 | core_schema.is_instance_schema(URIRef), 47 | from_str_schema, 48 | ]), 49 | serialization=core_schema.plain_serializer_function_ser_schema(lambda instance: str(instance)), 50 | ) 51 | 52 | @classmethod 53 | def __get_pydantic_json_schema__(cls, _core_schema: CoreSchema, _handler: GetJsonSchemaHandler) -> JsonSchemaValue: 54 | """Generate a JSON schema for URIRef. 55 | 56 | Returns a JSON schema that represents URIRef as a string with URI format. 57 | """ 58 | return {"type": "string", "format": "uri"} 59 | 60 | 61 | # Create an annotated type for URIRef 62 | PydanticURIRef = Annotated[URIRef, URIRefHandler] 63 | -------------------------------------------------------------------------------- /src/pydantic_rdf/annotation.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from rdflib import URIRef 4 | 5 | 6 | @dataclass 7 | class WithPredicate: 8 | """Annotation to specify a custom RDF predicate for a field. 9 | 10 | This annotation allows you to define a specific RDF predicate to use when serializing 11 | a model field to RDF, instead of using the default predicate generated from the field name. 12 | 13 | Args: 14 | predicate: The RDF predicate URI to use for this field 15 | 16 | Example: 17 | ```python 18 | from typing import Annotated 19 | from pydantic_rdf import BaseRdfModel, WithPredicate 20 | from rdflib import Namespace 21 | 22 | EX = Namespace("http://example.org/") 23 | 24 | class Person(BaseRdfModel): 25 | # This will use the EX.emailAddress predicate instead of the default EX.email 26 | email: Annotated[str, WithPredicate(EX.emailAddress)] 27 | ``` 28 | """ 29 | 30 | predicate: URIRef 31 | 32 | @classmethod 33 | def extract(cls, field) -> URIRef | None: # type: ignore 34 | """Extract from field annotation if present.""" 35 | for meta in getattr(field, "metadata", []): 36 | if isinstance(meta, WithPredicate): 37 | return meta.predicate 38 | return None 39 | 40 | 41 | @dataclass 42 | class WithDataType: 43 | """Annotation to specify a custom RDF datatype for a field. 44 | 45 | This annotation allows you to define a specific RDF datatype to use when serializing 46 | a model field to RDF literals, instead of using the default datatype inference. 47 | 48 | Args: 49 | data_type: The RDF datatype URI to use for this field 50 | 51 | Example: 52 | ```python 53 | from typing import Annotated 54 | from pydantic_rdf import BaseRdfModel, WithDataType 55 | from rdflib.namespace import XSD 56 | 57 | class Product(BaseRdfModel): 58 | # This will use xsd:decimal datatype instead of the default 59 | price: Annotated[float, WithDataType(XSD.decimal)] 60 | ``` 61 | """ 62 | 63 | data_type: URIRef 64 | 65 | @classmethod 66 | def extract(cls, field) -> URIRef | None: # type: ignore 67 | """Extract from field annotation if present.""" 68 | for meta in getattr(field, "metadata", []): 69 | if isinstance(meta, WithDataType): 70 | return meta.data_type 71 | return None 72 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "pydantic-rdf" 7 | version = "0.2.0" 8 | dependencies = [ 9 | "pydantic>=2.11.3", 10 | "rdflib>=7.1.4", 11 | ] 12 | requires-python = ">=3.11" 13 | authors = [ 14 | { name = "Omegaice", email = "950526+Omegaice@users.noreply.github.com" } 15 | ] 16 | description = "Bridge between Pydantic V2 models and RDF graphs" 17 | readme = "README.md" 18 | license-files = ["LICEN[CS]E.*"] 19 | keywords = ["pydantic", "rdf", "semantic web", "linked data", "graph", "serialization"] 20 | classifiers = [ 21 | "Development Status :: 4 - Beta", 22 | "Intended Audience :: Developers", 23 | "License :: OSI Approved :: MIT License", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.11", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | "Topic :: Scientific/Engineering :: Information Analysis", 28 | "Topic :: Utilities", 29 | "Typing :: Typed" 30 | ] 31 | 32 | [project.urls] 33 | Documentation = "https://omegaice.github.io/pydantic-rdf" 34 | Repository = "https://github.com/Omegaice/pydantic-rdf" 35 | Changelog = "https://github.com/Omegaice/pydantic-rdf/blob/main/CHANGELOG.md" 36 | Issues = "https://github.com/Omegaice/pydantic-rdf/issues" 37 | 38 | 39 | [dependency-groups] 40 | dev = [ 41 | {include-group = "lint"}, 42 | {include-group = "docs"}, 43 | {include-group = "test"}, 44 | ] 45 | lint = [ 46 | "ruff>=0.11.6", 47 | "mypy>=1.15.0", 48 | "pre-commit>=4.2.0", 49 | ] 50 | docs = [ 51 | "mkdocs>=1.4.0", 52 | "mkdocs-material>=9.0.0", 53 | "mkdocs-api-autonav>=0.2.1", 54 | "mkdocs-section-index>=0.3.9", 55 | "mkdocs-git-revision-date-localized-plugin>=1.4.5", 56 | "mkdocstrings[python]>=0.29.1", 57 | "griffe-pydantic>=1.1.4", 58 | "griffe-warnings-deprecated>=1.1.0", 59 | "griffe-generics>=1.0.13", 60 | "griffe-modernized-annotations>=1.0.8", 61 | ] 62 | test = [ 63 | "pytest>=8.3.5", 64 | "pytest-icdiff>=0.9", 65 | "pytest-sugar>=1.0.0", 66 | ] 67 | 68 | [tool.ruff] 69 | target-version = "py311" 70 | line-length = 120 71 | 72 | [tool.ruff.lint] 73 | select = [ 74 | "E", # pycodestyle errors 75 | "F", # pyflakes 76 | "I", # isort 77 | "UP", # pyupgrade 78 | "B", # flake8-bugbear 79 | "C4", # flake8-comprehensions 80 | "SIM",# flake8-simplify 81 | "NPY", # numpy 82 | "PERF",# performance 83 | "RUF", # ruff-specific rules 84 | ] 85 | 86 | [tool.ruff.format] 87 | preview = true 88 | 89 | [tool.mypy] 90 | python_version = "3.11" 91 | strict = true 92 | ignore_missing_imports = true 93 | show_error_codes = true 94 | pretty = true 95 | plugins = [] 96 | exclude = "tests/.*" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # PydanticRDF 4 | 5 | [github](https://github.com/Omegaice/pydantic-rdf) 6 | [PyPI](https://pypi.org/project/pydantic-rdf) 7 | [Python](https://pypi.org/project/pydantic-rdf) 8 | [docs](https://omegaice.github.io/pydantic-rdf) 9 | [license](https://github.com/Omegaice/pydantic-rdf/blob/master/LICENSE) 10 | 11 |
12 | 13 | A Python library that bridges Pydantic V2 models and RDF graphs, enabling seamless bidirectional conversion between typed data models and semantic web data. PydanticRDF combines Pydantic's powerful validation with RDFLib's graph capabilities to simplify working with linked data. 14 | 15 | ## Features 16 | 17 | - ✅ **Type Safety**: Define data models with Pydantic V2 and map them to RDF graphs 18 | - 🔄 **Serialization**: Convert Pydantic models to RDF triples with customizable predicates 19 | - 📥 **Deserialization**: Parse RDF data into validated Pydantic models 20 | - 🧩 **Complex Structures**: Support for nested models, lists, and circular references 21 | - 📊 **JSON Schema**: Generate valid JSON schemas from RDF models with proper URI handling 22 | 23 | ## Installation 24 | 25 | ```bash 26 | pip install pydantic-rdf 27 | ``` 28 | 29 | ## Quick Example 30 | 31 | ```python 32 | from rdflib import Namespace 33 | from pydantic_rdf import BaseRdfModel, WithPredicate 34 | from typing import Annotated 35 | 36 | # Define a model 37 | EX = Namespace("http://example.org/") 38 | 39 | class Person(BaseRdfModel): 40 | rdf_type = EX.Person 41 | _rdf_namespace = EX 42 | 43 | name: str 44 | age: int 45 | email: Annotated[str, WithPredicate(EX.emailAddress)] 46 | 47 | # Create and serialize 48 | person = Person(uri=EX.person1, name="John Doe", age=30, email="john@example.com") 49 | graph = person.model_dump_rdf() 50 | 51 | # The resulting RDF graph looks like this: 52 | # @prefix ex: . 53 | # @prefix rdf: . 54 | # 55 | # ex:person1 a ex:Person ; 56 | # ex:age 30 ; 57 | # ex:emailAddress "john@example.com" ; 58 | # ex:name "John Doe" . 59 | 60 | # Deserialize 61 | loaded_person = Person.parse_graph(graph, EX.person1) 62 | ``` 63 | 64 | ## Requirements 65 | 66 | - Python 3.11+ 67 | - pydantic >= 2.11.3 68 | - rdflib >= 7.1.4 69 | 70 | ## Documentation 71 | 72 | For complete documentation, visit [https://omegaice.github.io/pydantic-rdf/](https://omegaice.github.io/pydantic-rdf/) 73 | 74 | ## License 75 | 76 | MIT -------------------------------------------------------------------------------- /examples/basic_usage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Basic usage example for PydanticRDF (pydantic-rdf) using Schema.org types. 4 | 5 | This example demonstrates: 6 | 1. Defining model classes with RDF mapping (Schema.org) 7 | 2. Creating and serializing instances to RDF 8 | 3. Deserializing RDF back to model instances 9 | """ 10 | 11 | from typing import Annotated 12 | 13 | from pydantic import Field 14 | from rdflib import SDO 15 | 16 | from pydantic_rdf import BaseRdfModel, WithPredicate 17 | 18 | 19 | # Define a simple model using Schema.org 20 | class Person(BaseRdfModel): 21 | """A person model with RDF mapping (Schema.org).""" 22 | 23 | rdf_type = SDO.Person # RDF type for this model 24 | _rdf_namespace = SDO # Default namespace for properties 25 | 26 | name: str 27 | email: Annotated[str, WithPredicate(SDO.email)] 28 | jobTitle: Annotated[str, WithPredicate(SDO.jobTitle)] 29 | 30 | 31 | # Define a nested model using Schema.org 32 | class PostalAddress(BaseRdfModel): 33 | """A postal address model with RDF mapping (Schema.org).""" 34 | 35 | rdf_type = SDO.PostalAddress 36 | _rdf_namespace = SDO 37 | 38 | streetAddress: str 39 | addressLocality: str 40 | addressCountry: str = "Unknown" # Field with default value 41 | 42 | 43 | class PersonWithAddress(BaseRdfModel): 44 | """A person model with a nested address (Schema.org).""" 45 | 46 | rdf_type = SDO.Person 47 | _rdf_namespace = SDO 48 | 49 | name: str 50 | address: Annotated[PostalAddress, WithPredicate(SDO.address)] 51 | # List of strings (Schema.org: knowsAbout) 52 | knowsAbout: list[str] = Field(default_factory=list) 53 | 54 | 55 | def main(): 56 | # Create a simple Person instance 57 | person = Person(uri=SDO.Person_1, name="John Doe", email="john.doe@example.com", jobTitle="Software Engineer") 58 | 59 | # Serialize to RDF graph 60 | graph = person.model_dump_rdf() 61 | 62 | # Print the graph as Turtle 63 | print("Person RDF Graph:") 64 | print(graph.serialize(format="turtle")) 65 | print() 66 | 67 | # Deserialize back from the graph 68 | loaded_person = Person.parse_graph(graph, SDO.Person_1) 69 | print(f"Loaded Person: {loaded_person.name}, {loaded_person.email}, {loaded_person.jobTitle}") 70 | print() 71 | 72 | # Create nested models 73 | address = PostalAddress( 74 | uri=SDO.PostalAddress_1, 75 | streetAddress="123 Main St", 76 | addressLocality="Springfield", 77 | ) 78 | 79 | person_with_address = PersonWithAddress( 80 | uri=SDO.Person_2, name="Jane Smith", address=address, knowsAbout=["RDF", "Pydantic", "Python"] 81 | ) 82 | 83 | # Serialize to RDF graph 84 | graph2 = person_with_address.model_dump_rdf() 85 | 86 | # Print the graph as Turtle 87 | print("Person with Address RDF Graph:") 88 | print(graph2.serialize(format="turtle")) 89 | print() 90 | 91 | # Deserialize back from the graph 92 | loaded_person2 = PersonWithAddress.parse_graph(graph2, SDO.Person_2) 93 | print(f"Loaded Person: {loaded_person2.name}") 94 | print(f"Address: {loaded_person2.address.streetAddress}, {loaded_person2.address.addressLocality}") 95 | print(f"Knows About: {', '.join(loaded_person2.knowsAbout)}") 96 | 97 | 98 | if __name__ == "__main__": 99 | main() 100 | -------------------------------------------------------------------------------- /.cursor/rules/python/uv.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: UV Dependency Management Guide for LLM Agents 3 | globs: 4 | alwaysApply: true 5 | --- 6 | 7 | ## Overview 8 | UV is a high-performance Python package manager written in Rust that enables modern dependency management through pyproject.toml. This guide provides instructions on how to assist users with UV for dependency management. 9 | 10 | ## Key Concepts 11 | - UV replaces pip, pip-tools, venv, and other traditional Python tools 12 | - UV automatically creates virtual environments when needed 13 | - UV uses pyproject.toml as the primary configuration file 14 | - UV generates lock files for reproducible builds 15 | 16 | ## Core Commands 17 | 18 | ### Project Setup 19 | ```bash 20 | # Create a new project with default structure 21 | uv init project-name 22 | ``` 23 | 24 | ### Dependency Management 25 | ```bash 26 | # Add dependencies 27 | uv add package_name 28 | uv add "package_name>=1.0.0" 29 | uv add package_name --dev 30 | uv add package_name --optional feature_name 31 | uv add package_name --group group_name 32 | uv add git+https://github.com/org/repo.git 33 | uv add git+https://github.com/org/repo.git --rev main 34 | uv add --editable ./local-package 35 | 36 | # Remove dependencies 37 | uv remove package_name 38 | uv remove package_name --dev 39 | uv remove package_name --optional feature_name 40 | uv remove package_name --group group_name 41 | 42 | # Synchronize environment 43 | uv sync 44 | uv sync --group dev 45 | uv sync --all-groups 46 | uv sync --check 47 | uv sync --upgrade 48 | uv sync --upgrade-package package_name 49 | ``` 50 | 51 | ### Lock File Operations 52 | ```bash 53 | # Generate lock file without installing 54 | uv lock 55 | 56 | # Add dependency without updating lock file 57 | uv add package_name --frozen 58 | 59 | # Sync without updating lock file 60 | uv sync --frozen 61 | 62 | # Ensure lock file stays unchanged 63 | uv add package_name --locked 64 | ``` 65 | 66 | ### Running Commands 67 | ```bash 68 | # Run command in project environment 69 | uv run python -m module_name 70 | uv run python script.py 71 | ``` 72 | 73 | ### Workspace Operations 74 | ```bash 75 | # Add dependency to specific package 76 | uv add package_name --package subpackage_name 77 | 78 | # Sync all packages 79 | uv sync --all-packages 80 | ``` 81 | 82 | ## Response Guidelines 83 | 84 | When assisting with UV dependency management: 85 | 86 | 1. Always prioritize `uv add` over editing pyproject.toml manually 87 | 2. Suggest `uv run` instead of activating environments manually 88 | 3. Recommend `uv sync --check` for CI/CD pipelines 89 | 4. Advise use of dependency groups for organizational clarity 90 | 5. Encourage use of lock files for reproducibility 91 | 6. Explain that `uv run` automatically creates virtual environments when needed 92 | 7. Focus on modern pyproject.toml structure over requirements.txt 93 | 8. Highlight speed benefits of UV over traditional Python tools 94 | 95 | ## Benefits to Emphasize 96 | - Speed: UV is significantly faster than pip for installations 97 | - Modern workflow: Integrates directly with pyproject.toml 98 | - Reproducibility: Automatic lock file generation 99 | - Simplicity: Unified interface for dependency management 100 | - Performance: Smart caching for optimized installations 101 | 102 | ## Common Scenarios 103 | 104 | **For beginners needing setup help:** 105 | ```bash 106 | uv init project_name 107 | cd project_name 108 | uv add flask 109 | uv run python -m project_name 110 | ``` 111 | 112 | **For projects needing development dependencies:** 113 | ```bash 114 | uv add pytest black mypy --dev 115 | uv sync --group dev 116 | ``` 117 | 118 | **For reproducible CI environments:** 119 | ```bash 120 | uv sync --frozen 121 | ``` 122 | 123 | **For upgrading dependencies:** 124 | ```bash 125 | uv sync --upgrade 126 | ``` -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: PydanticRDF 2 | site_description: Bridge between Pydantic V2 models and RDF graphs 3 | site_url: https://omegaice.github.io/pydantic-rdf 4 | 5 | repo_name: Omegaice/pydantic-rdf 6 | repo_url: https://github.com/Omegaice/pydantic-rdf 7 | 8 | nav: 9 | - Home: index.md 10 | - Quick Start: quickstart.md 11 | 12 | extra: 13 | social: 14 | - icon: fontawesome/brands/python 15 | link: https://pypi.org/project/pydantic-rdf/ 16 | 17 | theme: 18 | name: material 19 | icon: 20 | repo: fontawesome/brands/github 21 | palette: 22 | primary: indigo 23 | accent: indigo 24 | features: 25 | - announce.dismiss 26 | - content.action.edit 27 | - content.action.view 28 | - content.code.annotate 29 | - content.code.copy 30 | - content.tooltips 31 | - navigation.footer 32 | - navigation.instant 33 | - navigation.path 34 | - navigation.tracking 35 | - navigation.sections 36 | - navigation.tabs 37 | - navigation.tabs.sticky 38 | - navigation.top 39 | - search.highlight 40 | - search.suggest 41 | - toc.follow 42 | palette: 43 | - media: "(prefers-color-scheme)" 44 | toggle: 45 | icon: material/brightness-auto 46 | name: Switch to light mode 47 | - media: "(prefers-color-scheme: light)" 48 | scheme: default 49 | primary: teal 50 | accent: purple 51 | toggle: 52 | icon: material/weather-sunny 53 | name: Switch to dark mode 54 | - media: "(prefers-color-scheme: dark)" 55 | scheme: slate 56 | primary: black 57 | accent: lime 58 | toggle: 59 | icon: material/weather-night 60 | name: Switch to system preference 61 | 62 | markdown_extensions: 63 | - admonition 64 | - pymdownx.highlight 65 | - pymdownx.superfences 66 | - pymdownx.inlinehilite 67 | - pymdownx.tabbed 68 | - toc: 69 | permalink: true 70 | permalink: "¤" 71 | 72 | plugins: 73 | - search 74 | - autorefs 75 | - section-index 76 | - git-revision-date-localized: 77 | enabled: !ENV [DEPLOY, false] 78 | enable_creation_date: true 79 | type: timeago 80 | - api-autonav: 81 | modules: ['src/pydantic_rdf'] 82 | - mkdocstrings: 83 | default_handler: python 84 | handlers: 85 | python: 86 | paths: [src] 87 | inventories: 88 | - https://docs.python.org/3.11/objects.inv 89 | - https://docs.pydantic.dev/2.11/objects.inv 90 | - https://rdflib.readthedocs.io/en/7.1.4/objects.inv 91 | options: 92 | # General 93 | find_stubs_package: true 94 | show_bases: true 95 | show_source: false 96 | extensions: 97 | - griffe_generics 98 | - griffe_warnings_deprecated 99 | - griffe_modernized_annotations 100 | - griffe_pydantic: 101 | schema: false 102 | # Headings 103 | heading_level: 1 104 | show_root_heading: true 105 | show_root_full_path: false 106 | show_symbol_type_heading: true 107 | show_symbol_type_toc: true 108 | # Members 109 | summary: true 110 | inherited_members: true 111 | # Docstrings 112 | docstring_options: 113 | ignore_init_summary: true 114 | docstring_section_style: list 115 | merge_init_into_class: true 116 | # Signatures 117 | annotations_path: "brief" 118 | line_length: 120 119 | separate_signature: true 120 | show_signature_annotations: true 121 | show_overloads: true 122 | signature_crossrefs: true 123 | unwrap_annotated: false 124 | -------------------------------------------------------------------------------- /tests/test_schema.py: -------------------------------------------------------------------------------- 1 | from pydantic import TypeAdapter 2 | from rdflib import Graph, Namespace, URIRef 3 | 4 | from pydantic_rdf.model import BaseRdfModel 5 | from pydantic_rdf.types import PydanticURIRef 6 | 7 | 8 | def test_uriref_in_json_schema(EX: Namespace): 9 | """Test that URIRef fields are properly handled in JSON schema generation.""" 10 | 11 | # Define test model 12 | class SimpleModel(BaseRdfModel): 13 | """A simple RDF model for testing schema generation.""" 14 | 15 | rdf_type = EX.SimpleType 16 | _rdf_namespace = EX 17 | 18 | name: str 19 | description: str | None = None 20 | 21 | # Generate schema for the model 22 | schema = TypeAdapter(SimpleModel).json_schema() 23 | 24 | # Basic schema structure checks 25 | assert "properties" in schema 26 | assert "uri" in schema["properties"] 27 | 28 | # Verify URIRef is represented as a string with URI format 29 | uri_schema = schema["properties"]["uri"] 30 | assert uri_schema["type"] == "string" 31 | assert uri_schema["format"] == "uri" 32 | 33 | 34 | def test_direct_uriref_handling(): 35 | """Test direct handling of PydanticURIRef as a field type.""" 36 | # Create a direct reference to the URIRef 37 | uri = URIRef("http://example.com/resource/1") 38 | 39 | # Convert to and validate as PydanticURIRef 40 | type_adapter = TypeAdapter(PydanticURIRef) 41 | 42 | # Test string conversion 43 | converted = type_adapter.validate_python("http://example.com/resource/2") 44 | assert isinstance(converted, URIRef) 45 | assert str(converted) == "http://example.com/resource/2" 46 | 47 | # Test direct URIRef validation 48 | validated = type_adapter.validate_python(uri) 49 | assert isinstance(validated, URIRef) 50 | assert validated == uri 51 | 52 | 53 | def test_model_with_uriref_field_schema(graph: Graph, EX: Namespace): 54 | """Test that a model with URIRef fields can be properly serialized and deserialized.""" 55 | 56 | class ResourceModel(BaseRdfModel): 57 | """Model with URIRef field for testing.""" 58 | 59 | rdf_type = EX.Resource 60 | _rdf_namespace = EX 61 | 62 | name: str 63 | related_resource: PydanticURIRef | None = None 64 | 65 | # Create an instance with a URIRef 66 | related = URIRef("http://example.com/related") 67 | resource = ResourceModel(uri=EX.resource1, name="Test Resource", related_resource=related) 68 | 69 | # Add to graph 70 | graph += resource.model_dump_rdf() 71 | 72 | # Verify triples are in the graph using string comparison 73 | found_name = False 74 | found_related = False 75 | 76 | for s, p, o in graph: 77 | if str(s) == str(EX.resource1) and str(p) == str(EX.name) and str(o) == "Test Resource": 78 | found_name = True 79 | 80 | if str(s) == str(EX.resource1) and str(p) == str(EX.related_resource) and str(o) == str(related): 81 | found_related = True 82 | 83 | assert found_name, "Name triple not found" 84 | assert found_related, "Related resource triple not found" 85 | 86 | # Generate and verify schema 87 | schema = TypeAdapter(ResourceModel).json_schema() 88 | 89 | assert "related_resource" in schema["properties"] 90 | # Check that it's either a string or has anyOf with a string option 91 | related_schema = schema["properties"]["related_resource"] 92 | 93 | # Optional fields might use a different schema structure with anyOf 94 | if "type" in related_schema: 95 | assert related_schema["type"] == "string" 96 | assert related_schema.get("format") == "uri" 97 | elif "anyOf" in related_schema: 98 | # Find the string type option and check it 99 | string_options = [t for t in related_schema["anyOf"] if t.get("type") == "string"] 100 | assert string_options, "No string type option found in anyOf schema" 101 | assert any(t.get("format") == "uri" for t in string_options) 102 | -------------------------------------------------------------------------------- /examples/schema_generation.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | JSON Schema generation example for PydanticRDF (pydantic-rdf). 4 | 5 | This example demonstrates: 6 | 1. Generating JSON schemas from RDF models 7 | 2. How URIRef fields are properly represented in the schema 8 | 3. Working with schema validation 9 | """ 10 | 11 | import json 12 | from typing import Annotated 13 | 14 | from pydantic import Field, TypeAdapter, ValidationError 15 | from rdflib import Namespace, URIRef 16 | 17 | from pydantic_rdf import BaseRdfModel, WithPredicate 18 | from pydantic_rdf.types import PydanticURIRef 19 | 20 | # Define a namespace 21 | EX = Namespace("http://example.org/") 22 | 23 | 24 | # Define a simple RDF model 25 | class Person(BaseRdfModel): 26 | """A person model for demonstrating schema generation.""" 27 | 28 | rdf_type = EX.Person 29 | _rdf_namespace = EX 30 | 31 | name: str 32 | email: str 33 | age: int 34 | website: PydanticURIRef | None = None 35 | 36 | 37 | # Define a more complex model with nested structure 38 | class Address(BaseRdfModel): 39 | """An address model for demonstrating nested schema generation.""" 40 | 41 | rdf_type = EX.Address 42 | _rdf_namespace = EX 43 | 44 | street: str 45 | city: str 46 | postal_code: str 47 | country: str 48 | 49 | 50 | class ContactInfo(BaseRdfModel): 51 | """A contact info model with custom URIRef field.""" 52 | 53 | rdf_type = EX.ContactInfo 54 | _rdf_namespace = EX 55 | 56 | email: str 57 | phone: str 58 | homepage: PydanticURIRef | None = None 59 | 60 | 61 | class Organization(BaseRdfModel): 62 | """An organization model with nested models and URIRef fields.""" 63 | 64 | rdf_type = EX.Organization 65 | _rdf_namespace = EX 66 | 67 | name: str 68 | address: Annotated[Address, WithPredicate(EX.hasAddress)] 69 | contact: Annotated[ContactInfo, WithPredicate(EX.hasContact)] 70 | related_orgs: list[PydanticURIRef] = Field(default_factory=list) 71 | 72 | 73 | def main(): 74 | # Generate JSON schema for Person model 75 | person_schema = TypeAdapter(Person).json_schema() 76 | 77 | # Pretty print the schema 78 | print("Person JSON Schema:") 79 | print(json.dumps(person_schema, indent=2)) 80 | print() 81 | 82 | # Examine URI-related fields 83 | uri_field = person_schema["properties"]["uri"] 84 | print("URI Field Schema:") 85 | print(json.dumps(uri_field, indent=2)) 86 | print() 87 | 88 | website_field = person_schema["properties"]["website"] 89 | print("Website Field Schema (Optional URIRef):") 90 | print(json.dumps(website_field, indent=2)) 91 | print() 92 | 93 | # Generate schema for complex model with nested structures 94 | org_schema = TypeAdapter(Organization).json_schema() 95 | 96 | print("Organization JSON Schema (Summary):") 97 | print(f"Properties: {list(org_schema['properties'].keys())}") 98 | print(f"Required: {org_schema.get('required', [])}") 99 | print() 100 | 101 | # Demonstrate validation with the schema 102 | # Valid data 103 | valid_person_data = { 104 | "uri": "http://example.org/person/1", 105 | "name": "John Doe", 106 | "email": "john@example.com", 107 | "age": 30, 108 | "website": "https://johndoe.com", 109 | } 110 | 111 | try: 112 | person = Person.model_validate(valid_person_data) 113 | print(f"Valid person created: {person.name}, {person.email}") 114 | print(f"URIRef fields: uri={person.uri}, website={person.website}") 115 | # Check if the fields are properly converted to URIRef instances 116 | is_uri_uriref = isinstance(person.uri, URIRef) 117 | is_website_uriref = isinstance(person.website, URIRef) 118 | print(f"Are URIRef instances? uri: {is_uri_uriref}, website: {is_website_uriref}") 119 | print() 120 | except ValidationError as e: 121 | print(f"Validation error: {e}") 122 | 123 | # Invalid data (wrong types) 124 | invalid_person_data = { 125 | "uri": "http://example.org/person/2", 126 | "name": "Jane Smith", 127 | "email": "jane@example.com", 128 | "age": "thirty", # Should be an integer 129 | "website": 12345, # Should be a URI string 130 | } 131 | 132 | try: 133 | Person.model_validate(invalid_person_data) 134 | print("This should not happen - validation should fail") 135 | except ValidationError as e: 136 | print("Expected validation error:") 137 | print(e) 138 | 139 | 140 | if __name__ == "__main__": 141 | main() 142 | -------------------------------------------------------------------------------- /docs/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quick Start Guide 2 | 3 | This guide will get you started with PydanticRDF quickly, showing basic usage patterns. 4 | 5 | ## Define Your First Model 6 | 7 | To use PydanticRDF, you create classes that inherit from `BaseRdfModel` and define RDF mapping details: 8 | 9 | ```python 10 | from rdflib import SDO 11 | from pydantic_rdf import BaseRdfModel, WithPredicate 12 | from pydantic import Annotated 13 | 14 | # Define a model using Schema.org types 15 | class Person(BaseRdfModel): 16 | # RDF type for this model (maps to rdf:type) 17 | rdf_type = SDO.Person 18 | 19 | # Default namespace for properties 20 | _rdf_namespace = SDO 21 | 22 | # Model fields 23 | name: str 24 | email: str 25 | job_title: Annotated[str, WithPredicate(SDO.jobTitle)] # Custom predicate 26 | ``` 27 | 28 | ## Create and Serialize Instances 29 | 30 | Once you have defined your model, you can create instances and serialize them to RDF: 31 | 32 | ```python 33 | # Create an instance 34 | person = Person( 35 | uri=SDO.Person_1, # URI is a required field for all RDF models 36 | name="John Doe", 37 | email="john.doe@example.com", 38 | job_title="Software Engineer" 39 | ) 40 | 41 | # Serialize to RDF graph 42 | graph = person.model_dump_rdf() 43 | 44 | # Print the graph as Turtle format 45 | print(graph.serialize(format="turtle")) 46 | ``` 47 | 48 | The output will be an RDF graph with triples representing the model: 49 | 50 | ``` 51 | @prefix schema: . 52 | @prefix rdf: . 53 | 54 | schema:Person/1 a schema:Person ; 55 | schema:email "john.doe@example.com" ; 56 | schema:jobTitle "Software Engineer" ; 57 | schema:name "John Doe" . 58 | ``` 59 | 60 | ## Deserialize from RDF 61 | 62 | You can also deserialize RDF data back into model instances: 63 | 64 | ```python 65 | # Parse an instance from the graph 66 | loaded_person = Person.parse_graph(graph, SDO.Person_1) 67 | 68 | # Access attributes 69 | assert loaded_person.name == "John Doe" 70 | assert loaded_person.email == "john.doe@example.com" 71 | assert loaded_person.job_title == "Software Engineer" 72 | ``` 73 | 74 | ## Working with Nested Models 75 | 76 | PydanticRDF supports nested models and relationships: 77 | 78 | ```python 79 | class PostalAddress(BaseRdfModel): 80 | rdf_type = SDO.PostalAddress 81 | _rdf_namespace = SDO 82 | 83 | streetAddress: str 84 | addressLocality: str 85 | 86 | class PersonWithAddress(BaseRdfModel): 87 | rdf_type = SDO.Person 88 | _rdf_namespace = SDO 89 | 90 | name: str 91 | address: PostalAddress 92 | 93 | # Create nested models 94 | address = PostalAddress(uri=SDO.PostalAddress_1, streetAddress="123 Main St", addressLocality="Springfield") 95 | person = PersonWithAddress(uri=SDO.Person_2, name="John Doe", address=address) 96 | 97 | # Serialize to RDF 98 | graph = person.model_dump_rdf() 99 | ``` 100 | 101 | ## Working with Lists 102 | 103 | PydanticRDF supports lists of items: 104 | 105 | ```python 106 | class BlogPosting(BaseRdfModel): 107 | rdf_type = SDO.BlogPosting 108 | _rdf_namespace = SDO 109 | 110 | headline: str 111 | keywords: list[str] # Will create multiple triples with the same predicate 112 | 113 | # Create with a list 114 | post = BlogPosting( 115 | uri=SDO.BlogPosting_1, 116 | headline="PydanticRDF Introduction", 117 | keywords=["RDF", "Pydantic", "Python"] 118 | ) 119 | 120 | # Serialize to RDF 121 | graph = post.model_dump_rdf() 122 | ``` 123 | 124 | ## JSON Schema Generation 125 | 126 | PydanticRDF supports generating valid JSON schemas for your RDF models: 127 | 128 | ```python 129 | from pydantic import TypeAdapter 130 | 131 | # Define your model as before 132 | class Person(BaseRdfModel): 133 | rdf_type = SDO.Person 134 | _rdf_namespace = SDO 135 | 136 | name: str 137 | email: str 138 | 139 | # Generate JSON schema 140 | schema = TypeAdapter(Person).json_schema() 141 | 142 | # URIRef fields will be properly represented as strings with URI format 143 | # { 144 | # "properties": { 145 | # "uri": { 146 | # "type": "string", 147 | # "format": "uri", 148 | # "description": "The URI identifier for this RDF entity" 149 | # }, 150 | # "name": { 151 | # "type": "string" 152 | # }, 153 | # "email": { 154 | # "type": "string" 155 | # } 156 | # }, 157 | # "required": ["uri", "name", "email"], 158 | # ... 159 | # } 160 | ``` 161 | 162 | ## Next Steps 163 | 164 | Now that you have the basics, you can: 165 | 166 | - Explore the [API reference](reference/pydantic_rdf/index.md) for detailed documentation 167 | -------------------------------------------------------------------------------- /examples/sparql_integration.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | SPARQL integration example for PydanticRDF (pydantic-rdf). 4 | 5 | This example demonstrates: 6 | 1. Loading RDF data from an external source 7 | 2. Running SPARQL queries on the data 8 | 3. Converting query results to Pydantic models 9 | """ 10 | 11 | from typing import Annotated 12 | 13 | from pydantic import Field 14 | from rdflib import Graph, Literal, Namespace, URIRef 15 | from rdflib.namespace import FOAF, RDF 16 | from rdflib.query import ResultRow 17 | 18 | from pydantic_rdf import BaseRdfModel, WithPredicate 19 | 20 | # Define our namespaces 21 | EX = Namespace("http://example.org/") 22 | SCHEMA = Namespace("http://schema.org/") 23 | 24 | 25 | # Define our models 26 | class Person(BaseRdfModel): 27 | """A person model with RDF mapping.""" 28 | 29 | rdf_type = FOAF.Person 30 | _rdf_namespace = FOAF 31 | 32 | name: str 33 | knows: list["Person"] = Field(default_factory=list) 34 | age: int | None = None 35 | 36 | @classmethod 37 | def from_sparql_result(cls, graph: Graph, binding): 38 | """Create an instance from a SPARQL query result binding.""" 39 | person_uri = binding.get("person") 40 | if not person_uri or not isinstance(person_uri, URIRef): 41 | raise ValueError("Invalid person URI in SPARQL binding") 42 | 43 | # Use the standard parse_graph method to create the instance 44 | return cls.parse_graph(graph, person_uri) 45 | 46 | 47 | class Book(BaseRdfModel): 48 | """A book model with RDF mapping.""" 49 | 50 | rdf_type = SCHEMA.Book 51 | _rdf_namespace = SCHEMA 52 | 53 | title: str 54 | author: Annotated[Person, WithPredicate(SCHEMA.author)] 55 | year: int | None = None 56 | isbn: str | None = None 57 | 58 | 59 | def main(): 60 | # Create a sample RDF graph 61 | g = Graph() 62 | 63 | # Add some triples about people 64 | g.add((EX.person1, RDF.type, FOAF.Person)) 65 | g.add((EX.person1, FOAF.name, Literal("Alice Smith"))) 66 | g.add((EX.person1, FOAF.age, Literal(35))) 67 | 68 | g.add((EX.person2, RDF.type, FOAF.Person)) 69 | g.add((EX.person2, FOAF.name, Literal("Bob Jones"))) 70 | g.add((EX.person2, FOAF.age, Literal(42))) 71 | 72 | g.add((EX.person3, RDF.type, FOAF.Person)) 73 | g.add((EX.person3, FOAF.name, Literal("Charlie Miller"))) 74 | 75 | # Add relationships between people 76 | g.add((EX.person1, FOAF.knows, EX.person2)) 77 | g.add((EX.person2, FOAF.knows, EX.person3)) 78 | g.add((EX.person3, FOAF.knows, EX.person1)) 79 | 80 | # Add some books 81 | g.add((EX.book1, RDF.type, SCHEMA.Book)) 82 | g.add((EX.book1, SCHEMA.title, Literal("The RDF Guide"))) 83 | g.add((EX.book1, SCHEMA.author, EX.person1)) 84 | g.add((EX.book1, SCHEMA.datePublished, Literal(2023))) 85 | g.add((EX.book1, SCHEMA.isbn, Literal("978-3-16-148410-0"))) 86 | 87 | # Print the graph 88 | print("Sample RDF Graph:") 89 | print(g.serialize(format="turtle")) 90 | print() 91 | 92 | # Define a SPARQL query to find all people 93 | query = """ 94 | SELECT ?person WHERE { 95 | ?person a foaf:Person . 96 | } 97 | """ 98 | 99 | print("Running SPARQL query to find all people...") 100 | 101 | # Execute the query 102 | results = g.query(query) 103 | 104 | # Convert results to Person models 105 | people = [] 106 | for result in results: 107 | person = Person.from_sparql_result(g, result) 108 | people.append(person) 109 | 110 | # Print the results 111 | print(f"Found {len(people)} people:") 112 | for person in people: 113 | print(f" - {person.name} (URI: {person.uri})") 114 | print() 115 | 116 | # Find a specific book and its author with SPARQL 117 | book_query = """ 118 | SELECT ?book ?title ?author WHERE { 119 | ?book a schema:Book ; 120 | schema:title ?title ; 121 | schema:author ?author . 122 | } 123 | """ 124 | 125 | print("Running SPARQL query to find books with authors...") 126 | 127 | # Execute the query 128 | book_results = g.query(book_query) 129 | 130 | # Process the results 131 | for result in book_results: 132 | assert isinstance(result, ResultRow) 133 | 134 | book_uri = result.get("book") 135 | if book_uri is not None and isinstance(book_uri, URIRef): 136 | book = Book.parse_graph(g, book_uri) 137 | print(f"Book: {book.title}") 138 | print(f"Author: {book.author.name}") 139 | if book.year: 140 | print(f"Year: {book.year}") 141 | if book.isbn: 142 | print(f"ISBN: {book.isbn}") 143 | 144 | 145 | if __name__ == "__main__": 146 | main() 147 | -------------------------------------------------------------------------------- /examples/advanced_usage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Advanced usage example for PydanticRDF (pydantic-rdf). 4 | 5 | This example demonstrates: 6 | 1. Using validators and computed fields 7 | 2. Working with custom datatypes 8 | 3. Handling circular references 9 | 4. Inheritance in RDF models 10 | """ 11 | 12 | from datetime import datetime 13 | from typing import Annotated, Self 14 | 15 | from pydantic import Field, computed_field, field_validator, model_validator 16 | from rdflib import Namespace 17 | from rdflib.namespace import XSD 18 | 19 | from pydantic_rdf import BaseRdfModel, WithDataType 20 | 21 | # Define namespaces 22 | EX = Namespace("http://example.org/") 23 | FOAF = Namespace("http://xmlns.com/foaf/0.1/") 24 | 25 | 26 | # Base class for all content types 27 | class BaseContent(BaseRdfModel): 28 | """Base class for content items.""" 29 | 30 | rdf_type = EX.Content 31 | _rdf_namespace = EX 32 | 33 | title: str 34 | created_at: datetime 35 | author: str 36 | 37 | @field_validator("title") 38 | @classmethod 39 | def validate_title(cls, v: str) -> str: 40 | """Ensure title is not empty and has proper capitalization.""" 41 | if not v.strip(): 42 | raise ValueError("Title cannot be empty") 43 | return v.strip().title() 44 | 45 | @computed_field 46 | def age_days(self) -> int: 47 | """Calculate the age of the content in days.""" 48 | delta = datetime.now() - self.created_at 49 | return delta.days 50 | 51 | 52 | # Article subclass 53 | class Article(BaseContent): 54 | """A blog article.""" 55 | 56 | rdf_type = EX.Article 57 | 58 | content: str 59 | tags: list[str] = Field(default_factory=list) 60 | 61 | @model_validator(mode="after") 62 | def validate_content_length(self) -> Self: 63 | """Ensure content is substantial enough for an article.""" 64 | if len(self.content) < 50: 65 | raise ValueError("Article content must be at least 50 characters") 66 | return self 67 | 68 | 69 | # Video subclass 70 | class Video(BaseContent): 71 | """A video content item.""" 72 | 73 | rdf_type = EX.Video 74 | 75 | duration: Annotated[int, WithDataType(XSD.integer)] # Duration in seconds 76 | url: str 77 | 78 | 79 | # Example of circular references 80 | class Node(BaseRdfModel): 81 | """A node in a linked list, demonstrating circular references.""" 82 | 83 | rdf_type = EX.Node 84 | _rdf_namespace = EX 85 | 86 | name: str 87 | next: Self | None = None # Self-reference 88 | 89 | 90 | def main(): 91 | # Create an article 92 | article = Article( 93 | uri=EX.article1, 94 | title="understanding rdf and pydantic", # Will be capitalized 95 | content="This is an article about using PydanticRDF to bridge Pydantic and RDF graphs. " * 3, 96 | created_at=datetime(2025, 4, 20, 12, 0, 0), 97 | author="John Doe", 98 | tags=["RDF", "Pydantic", "Python", "Semantic Web"], 99 | ) 100 | 101 | # Serialize to RDF 102 | graph = article.model_dump_rdf() 103 | 104 | print("Article RDF Graph:") 105 | print(graph.serialize(format="turtle")) 106 | print() 107 | 108 | # Load the article from RDF 109 | loaded_article = Article.parse_graph(graph, EX.article1) 110 | print(f"Loaded Article: {loaded_article.title}") 111 | print(f"Age in days: {loaded_article.age_days}") 112 | print(f"Tags: {', '.join(loaded_article.tags)}") 113 | print() 114 | 115 | # Create linked nodes demonstrating circular references 116 | node3 = Node(uri=EX.node3, name="Node 3", next=None) 117 | node2 = Node(uri=EX.node2, name="Node 2", next=node3) 118 | node1 = Node(uri=EX.node1, name="Node 1", next=node2) 119 | # Create a cycle 120 | node3.next = node1 121 | 122 | # Serialize to RDF 123 | graph2 = node1.model_dump_rdf() 124 | # Add the other nodes to the same graph 125 | graph2 += node2.model_dump_rdf() 126 | graph2 += node3.model_dump_rdf() 127 | 128 | print("Nodes RDF Graph:") 129 | print(graph2.serialize(format="turtle")) 130 | print() 131 | 132 | # Load the nodes from RDF 133 | loaded_node1 = Node.parse_graph(graph2, EX.node1) 134 | # Access the entire circular structure 135 | loaded_node2 = loaded_node1.next 136 | loaded_node3 = loaded_node2.next if loaded_node2 else None 137 | 138 | # Print the circular reference 139 | print("Circular Reference:") 140 | print(f"Node 1 -> {loaded_node1.name}") 141 | if loaded_node2: 142 | print(f"Node 2 -> {loaded_node2.name}") 143 | if loaded_node3: 144 | print(f"Node 3 -> {loaded_node3.name}") 145 | if loaded_node3.next: 146 | print(f"Node 3 -> Node 1? {loaded_node3.next.uri == EX.node1}") 147 | 148 | 149 | if __name__ == "__main__": 150 | main() 151 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python,visualstudiocode 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,visualstudiocode 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # poetry 102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 | #poetry.lock 107 | 108 | # pdm 109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 | #pdm.lock 111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 | # in version control. 113 | # https://pdm.fming.dev/#use-with-ide 114 | .pdm.toml 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # PyCharm 160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 | # and can be added to the global gitignore or merged into this file. For a more nuclear 163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 | #.idea/ 165 | 166 | ### Python Patch ### 167 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 168 | poetry.toml 169 | 170 | # ruff 171 | .ruff_cache/ 172 | 173 | # LSP config files 174 | pyrightconfig.json 175 | 176 | ### VisualStudioCode ### 177 | .vscode/* 178 | !.vscode/settings.json 179 | !.vscode/tasks.json 180 | !.vscode/launch.json 181 | !.vscode/extensions.json 182 | !.vscode/*.code-snippets 183 | 184 | # Local History for Visual Studio Code 185 | .history/ 186 | 187 | # Built Visual Studio Code Extensions 188 | *.vsix 189 | 190 | ### VisualStudioCode Patch ### 191 | # Ignore all local history of files 192 | .history 193 | .ionide 194 | 195 | # End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode 196 | 197 | ### NixOS ### 198 | result 199 | 200 | # Development environments 201 | .direnv 202 | 203 | **/.claude/settings.local.json 204 | -------------------------------------------------------------------------------- /src/pydantic_rdf/model.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from collections.abc import MutableMapping, Sequence 4 | from typing import ( 5 | Annotated, 6 | Any, 7 | ClassVar, 8 | Final, 9 | Self, 10 | TypeAlias, 11 | TypeVar, 12 | Union, 13 | cast, 14 | get_args, 15 | get_origin, 16 | ) 17 | 18 | from pydantic import BaseModel, ConfigDict, Field 19 | from pydantic.fields import FieldInfo 20 | from rdflib import RDF, Graph, Literal, URIRef 21 | 22 | from pydantic_rdf.annotation import WithPredicate 23 | from pydantic_rdf.exceptions import CircularReferenceError, UnsupportedFieldTypeError 24 | from pydantic_rdf.types import IsDefinedNamespace, IsPrefixNamespace, PydanticURIRef, TypeInfo 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | T = TypeVar("T", bound="BaseRdfModel") 30 | M = TypeVar("M", bound="BaseRdfModel") # For cls parameter annotations 31 | 32 | # Sentinel object to detect circular references during parsing 33 | _IN_PROGRESS: Final = object() 34 | 35 | 36 | CacheKey: TypeAlias = tuple[type["BaseRdfModel"], URIRef] 37 | RDFEntityCache: TypeAlias = MutableMapping[CacheKey, object] 38 | 39 | 40 | class BaseRdfModel(BaseModel): 41 | """Base class for RDF-mappable Pydantic models.""" 42 | 43 | model_config = ConfigDict(arbitrary_types_allowed=True) 44 | 45 | # Class variables for RDF mapping 46 | rdf_type: ClassVar[PydanticURIRef] 47 | _rdf_namespace: ClassVar[IsPrefixNamespace | IsDefinedNamespace] 48 | 49 | uri: PydanticURIRef = Field(description="The URI identifier for this RDF entity") 50 | 51 | # TYPE ANALYSIS HELPERS 52 | @classmethod 53 | def _get_field_predicate(cls: type[M], field_name: str, field: FieldInfo) -> URIRef: 54 | """Return the RDF predicate URI for a given field name and FieldInfo. 55 | 56 | Returns: 57 | The RDF predicate URIRef for the field. 58 | """ 59 | if predicate := WithPredicate.extract(field): 60 | return predicate 61 | return cls._rdf_namespace[field_name] 62 | 63 | @staticmethod 64 | def _get_annotated_type(annotation: Any) -> Any | None: 65 | """Return the type wrapped by Annotated, or None if not Annotated. 66 | 67 | Args: 68 | annotation: The type annotation to inspect. 69 | 70 | Returns: 71 | The type wrapped by Annotated, or None if not Annotated. 72 | """ 73 | if get_origin(annotation) is Annotated: 74 | return get_args(annotation)[0] 75 | return None 76 | 77 | @staticmethod 78 | def _get_union_type(annotation: Any) -> Any | None: 79 | """Return the first non-None type in a Union/Optional annotation, or None. 80 | 81 | Args: 82 | annotation: The type annotation to inspect. 83 | 84 | Returns: 85 | The first non-None type in the Union, or None if not a Union. 86 | """ 87 | if get_origin(annotation) is Union: 88 | # Return the first non-None type (for Optional/Union) 89 | return next((arg for arg in get_args(annotation) if arg is not type(None)), None) 90 | return None 91 | 92 | @staticmethod 93 | def _get_sequence_type(annotation: Any) -> Any | None: 94 | """Return the item type if annotation is a Sequence (not str), else None. 95 | 96 | Args: 97 | annotation: The type annotation to inspect. 98 | 99 | Returns: 100 | The item type if annotation is a Sequence (not str), else None. 101 | """ 102 | origin = get_origin(annotation) 103 | if isinstance(origin, type) and issubclass(origin, Sequence) and not issubclass(origin, str): 104 | return get_args(annotation)[0] 105 | return None 106 | 107 | @classmethod 108 | def _get_item_type(cls, annotation: Any) -> Any: 109 | """Recursively unwraps annotation to return the underlying item type. 110 | 111 | Args: 112 | annotation: The type annotation to unwrap. 113 | 114 | Returns: 115 | The underlying item type after unwrapping Annotated, Sequence, and Union. 116 | """ 117 | for extractor in (cls._get_annotated_type, cls._get_sequence_type, cls._get_union_type): 118 | if (item_type := extractor(annotation)) is not None: 119 | return cls._get_item_type(item_type) 120 | return annotation 121 | 122 | @classmethod 123 | def _resolve_type_info(cls, annotation: Any) -> TypeInfo: 124 | """Return TypeInfo indicating if annotation is a list and its item type. 125 | 126 | Args: 127 | annotation: The type annotation to analyze. 128 | 129 | Returns: 130 | TypeInfo indicating whether the annotation is a list and its item type. 131 | """ 132 | if (item_type := cls._get_sequence_type(annotation)) is not None: 133 | return TypeInfo(is_list=True, item_type=item_type) 134 | if (item_type := cls._get_annotated_type(annotation)) is not None: 135 | return cls._resolve_type_info(item_type) 136 | if (item_type := cls._get_union_type(annotation)) is not None: 137 | return TypeInfo(is_list=False, item_type=cls._get_item_type(item_type)) 138 | return TypeInfo(is_list=False, item_type=annotation) 139 | 140 | # FIELD EXTRACTION AND CONVERSION 141 | @classmethod 142 | def _extract_model_type(cls, type_annotation: Any) -> type["BaseRdfModel"] | None: 143 | """Return the BaseRdfModel subclass from a type annotation, or None. 144 | 145 | Args: 146 | type_annotation: The type annotation to inspect. 147 | 148 | Returns: 149 | The BaseRdfModel subclass if present, else None. 150 | """ 151 | # Self reference 152 | if type_annotation is Self: 153 | return cls 154 | 155 | # Direct BaseRdfModel type 156 | if get_origin(type_annotation) is None: 157 | if ( 158 | isinstance(type_annotation, type) 159 | and issubclass(type_annotation, BaseRdfModel) 160 | and type_annotation is not BaseRdfModel 161 | ): 162 | return type_annotation 163 | return None 164 | 165 | # Union/Optional types 166 | if (item_type := cls._get_union_type(type_annotation)) is not None: 167 | return cls._extract_model_type(item_type) 168 | 169 | return None 170 | 171 | @classmethod 172 | def _convert_rdf_value( 173 | cls: type[M], 174 | graph: Graph, 175 | value: Any, 176 | type_annotation: Any, 177 | cache: RDFEntityCache, 178 | ) -> Any: 179 | """Convert an RDF value to a Python value or nested BaseRdfModel instance. 180 | 181 | Returns: 182 | The converted Python value or BaseRdfModel instance. 183 | 184 | Raises: 185 | CircularReferenceError: If a circular reference is detected during parsing. 186 | """ 187 | # Check if this is a nested BaseRdfModel 188 | if (model_type := cls._extract_model_type(type_annotation)) and isinstance(value, URIRef): 189 | # Handle nested BaseRdfModel instances with caching to prevent recursion 190 | if cached := cache.get((model_type, value)): 191 | # Check for circular references 192 | if cached is _IN_PROGRESS: 193 | raise CircularReferenceError(value) 194 | return cached 195 | return model_type.parse_graph(graph, value, _cache=cache) 196 | 197 | # Convert literals to Python values 198 | if isinstance(value, Literal): 199 | python_value = value.toPython() 200 | # Handle JSON strings for dictionary fields 201 | origin = get_origin(type_annotation) 202 | if origin is dict and isinstance(python_value, str): 203 | try: 204 | return json.loads(python_value) 205 | except json.JSONDecodeError: 206 | pass # If not valid JSON, return as is 207 | return python_value 208 | 209 | return value 210 | 211 | @classmethod 212 | def _extract_field_value( 213 | cls: type[M], 214 | graph: Graph, 215 | uri: URIRef, 216 | field_name: str, 217 | field: FieldInfo, 218 | cache: RDFEntityCache, 219 | ) -> Any | None: 220 | """Extract and convert the value(s) for a field from the RDF graph. 221 | 222 | Returns: 223 | The extracted and converted value(s) for the field, or None if not present. 224 | 225 | Raises: 226 | UnsupportedFieldTypeError: If the field type is not supported for RDF parsing. 227 | """ 228 | # Get all values for this predicate 229 | predicate = cls._get_field_predicate(field_name, field) 230 | values = list(graph.objects(uri, predicate)) 231 | if not values: 232 | return None 233 | 234 | # Check if this is a list type 235 | type_info = cls._resolve_type_info(field.annotation) 236 | 237 | # Check for unsupported types 238 | if type_info.item_type is complex: 239 | raise UnsupportedFieldTypeError(type_info.item_type, field_name) 240 | 241 | # Process the values based on their type 242 | if type_info.is_list: 243 | return [cls._convert_rdf_value(graph, v, type_info.item_type, cache) for v in values] 244 | 245 | return cls._convert_rdf_value(graph, values[0], type_info.item_type, cache) 246 | 247 | # RDF PARSING 248 | @classmethod 249 | def parse_graph(cls: type[T], graph: Graph, uri: URIRef, _cache: RDFEntityCache | None = None) -> T: 250 | """Parse an RDF entity from the graph into a model instance. 251 | 252 | Uses a cache to prevent recursion and circular references. 253 | 254 | Args: 255 | _cache: Optional cache for already-parsed entities. 256 | 257 | Returns: 258 | An instance of the model corresponding to the RDF entity. 259 | 260 | Raises: 261 | CircularReferenceError: If a circular reference is detected during parsing. 262 | ValueError: If the URI does not have the expected RDF type. 263 | UnsupportedFieldTypeError: If a field type is not supported for RDF parsing. 264 | 265 | Example: 266 | ```python 267 | model = MyModel.parse_graph(graph, EX.some_uri) 268 | ``` 269 | """ 270 | # Initialize cache if not provided 271 | cache: RDFEntityCache = {} if _cache is None else _cache 272 | 273 | # Return from cache if already constructed 274 | if cached := cache.get((cls, uri)): 275 | if cached is _IN_PROGRESS: 276 | raise CircularReferenceError(uri) 277 | return cast(T, cached) 278 | 279 | # Mark entry in cache as being built 280 | cache[(cls, uri)] = _IN_PROGRESS 281 | 282 | # Verify the entity has the correct RDF type 283 | if (uri, RDF.type, cls.rdf_type) not in graph: 284 | raise ValueError(f"URI {uri} does not have type {cls.rdf_type}") 285 | 286 | # Collect field data from the graph 287 | data: dict[str, Any] = {} 288 | for field_name, field in cls.model_fields.items(): 289 | if field_name in BaseRdfModel.model_fields: 290 | continue 291 | value = cls._extract_field_value(graph, uri, field_name, field, cache) 292 | if value is not None: 293 | data[field_name] = value 294 | 295 | # Construct the instance with validation 296 | instance = cls.model_validate({"uri": uri, **data}) 297 | 298 | # Update cache with the constructed instance 299 | cache[(cls, uri)] = instance 300 | 301 | return instance 302 | 303 | @classmethod 304 | def all_entities(cls: type[T], graph: Graph) -> list[T]: 305 | """Return all entities of this model's RDF type from the graph. 306 | 307 | Returns: 308 | A list of model instances for each entity of this RDF type in the graph. 309 | 310 | Raises: 311 | CircularReferenceError: If a circular reference is detected during parsing. 312 | ValueError: If any entity URI does not have the expected RDF type. 313 | UnsupportedFieldTypeError: If a field type is not supported for RDF parsing. 314 | 315 | Example: 316 | ```python 317 | entities = MyModel.all_entities(graph) 318 | ``` 319 | """ 320 | return [ 321 | cls.parse_graph(graph, uri) for uri in graph.subjects(RDF.type, cls.rdf_type) if isinstance(uri, URIRef) 322 | ] 323 | 324 | # SERIALIZATION 325 | def model_dump_rdf(self: Self) -> Graph: 326 | """Serialize this model instance to an RDF graph. 327 | 328 | Returns: 329 | An RDFLib Graph representing this model instance. 330 | 331 | Example: 332 | ```python 333 | graph = instance.model_dump_rdf() 334 | ``` 335 | """ 336 | graph = Graph() 337 | graph.add((self.uri, RDF.type, self.rdf_type)) 338 | 339 | dumped = self.model_dump() 340 | 341 | for field_name, field in type(self).model_fields.items(): 342 | if field_name == "uri": 343 | continue 344 | 345 | type_info = type(self)._resolve_type_info(field.annotation) 346 | 347 | # Use attribute value for BaseRdfModel fields, else use dumped value 348 | if ( 349 | type_info.is_list 350 | and isinstance(type_info.item_type, type) 351 | and issubclass(type_info.item_type, BaseRdfModel) 352 | ) or (isinstance(type_info.item_type, type) and issubclass(type_info.item_type, BaseRdfModel)): 353 | value = getattr(self, field_name, None) 354 | else: 355 | value = dumped.get(field_name, None) 356 | 357 | if value is None: 358 | continue 359 | 360 | predicate = self._get_field_predicate(field_name, field) 361 | 362 | # Handle list fields 363 | if type_info.is_list and isinstance(value, list): 364 | if isinstance(type_info.item_type, type) and issubclass(type_info.item_type, BaseRdfModel): 365 | for item in value: 366 | if isinstance(item, BaseRdfModel): 367 | graph.add((self.uri, predicate, item.uri)) 368 | graph += item.model_dump_rdf() 369 | continue 370 | else: 371 | # List of simple types 372 | for item in value: 373 | graph.add((self.uri, predicate, Literal(item))) 374 | continue 375 | 376 | # Handle single BaseRdfModel 377 | if isinstance(type_info.item_type, type) and issubclass(type_info.item_type, BaseRdfModel): 378 | if isinstance(value, BaseRdfModel): 379 | graph.add((self.uri, predicate, value.uri)) 380 | graph += value.model_dump_rdf() 381 | else: 382 | # Special handling for dict fields: serialize as JSON string 383 | origin = get_origin(type_info.item_type) 384 | if origin is dict and isinstance(value, dict): 385 | graph.add((self.uri, predicate, Literal(json.dumps(value)))) 386 | else: 387 | graph.add((self.uri, predicate, Literal(value))) 388 | 389 | return graph 390 | -------------------------------------------------------------------------------- /tests/test_serialize.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | from typing import Annotated 3 | 4 | import pytest 5 | from pydantic import ConfigDict, field_serializer 6 | from rdflib import XSD, Graph, Literal, Namespace 7 | 8 | from pydantic_rdf.annotation import WithPredicate 9 | from pydantic_rdf.model import BaseRdfModel 10 | 11 | 12 | def test_basic_string_field_serialization(graph: Graph, EX: Namespace): 13 | """Test that a simple string field is serialized as an RDF triple with the correct predicate and value.""" 14 | 15 | class MyType(BaseRdfModel): 16 | rdf_type = EX.MyType 17 | _rdf_namespace = EX 18 | 19 | name: str 20 | 21 | obj = MyType(uri=EX.entity1, name="TestName") 22 | graph += obj.model_dump_rdf() 23 | assert (EX.entity1, EX.name, Literal("TestName")) in graph 24 | 25 | 26 | def test_extra_rdf_triples_serialization(graph: Graph, EX: Namespace): 27 | """Test that only model fields are serialized and unrelated triples are not added to the graph.""" 28 | 29 | class MyType(BaseRdfModel): 30 | rdf_type = EX.MyType 31 | _rdf_namespace = EX 32 | name: str 33 | 34 | obj = MyType(uri=EX.entity1, name="TestName") 35 | graph += obj.model_dump_rdf() 36 | assert (EX.entity1, EX.name, Literal("TestName")) in graph 37 | assert (EX.entity1, EX.unrelated, None) not in graph 38 | 39 | 40 | def test_nested_BaseRdfModel_serialization(graph: Graph, EX: Namespace): 41 | """Test that nested BaseRdfModel fields are serialized as related resources with correct predicates.""" 42 | 43 | class Child(BaseRdfModel): 44 | rdf_type = EX.Child 45 | _rdf_namespace = EX 46 | value: str 47 | 48 | class Parent(BaseRdfModel): 49 | rdf_type = EX.Parent 50 | _rdf_namespace = EX 51 | name: str 52 | child: Annotated[Child, WithPredicate(EX.hasChild)] 53 | 54 | child = Child(uri=EX.child1, value="child1-value") 55 | parent = Parent(uri=EX.parent1, name="ParentName", child=child) 56 | graph += parent.model_dump_rdf() 57 | assert (EX.parent1, EX.name, Literal("ParentName")) in graph 58 | assert (EX.parent1, EX.hasChild, EX.child1) in graph 59 | assert (EX.child1, EX.value, Literal("child1-value")) in graph 60 | 61 | 62 | def test_list_of_nested_BaseRdfModels_serialization(graph: Graph, EX: Namespace): 63 | """Test that a list of nested BaseRdfModel instances is serialized as multiple related resources.""" 64 | 65 | class Child(BaseRdfModel): 66 | rdf_type = EX.Child 67 | _rdf_namespace = EX 68 | value: str 69 | 70 | class Parent(BaseRdfModel): 71 | rdf_type = EX.Parent 72 | _rdf_namespace = EX 73 | name: str 74 | children: Annotated[list[Child], WithPredicate(EX.hasChildren)] 75 | 76 | child1 = Child(uri=EX.child1, value="child1-value") 77 | child2 = Child(uri=EX.child2, value="child2-value") 78 | parent = Parent(uri=EX.parent1, name="ParentName", children=[child1, child2]) 79 | graph += parent.model_dump_rdf() 80 | assert (EX.parent1, EX.name, Literal("ParentName")) in graph 81 | assert (EX.parent1, EX.hasChildren, EX.child1) in graph 82 | assert (EX.parent1, EX.hasChildren, EX.child2) in graph 83 | assert (EX.child1, EX.value, Literal("child1-value")) in graph 84 | assert (EX.child2, EX.value, Literal("child2-value")) in graph 85 | 86 | 87 | def test_list_of_literals_serialization(graph: Graph, EX: Namespace): 88 | """Test that a list of literal values is serialized as multiple triples for the same predicate.""" 89 | 90 | class MyType(BaseRdfModel): 91 | rdf_type = EX.MyType 92 | _rdf_namespace = EX 93 | tags: list[str] 94 | 95 | obj = MyType(uri=EX.entity1, tags=["tag1", "tag2"]) 96 | graph += obj.model_dump_rdf() 97 | assert (EX.entity1, EX.tags, Literal("tag1")) in graph 98 | assert (EX.entity1, EX.tags, Literal("tag2")) in graph 99 | 100 | 101 | def test_optional_field_serialization(graph: Graph, EX: Namespace): 102 | """Test that optional fields are only serialized if they are not None.""" 103 | 104 | class MyType(BaseRdfModel): 105 | rdf_type = EX.MyType 106 | _rdf_namespace = EX 107 | name: str 108 | nickname: str | None = None 109 | 110 | obj = MyType(uri=EX.entity1, name="TestName") 111 | graph += obj.model_dump_rdf() 112 | assert (EX.entity1, EX.name, Literal("TestName")) in graph 113 | # Should not serialize EX.nickname if None 114 | assert (EX.entity1, EX.nickname, None) not in graph 115 | 116 | 117 | def test_field_with_default_value_serialization(graph: Graph, EX: Namespace): 118 | """Test that fields with default values are serialized with their default if not explicitly set.""" 119 | 120 | class MyType(BaseRdfModel): 121 | rdf_type = EX.MyType 122 | _rdf_namespace = EX 123 | name: str 124 | status: str = "active" 125 | 126 | obj = MyType(uri=EX.entity1, name="TestName") 127 | graph += obj.model_dump_rdf() 128 | assert (EX.entity1, EX.name, Literal("TestName")) in graph 129 | assert (EX.entity1, EX.status, Literal("active")) in graph 130 | 131 | 132 | def test_multiple_entities_serialization(graph: Graph, EX: Namespace): 133 | """Test that multiple model instances serialize to separate sets of triples in the graph.""" 134 | 135 | class MyType(BaseRdfModel): 136 | rdf_type = EX.MyType 137 | _rdf_namespace = EX 138 | name: str 139 | 140 | obj1 = MyType(uri=EX.entity1, name="Entity1") 141 | obj2 = MyType(uri=EX.entity2, name="Entity2") 142 | 143 | graph += obj1.model_dump_rdf() 144 | graph += obj2.model_dump_rdf() 145 | 146 | assert (EX.entity1, EX.name, Literal("Entity1")) in graph 147 | assert (EX.entity2, EX.name, Literal("Entity2")) in graph 148 | 149 | 150 | def test_self_reference_serialization(graph: Graph, EX: Namespace): 151 | """Test that self-referential fields are serialized as resource links (URIs) in the graph.""" 152 | 153 | class Node(BaseRdfModel): 154 | rdf_type = EX.Node 155 | _rdf_namespace = EX 156 | name: str 157 | next: Annotated["Node", None] | None = None 158 | 159 | node2 = Node(uri=EX.node2, name="Node2") 160 | node1 = Node(uri=EX.node1, name="Node1", next=node2) 161 | graph += node1.model_dump_rdf() 162 | assert (EX.node1, EX.name, Literal("Node1")) in graph 163 | assert (EX.node1, EX.next, EX.node2) in graph 164 | assert (EX.node2, EX.name, Literal("Node2")) in graph 165 | 166 | 167 | def test_field_with_unknown_type_serialization(graph: Graph, EX: Namespace): 168 | """Test that unsupported field types (e.g., complex) are not serialized or raise an error.""" 169 | 170 | class MyType(BaseRdfModel): 171 | rdf_type = EX.MyType 172 | _rdf_namespace = EX 173 | data: complex 174 | 175 | obj = MyType(uri=EX.entity1, data=complex(1, 2)) 176 | graph += obj.model_dump_rdf() 177 | # Should raise TypeError or skip serialization 178 | # (No assertion, just ensure test fails or is skipped) 179 | pass 180 | 181 | 182 | def test_successful_type_coercion_serialization(graph: Graph, EX: Namespace): 183 | """Test that values are correctly coerced to their field types and serialized as expected.""" 184 | 185 | class MyType(BaseRdfModel): 186 | rdf_type = EX.MyType 187 | _rdf_namespace = EX 188 | score: float 189 | 190 | obj = MyType(uri=EX.entity1, score=1.5) 191 | graph += obj.model_dump_rdf() 192 | assert (EX.entity1, EX.score, Literal(1.5)) in graph 193 | 194 | 195 | def test_computed_field_not_serialized(graph: Graph, EX: Namespace): 196 | """Test that computed fields (properties) are not serialized as RDF triples.""" 197 | 198 | class Person(BaseRdfModel): 199 | rdf_type = EX.Person 200 | _rdf_namespace = EX 201 | first_name: str 202 | last_name: str 203 | 204 | @property 205 | def full_name(self) -> str: 206 | return f"{self.first_name} {self.last_name}" 207 | 208 | obj = Person(uri=EX.person1, first_name="John", last_name="Doe") 209 | graph += obj.model_dump_rdf() 210 | assert (EX.person1, EX.first_name, Literal("John")) in graph 211 | assert (EX.person1, EX.last_name, Literal("Doe")) in graph 212 | # Computed field should not be serialized 213 | assert (EX.person1, EX.full_name, None) not in graph 214 | 215 | 216 | def test_field_validator_serialization(graph: Graph, EX: Namespace): 217 | """Test that field validators are respected and validated values are serialized.""" 218 | 219 | class User(BaseRdfModel): 220 | rdf_type = EX.User 221 | _rdf_namespace = EX 222 | email: str 223 | 224 | @classmethod 225 | def validate_email(cls, v: str) -> str: 226 | if "@" not in v: 227 | raise ValueError("Invalid email format") 228 | return v.lower() 229 | 230 | obj = User(uri=EX.user1, email="John.Doe@example.com") 231 | graph += obj.model_dump_rdf() 232 | assert (EX.user1, EX.email, Literal("John.Doe@example.com")) in graph 233 | 234 | 235 | def test_frozen_model_serialization(graph: Graph, EX: Namespace): 236 | """Test that frozen models can still be serialized to RDF triples.""" 237 | 238 | class Config(BaseRdfModel): 239 | rdf_type = EX.Config 240 | _rdf_namespace = EX 241 | model_config = ConfigDict(frozen=True) 242 | name: str 243 | version: str 244 | 245 | obj = Config(uri=EX.config1, name="MyApp", version="1.0.0") 246 | graph += obj.model_dump_rdf() 247 | assert (EX.config1, EX.name, Literal("MyApp")) in graph 248 | assert (EX.config1, EX.version, Literal("1.0.0")) in graph 249 | 250 | 251 | def test_frozen_model_dict_serialization(graph: Graph, EX: Namespace): 252 | """Test that dictionary fields in frozen models are serialized as expected.""" 253 | 254 | class Config(BaseRdfModel): 255 | rdf_type = EX.Config 256 | _rdf_namespace = EX 257 | model_config = ConfigDict(frozen=True) 258 | settings: dict[str, str] 259 | 260 | obj = Config(uri=EX.config1, settings={"theme": "dark", "language": "en"}) 261 | graph += obj.model_dump_rdf() 262 | assert (EX.config1, EX.settings, Literal('{"theme": "dark", "language": "en"}')) in graph 263 | 264 | 265 | def test_article_subclass_serialization(graph: Graph, EX: Namespace): 266 | """Test that subclassed models serialize both base and subclass fields correctly.""" 267 | 268 | class BaseContent(BaseRdfModel): 269 | """Base class for content-related serialization tests.""" 270 | 271 | rdf_type = EX.Content 272 | _rdf_namespace = EX 273 | title: str 274 | created_at: str 275 | author: str 276 | 277 | class Article(BaseContent): 278 | rdf_type = EX.Article 279 | content: str 280 | tags: list[str] 281 | 282 | obj = Article( 283 | uri=EX.article1, 284 | title="My First Article", 285 | created_at="2024-03-15", 286 | author="John Doe", 287 | content="This is the article content", 288 | tags=["python", "rdf"], 289 | ) 290 | graph += obj.model_dump_rdf() 291 | assert (EX.article1, EX.title, Literal("My First Article")) in graph 292 | assert (EX.article1, EX.created_at, Literal("2024-03-15")) in graph 293 | assert (EX.article1, EX.author, Literal("John Doe")) in graph 294 | assert (EX.article1, EX.content, Literal("This is the article content")) in graph 295 | assert (EX.article1, EX.tags, Literal("python")) in graph 296 | assert (EX.article1, EX.tags, Literal("rdf")) in graph 297 | 298 | 299 | def test_video_subclass_serialization(graph: Graph, EX: Namespace): 300 | """Test that another subclassed model serializes all its fields correctly.""" 301 | 302 | class BaseContent(BaseRdfModel): 303 | """Base class for content-related serialization tests.""" 304 | 305 | rdf_type = EX.Content 306 | _rdf_namespace = EX 307 | title: str 308 | created_at: str 309 | author: str 310 | 311 | class Video(BaseContent): 312 | rdf_type = EX.Video 313 | duration: int 314 | url: str 315 | 316 | obj = Video( 317 | uri=EX.video1, 318 | title="My Tutorial Video", 319 | created_at="2024-03-16", 320 | author="Jane Smith", 321 | duration=300, 322 | url="https://example.com/video1", 323 | ) 324 | graph += obj.model_dump_rdf() 325 | assert (EX.video1, EX.title, Literal("My Tutorial Video")) in graph 326 | assert (EX.video1, EX.created_at, Literal("2024-03-16")) in graph 327 | assert (EX.video1, EX.author, Literal("Jane Smith")) in graph 328 | assert (EX.video1, EX.duration, Literal(300)) in graph 329 | assert (EX.video1, EX.url, Literal("https://example.com/video1")) in graph 330 | 331 | 332 | def test_custom_serialization_format(graph: Graph, EX: Namespace): 333 | """Test that custom serialization logic produces the expected RDF triples.""" 334 | 335 | class Event(BaseRdfModel): 336 | rdf_type = EX.Event 337 | _rdf_namespace = EX 338 | name: str 339 | timestamp: datetime 340 | metadata: dict[str, str] 341 | 342 | @field_serializer("timestamp") 343 | def serialize_timestamp(self, value): 344 | return value.isoformat() 345 | 346 | obj = Event( 347 | uri=EX.event1, 348 | name="System Update", 349 | timestamp=datetime(2024, 3, 15, 14, 30, 0), 350 | metadata={"status": "completed", "duration": "5m"}, 351 | ) 352 | graph += obj.model_dump_rdf() 353 | # obj.to_graph(graph) 354 | assert (EX.event1, EX.name, Literal("System Update")) in graph 355 | assert (EX.event1, EX.timestamp, Literal("2024-03-15T14:30:00")) in graph 356 | assert (EX.event1, EX.metadata, Literal('{"status": "completed", "duration": "5m"}')) in graph 357 | 358 | 359 | def test_serialization_type_preservation(graph: Graph, EX: Namespace): 360 | """Test that field types (e.g., datetime) are preserved and serialized in the correct format.""" 361 | 362 | class Event(BaseRdfModel): 363 | rdf_type = EX.Event 364 | _rdf_namespace = EX 365 | timestamp: datetime 366 | 367 | obj = Event(uri=EX.event1, timestamp=datetime(2024, 3, 15, 14, 30, 0)) 368 | graph += obj.model_dump_rdf() 369 | assert (EX.event1, EX.timestamp, Literal("2024-03-15T14:30:00", datatype=XSD.dateTime)) in graph 370 | 371 | 372 | @pytest.mark.xfail( 373 | reason="Multiple values for a single-valued field are not yet supported; should pick one or raise a clear error" 374 | ) 375 | def test_multiple_values_single_field_serialization(graph: Graph, EX: Namespace): 376 | """Test that only one value is serialized for single-valued fields, or an error is raised.""" 377 | 378 | class Person(BaseRdfModel): 379 | rdf_type = EX.Person 380 | _rdf_namespace = EX 381 | name: str 382 | 383 | obj = Person(uri=EX.person1, name="John") 384 | graph += obj.model_dump_rdf() 385 | assert (EX.person1, EX.name, Literal("John")) in graph 386 | # If multiple values, should pick one or raise error 387 | 388 | 389 | @pytest.mark.xfail(reason="Language-tagged literals are not yet supported; should handle language alternatives") 390 | def test_language_tagged_literals_serialization(graph: Graph, EX: Namespace): 391 | """Test that language-tagged literals are handled or ignored during serialization.""" 392 | 393 | class MultiLingualContent(BaseRdfModel): 394 | rdf_type = EX.Content 395 | _rdf_namespace = EX 396 | title: str 397 | 398 | obj = MultiLingualContent(uri=EX.content1, title="Hello") 399 | graph += obj.model_dump_rdf() 400 | assert (EX.content1, EX.title, Literal("Hello")) in graph 401 | # Should support language tags in future 402 | 403 | 404 | @pytest.mark.xfail(reason="Blank nodes are not yet supported; should handle complex structures") 405 | def test_blank_node_complex_structure_serialization(graph: Graph, EX: Namespace): 406 | """Test that blank nodes and their nested structure are serialized correctly.""" 407 | 408 | class Address(BaseRdfModel): 409 | rdf_type = EX.Address 410 | _rdf_namespace = EX 411 | street: str 412 | city: str 413 | 414 | class Person(BaseRdfModel): 415 | rdf_type = EX.Person 416 | _rdf_namespace = EX 417 | name: str 418 | address: Annotated[Address, WithPredicate(EX.address)] 419 | 420 | address = Address(uri=EX.address1, street="123 Main St", city="Springfield") 421 | person = Person(uri=EX.person1, name="John Doe", address=address) 422 | graph += person.model_dump_rdf() 423 | assert (EX.person1, EX.name, Literal("John Doe")) in graph 424 | assert (EX.person1, EX.address, EX.address1) in graph 425 | assert (EX.address1, EX.street, Literal("123 Main St")) in graph 426 | assert (EX.address1, EX.city, Literal("Springfield")) in graph 427 | 428 | 429 | def test_multiple_rdf_types_serialization(graph: Graph, EX: Namespace): 430 | """Test that resources with multiple rdf:types are serialized with all relevant type triples.""" 431 | 432 | class Employee(BaseRdfModel): 433 | rdf_type = EX.Employee 434 | _rdf_namespace = EX 435 | name: str 436 | 437 | obj = Employee(uri=EX.person1, name="John Doe") 438 | graph += obj.model_dump_rdf() 439 | assert (EX.person1, EX.name, Literal("John Doe")) in graph 440 | # Should add rdf:type triple for Employee 441 | 442 | 443 | @pytest.mark.xfail(reason="RDF lists are not yet supported; should preserve order from rdf:List") 444 | def test_rdf_list_ordering_serialization(graph: Graph, EX: Namespace): 445 | """Test that lists are serialized as RDF lists, preserving order.""" 446 | 447 | class Playlist(BaseRdfModel): 448 | rdf_type = EX.Playlist 449 | _rdf_namespace = EX 450 | name: str 451 | tracks: list[str] 452 | 453 | obj = Playlist(uri=EX.playlist1, name="My Playlist", tracks=["track1", "track2", "track3"]) 454 | graph += obj.model_dump_rdf() 455 | assert (EX.playlist1, EX.name, Literal("My Playlist")) in graph 456 | # Should serialize tracks as rdf:List in order 457 | 458 | 459 | @pytest.mark.xfail(reason="RDF reification is not yet supported; should parse reified statements") 460 | def test_reification_handling_serialization(graph: Graph, EX: Namespace): 461 | """Test that reified statements are serialized as RDF reification structures.""" 462 | 463 | class Statement(BaseRdfModel): 464 | rdf_type = EX.Statement 465 | _rdf_namespace = EX 466 | subject: str 467 | predicate: str 468 | object: str 469 | confidence: float 470 | 471 | obj = Statement(uri=EX.statement1, subject="JohnDoe", predicate="knows", object="JaneSmith", confidence=0.9) 472 | graph += obj.model_dump_rdf() 473 | assert (EX.statement1, EX.subject, Literal("JohnDoe")) in graph 474 | assert (EX.statement1, EX.predicate, Literal("knows")) in graph 475 | assert (EX.statement1, EX.object, Literal("JaneSmith")) in graph 476 | assert (EX.statement1, EX.confidence, Literal(0.9)) in graph 477 | 478 | 479 | def test_custom_datatype_literals_serialization(graph: Graph, EX: Namespace): 480 | """Test that custom datatypes (e.g., geo:point, xsd:date) are serialized as RDF literals with correct datatypes.""" 481 | 482 | class CustomDatatypes(BaseRdfModel): 483 | rdf_type = EX.CustomDatatypes 484 | _rdf_namespace = EX 485 | coordinate: str 486 | date: date 487 | 488 | obj = CustomDatatypes(uri=EX.data1, coordinate="45.123,-122.456", date=date(2024, 3, 20)) 489 | graph += obj.model_dump_rdf() 490 | assert (EX.data1, EX.coordinate, Literal("45.123,-122.456")) in graph 491 | assert (EX.data1, EX.date, Literal("2024-03-20", datatype=XSD.date)) in graph 492 | 493 | 494 | @pytest.mark.xfail(reason="RDF container membership properties are not yet supported; should parse container items") 495 | def test_container_membership_serialization(graph: Graph, EX: Namespace): 496 | """Test that RDF containers are serialized using container membership properties (rdf:_1, rdf:_2, ...).""" 497 | 498 | class Container(BaseRdfModel): 499 | rdf_type = EX.Bag 500 | _rdf_namespace = EX 501 | items: list[str] 502 | 503 | obj = Container(uri=EX.container1, items=["item1", "item2", "item3"]) 504 | graph += obj.model_dump_rdf() 505 | # Should serialize items as rdf:_1, rdf:_2, ... 506 | 507 | 508 | def test_datatype_promotion_serialization(graph: Graph, EX: Namespace): 509 | """Test that numeric values are promoted/coerced to the correct datatype in serialization.""" 510 | 511 | class Numbers(BaseRdfModel): 512 | rdf_type = EX.Numbers 513 | _rdf_namespace = EX 514 | integer_field: int 515 | decimal_field: float 516 | any_number: float 517 | 518 | obj = Numbers(uri=EX.numbers1, integer_field=42, decimal_field=42.0, any_number=42.0) 519 | graph += obj.model_dump_rdf() 520 | assert (EX.numbers1, EX.integer_field, Literal(42)) in graph 521 | assert (EX.numbers1, EX.decimal_field, Literal(42.0)) in graph 522 | assert (EX.numbers1, EX.any_number, Literal(42.0)) in graph 523 | 524 | 525 | @pytest.mark.xfail(reason="Blank nodes are not yet supported") 526 | def test_blank_node_identity_serialization(graph: Graph, EX: Namespace): 527 | """Test that blank node identity is preserved and shared blank nodes are serialized consistently.""" 528 | 529 | class NodeContainer(BaseRdfModel): 530 | rdf_type = EX.NodeContainer 531 | _rdf_namespace = EX 532 | name: str 533 | related: Annotated[list["NodeContainer"], WithPredicate(EX.related)] 534 | 535 | shared = NodeContainer(uri=EX.shared, name="Shared Node", related=[]) 536 | container1 = NodeContainer(uri=EX.container1, name="Container 1", related=[shared]) 537 | graph += container1.model_dump_rdf() 538 | container2 = NodeContainer(uri=EX.container2, name="Container 2", related=[shared]) 539 | graph += container2.model_dump_rdf() 540 | assert (EX.container1, EX.related, EX.shared) in graph 541 | assert (EX.container2, EX.related, EX.shared) in graph 542 | assert (EX.shared, EX.name, Literal("Shared Node")) in graph 543 | 544 | 545 | @pytest.mark.xfail(reason="We don't support SPARQL-like property paths.") 546 | def test_property_paths_serialization(graph: Graph, EX: Namespace): 547 | """Test that property paths (complex predicate chains) are handled or raise a clear error during serialization.""" 548 | 549 | class Organization(BaseRdfModel): 550 | rdf_type = EX.Organization 551 | _rdf_namespace = EX 552 | name: str 553 | 554 | class Person(BaseRdfModel): 555 | rdf_type = EX.Person 556 | _rdf_namespace = EX 557 | name: str 558 | 559 | org = Organization(uri=EX.org1, name="Test Org") 560 | graph += org.model_dump_rdf() 561 | assert (EX.org1, EX.name, Literal("Test Org")) in graph 562 | -------------------------------------------------------------------------------- /tests/test_deserialize.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Self 2 | 3 | import pytest 4 | from pydantic import ( 5 | ConfigDict, 6 | Field, 7 | ValidationError, 8 | computed_field, 9 | field_validator, 10 | model_serializer, 11 | model_validator, 12 | ) 13 | from rdflib import RDF, XSD, BNode, Graph, Literal, Namespace 14 | from rdflib.extras.describer import Describer 15 | 16 | from pydantic_rdf.annotation import WithPredicate 17 | from pydantic_rdf.model import BaseRdfModel, CircularReferenceError, UnsupportedFieldTypeError 18 | 19 | 20 | def test_basic_string_field(graph: Graph, EX: Namespace): 21 | # Define class to test 22 | class MyType(BaseRdfModel): 23 | rdf_type = EX.MyType 24 | _rdf_namespace = EX 25 | 26 | name: str 27 | 28 | # Create a new entity 29 | d = Describer(graph=graph, about=EX.entity1) 30 | d.rdftype(EX.MyType) 31 | d.value(EX.name, Literal("TestName")) 32 | 33 | # Deserialize the entity from the graph 34 | obj = MyType.parse_graph(graph, EX.entity1) 35 | assert obj.name == "TestName" 36 | 37 | 38 | def test_missing_required_field(graph: Graph, EX: Namespace): 39 | # Define class to test 40 | class MyType(BaseRdfModel): 41 | rdf_type = EX.MyType 42 | _rdf_namespace = EX 43 | name: str 44 | 45 | # Create a new entity 46 | d = Describer(graph=graph, about=EX.entity1) 47 | d.rdftype(EX.MyType) 48 | # No EX.name triple 49 | 50 | # Deserialize the entity from the graph and assert results 51 | with pytest.raises(ValidationError): 52 | MyType.parse_graph(graph, EX.entity1) 53 | 54 | 55 | def test_extra_rdf_triples(graph: Graph, EX: Namespace): 56 | # Define class to test 57 | class MyType(BaseRdfModel): 58 | rdf_type = EX.MyType 59 | _rdf_namespace = EX 60 | name: str 61 | 62 | # Create a new entity 63 | d = Describer(graph=graph, about=EX.entity1) 64 | d.rdftype(EX.MyType) 65 | d.value(EX.name, Literal("TestName")) 66 | d.value(EX.unrelated, Literal("ShouldBeIgnored")) 67 | 68 | # Deserialize the entity from the graph 69 | obj = MyType.parse_graph(graph, EX.entity1) 70 | assert obj.name == "TestName" 71 | 72 | 73 | def test_incorrect_rdf_type(graph: Graph, EX: Namespace): 74 | # Define class to test 75 | class MyType(BaseRdfModel): 76 | rdf_type = EX.MyType 77 | _rdf_namespace = EX 78 | name: str 79 | 80 | # Create a new entity 81 | d = Describer(graph=graph, about=EX.entity1) 82 | d.rdftype(EX.OtherType) # Wrong type! 83 | d.value(EX.name, Literal("TestName")) 84 | 85 | # Deserialize the entity from the graph and assert results 86 | with pytest.raises(ValueError): 87 | MyType.parse_graph(graph, EX.entity1) 88 | 89 | 90 | def test_nested_BaseRdfModel_extraction(graph: Graph, EX: Namespace): 91 | # Define class to test 92 | class Child(BaseRdfModel): 93 | rdf_type = EX.Child 94 | _rdf_namespace = EX 95 | value: str 96 | 97 | class Parent(BaseRdfModel): 98 | rdf_type = EX.Parent 99 | _rdf_namespace = EX 100 | name: str 101 | child: Annotated[Child, WithPredicate(EX.hasChild)] 102 | 103 | # Create a new entity 104 | d = Describer(graph=graph, about=EX.parent1) 105 | d.rdftype(EX.Parent) 106 | d.value(EX.name, Literal("ParentName")) 107 | 108 | # Add a child relationship 109 | with d.rel(EX.hasChild, EX.child1): 110 | d.rdftype(EX.Child) 111 | d.value(EX.value, Literal("child1-value")) 112 | 113 | # Deserialize the entity from the graph 114 | parent = Parent.parse_graph(graph, EX.parent1) 115 | 116 | # Assert results 117 | assert parent.name == "ParentName" 118 | assert isinstance(parent.child, Child) 119 | assert parent.child.value == "child1-value" 120 | 121 | 122 | def test_list_of_nested_BaseRdfModels(graph: Graph, EX: Namespace): 123 | # Define class to test 124 | class Child(BaseRdfModel): 125 | rdf_type = EX.Child 126 | _rdf_namespace = EX 127 | 128 | value: str 129 | 130 | class Parent(BaseRdfModel): 131 | rdf_type = EX.Parent 132 | _rdf_namespace = EX 133 | 134 | name: str 135 | children: Annotated[list[Child], WithPredicate(EX.hasChildren)] = Field(default_factory=list) 136 | 137 | # Create a new entity 138 | d = Describer(graph=graph, about=EX.parent1) 139 | d.rdftype(EX.Parent) 140 | d.value(EX.name, Literal("ParentName")) 141 | 142 | with d.rel(EX.hasChildren, EX.child1): 143 | d.rdftype(EX.Child) 144 | d.value(EX.value, Literal("child1-value")) 145 | 146 | with d.rel(EX.hasChildren, EX.child2): 147 | d.rdftype(EX.Child) 148 | d.value(EX.value, Literal("child2-value")) 149 | 150 | # Deserialize the entity from the graph 151 | parent = Parent.parse_graph(graph, EX.parent1) 152 | 153 | # Assert results 154 | assert parent.name == "ParentName" 155 | assert isinstance(parent.children, list) 156 | assert {c.value for c in parent.children} == {"child1-value", "child2-value"} 157 | 158 | 159 | def test_list_of_literals(graph: Graph, EX: Namespace): 160 | # Define class to test 161 | class MyType(BaseRdfModel): 162 | rdf_type = EX.MyType 163 | _rdf_namespace = EX 164 | tags: list[str] 165 | 166 | # Create a new entity 167 | d = Describer(graph=graph, about=EX.entity1) 168 | d.rdftype(EX.MyType) 169 | d.value(EX.tags, Literal("tag1")) 170 | d.value(EX.tags, Literal("tag2")) 171 | 172 | # Deserialize the entity from the graph 173 | obj = MyType.parse_graph(graph, EX.entity1) 174 | 175 | # Assert results 176 | assert isinstance(obj.tags, list) 177 | assert set(obj.tags) == {"tag1", "tag2"} 178 | 179 | 180 | def test_default_predicate_no_annotation(graph: Graph, EX: Namespace): 181 | # Define class to test 182 | class MyType(BaseRdfModel): 183 | rdf_type = EX.MyType 184 | _rdf_namespace = EX 185 | description: str 186 | 187 | # Create a new entity 188 | d = Describer(graph=graph, about=EX.entity1) 189 | d.rdftype(EX.MyType) 190 | d.value(EX.description, Literal("A description")) 191 | 192 | # Deserialize the entity from the graph 193 | obj = MyType.parse_graph(graph, EX.entity1) 194 | # Assert results 195 | assert obj.description == "A description" 196 | 197 | 198 | def test_optional_field(graph: Graph, EX: Namespace): 199 | # Define class to test 200 | class MyType(BaseRdfModel): 201 | rdf_type = EX.MyType 202 | _rdf_namespace = EX 203 | name: str 204 | nickname: str | None = None 205 | 206 | # Create a new entity 207 | d = Describer(graph=graph, about=EX.entity1) 208 | d.rdftype(EX.MyType) 209 | d.value(EX.name, Literal("TestName")) 210 | # No EX.nickname triple 211 | 212 | # Deserialize the entity from the graph 213 | obj = MyType.parse_graph(graph, EX.entity1) 214 | # Assert results 215 | assert obj.name == "TestName" 216 | assert obj.nickname is None 217 | 218 | 219 | def test_field_with_default_value(graph: Graph, EX: Namespace): 220 | # Define class to test 221 | class MyType(BaseRdfModel): 222 | rdf_type = EX.MyType 223 | _rdf_namespace = EX 224 | name: str 225 | status: str = "active" 226 | 227 | # Create a new entity 228 | d = Describer(graph=graph, about=EX.entity1) 229 | d.rdftype(EX.MyType) 230 | d.value(EX.name, Literal("TestName")) 231 | # No EX.status triple 232 | 233 | # Deserialize the entity from the graph 234 | obj = MyType.parse_graph(graph, EX.entity1) 235 | # Assert results 236 | assert obj.name == "TestName" 237 | assert obj.status == "active" 238 | 239 | 240 | def test_multiple_entities_extraction(graph: Graph, EX: Namespace): 241 | # Define class to test 242 | class MyType(BaseRdfModel): 243 | rdf_type = EX.MyType 244 | _rdf_namespace = EX 245 | name: str 246 | 247 | # Create new entities 248 | d1 = Describer(graph=graph, about=EX.entity1) 249 | d1.rdftype(EX.MyType) 250 | d1.value(EX.name, Literal("Entity1")) 251 | 252 | d2 = Describer(graph=graph, about=EX.entity2) 253 | d2.rdftype(EX.MyType) 254 | d2.value(EX.name, Literal("Entity2")) 255 | 256 | # Deserialize all entities from the graph 257 | objs = MyType.all_entities(graph) 258 | # Assert results 259 | names = {obj.name for obj in objs} 260 | assert names == {"Entity1", "Entity2"} 261 | 262 | 263 | def test_non_uriref_subject(graph: Graph, EX: Namespace): 264 | # Define class to test 265 | class MyType(BaseRdfModel): 266 | rdf_type = EX.MyType 267 | _rdf_namespace = EX 268 | 269 | name: str 270 | 271 | # Create a new entity with a BNode (not a URIRef) 272 | bnode = BNode() 273 | graph.add((bnode, EX.name, Literal("TestName"))) 274 | graph.add((bnode, EX.rdftype, EX.MyType)) 275 | 276 | # Should not return any entities for BNode 277 | objs = MyType.all_entities(graph) 278 | 279 | # Assert results 280 | assert objs == [] 281 | 282 | 283 | def test_self_reference(graph: Graph, EX: Namespace): 284 | # Define class to test 285 | class Node(BaseRdfModel): 286 | rdf_type = EX.Node 287 | _rdf_namespace = EX 288 | name: str 289 | next: Self | None = None 290 | 291 | # Create two nodes referencing each other 292 | d = Describer(graph=graph, about=EX.node1) 293 | d.rdftype(EX.Node) 294 | d.value(EX.name, Literal("Node1")) 295 | with d.rel(EX.next, EX.node2): 296 | d.rdftype(EX.Node) 297 | d.value(EX.name, Literal("Node2")) 298 | 299 | # Deserialize the entity from the graph 300 | node1 = Node.parse_graph(graph, EX.node1) 301 | 302 | # Assert results 303 | assert node1.name == "Node1" 304 | assert node1.next is not None # Add type check before accessing attribute 305 | assert node1.next.name == "Node2" 306 | 307 | 308 | def test_circular_reference(graph: Graph, EX: Namespace): 309 | # Define class to test 310 | class Node(BaseRdfModel): 311 | rdf_type = EX.Node 312 | _rdf_namespace = EX 313 | name: str 314 | next: Self | None = None 315 | 316 | # Create two nodes referencing each other 317 | d = Describer(graph=graph, about=EX.node1) 318 | d.rdftype(EX.Node) 319 | d.value(EX.name, Literal("Node1")) 320 | with d.rel(EX.next, EX.node2): 321 | d.rdftype(EX.Node) 322 | d.value(EX.name, Literal("Node2")) 323 | d.rel(EX.next, EX.node1) 324 | 325 | # Deserialize the entity from the graph 326 | with pytest.raises(CircularReferenceError): 327 | Node.parse_graph(graph, EX.node1) 328 | 329 | 330 | def test_field_with_unknown_type(graph: Graph, EX: Namespace): 331 | # Define class to test 332 | class MyType(BaseRdfModel): 333 | rdf_type = EX.MyType 334 | _rdf_namespace = EX 335 | data: complex # Not supported 336 | 337 | # Create a new entity 338 | d = Describer(graph=graph, about=EX.entity1) 339 | d.rdftype(EX.MyType) 340 | d.value(EX.data, Literal("not-a-complex")) 341 | 342 | # Deserialize the entity from the graph and assert results 343 | with pytest.raises(UnsupportedFieldTypeError): 344 | MyType.parse_graph(graph, EX.entity1) 345 | 346 | 347 | def test_failed_type_coercion(graph: Graph, EX: Namespace): 348 | """Test that invalid type coercion raises appropriate validation error.""" 349 | 350 | class MyType(BaseRdfModel): 351 | rdf_type = EX.MyType 352 | _rdf_namespace = EX 353 | age: int 354 | 355 | d = Describer(graph=graph, about=EX.entity1) 356 | d.rdftype(EX.MyType) 357 | d.value(EX.age, Literal("not-an-integer")) 358 | 359 | with pytest.raises(ValidationError) as exc_info: 360 | MyType.parse_graph(graph, EX.entity1) 361 | 362 | assert "age" in str(exc_info.value) 363 | 364 | 365 | def test_successful_type_coercion(graph: Graph, EX: Namespace): 366 | """Test that valid string-to-number coercion works.""" 367 | 368 | class MyType(BaseRdfModel): 369 | rdf_type = EX.MyType 370 | _rdf_namespace = EX 371 | score: float 372 | 373 | d = Describer(graph=graph, about=EX.entity1) 374 | d.rdftype(EX.MyType) 375 | d.value(EX.score, Literal("1.5")) 376 | 377 | obj = MyType.parse_graph(graph, EX.entity1) 378 | assert obj.score == 1.5 379 | assert isinstance(obj.score, float) 380 | 381 | 382 | def test_computed_field_string_concatenation(graph: Graph, EX: Namespace): 383 | """Test computed field that concatenates string values.""" 384 | 385 | class Person(BaseRdfModel): 386 | rdf_type = EX.Person 387 | _rdf_namespace = EX 388 | 389 | first_name: str 390 | last_name: str 391 | 392 | @computed_field 393 | def full_name(self) -> str: 394 | return f"{self.first_name} {self.last_name}" 395 | 396 | d = Describer(graph=graph, about=EX.person1) 397 | d.rdftype(EX.Person) 398 | d.value(EX.first_name, Literal("John")) 399 | d.value(EX.last_name, Literal("Doe")) 400 | 401 | person = Person.parse_graph(graph, EX.person1) 402 | assert person.full_name == "John Doe" 403 | 404 | 405 | def test_computed_field_boolean_condition(graph: Graph, EX: Namespace): 406 | """Test computed field that evaluates a boolean condition.""" 407 | 408 | class Person(BaseRdfModel): 409 | rdf_type = EX.Person 410 | _rdf_namespace = EX 411 | 412 | age: int 413 | 414 | @computed_field 415 | def is_adult(self) -> bool: 416 | return self.age >= 18 417 | 418 | # Test with adult 419 | d1 = Describer(graph=graph, about=EX.person1) 420 | d1.rdftype(EX.Person) 421 | d1.value(EX.age, Literal(25)) 422 | 423 | adult = Person.parse_graph(graph, EX.person1) 424 | assert adult.is_adult is True 425 | 426 | # Test with minor 427 | d2 = Describer(graph=graph, about=EX.person2) 428 | d2.rdftype(EX.Person) 429 | d2.value(EX.age, Literal(15)) 430 | 431 | minor = Person.parse_graph(graph, EX.person2) 432 | assert minor.is_adult is False 433 | 434 | 435 | def test_email_field_validation(graph: Graph, EX: Namespace): 436 | """Test email field validation with custom validator.""" 437 | 438 | class User(BaseRdfModel): 439 | rdf_type = EX.User 440 | _rdf_namespace = EX 441 | 442 | email: str 443 | 444 | @field_validator("email") 445 | @classmethod 446 | def validate_email(cls, v: str) -> str: 447 | if "@" not in v: 448 | raise ValueError("Invalid email format") 449 | return v.lower() 450 | 451 | # Test valid email 452 | d1 = Describer(graph=graph, about=EX.user1) 453 | d1.rdftype(EX.User) 454 | d1.value(EX.email, Literal("John.Doe@example.com")) 455 | 456 | user = User.parse_graph(graph, EX.user1) 457 | assert user.email == "john.doe@example.com" # Check email was lowercased 458 | 459 | # Test invalid email 460 | d2 = Describer(graph=graph, about=EX.user2) 461 | d2.rdftype(EX.User) 462 | d2.value(EX.email, Literal("invalid-email")) 463 | 464 | with pytest.raises(ValidationError) as exc_info: 465 | User.parse_graph(graph, EX.user2) 466 | assert "Invalid email format" in str(exc_info.value) 467 | 468 | 469 | def test_numeric_field_validation(graph: Graph, EX: Namespace): 470 | """Test numeric field validation with custom validator.""" 471 | 472 | class Payment(BaseRdfModel): 473 | rdf_type = EX.Payment 474 | _rdf_namespace = EX 475 | 476 | amount: float 477 | 478 | @field_validator("amount") 479 | @classmethod 480 | def validate_amount(cls, v: float) -> float: 481 | if v < 0: 482 | raise ValueError("Amount cannot be negative") 483 | return v 484 | 485 | # Test valid amount 486 | d1 = Describer(graph=graph, about=EX.payment1) 487 | d1.rdftype(EX.Payment) 488 | d1.value(EX.amount, Literal(100.0)) 489 | 490 | payment = Payment.parse_graph(graph, EX.payment1) 491 | assert payment.amount == 100.0 492 | 493 | # Test negative amount 494 | d2 = Describer(graph=graph, about=EX.payment2) 495 | d2.rdftype(EX.Payment) 496 | d2.value(EX.amount, Literal(-50.0)) 497 | 498 | with pytest.raises(ValidationError) as exc_info: 499 | Payment.parse_graph(graph, EX.payment2) 500 | assert "Amount cannot be negative" in str(exc_info.value) 501 | 502 | 503 | def test_model_level_validation(graph: Graph, EX: Namespace): 504 | """Test model-level validation with custom validator.""" 505 | 506 | class Employee(BaseRdfModel): 507 | rdf_type = EX.Employee 508 | _rdf_namespace = EX 509 | 510 | department: str 511 | salary: float 512 | 513 | @model_validator(mode="after") 514 | def validate_department_salary(self) -> Self: 515 | if self.department == "IT" and self.salary < 50000: 516 | raise ValueError("IT department salary must be at least 50000") 517 | return self 518 | 519 | # Test valid case 520 | d1 = Describer(graph=graph, about=EX.emp1) 521 | d1.rdftype(EX.Employee) 522 | d1.value(EX.department, Literal("IT")) 523 | d1.value(EX.salary, Literal(60000)) 524 | 525 | emp = Employee.parse_graph(graph, EX.emp1) 526 | assert emp.department == "IT" 527 | assert emp.salary == 60000 528 | 529 | # Test invalid case 530 | d2 = Describer(graph=graph, about=EX.emp2) 531 | d2.rdftype(EX.Employee) 532 | d2.value(EX.department, Literal("IT")) 533 | d2.value(EX.salary, Literal(40000)) 534 | 535 | with pytest.raises(ValidationError) as exc_info: 536 | Employee.parse_graph(graph, EX.emp2) 537 | assert "IT department salary must be at least 50000" in str(exc_info.value) 538 | 539 | 540 | def test_frozen_model_attribute_modification(graph: Graph, EX: Namespace): 541 | """Test that frozen models prevent direct attribute modification.""" 542 | 543 | class Config(BaseRdfModel): 544 | rdf_type = EX.Config 545 | _rdf_namespace = EX 546 | 547 | model_config = ConfigDict(frozen=True) 548 | 549 | name: str 550 | version: str 551 | 552 | d = Describer(graph=graph, about=EX.config1) 553 | d.rdftype(EX.Config) 554 | d.value(EX.name, Literal("MyApp")) 555 | d.value(EX.version, Literal("1.0.0")) 556 | 557 | config = Config.parse_graph(graph, EX.config1) 558 | 559 | with pytest.raises(ValidationError) as exc_info: 560 | config.name = "NewName" 561 | assert "frozen" in str(exc_info.value).lower() 562 | 563 | assert config.name == "MyApp" 564 | assert config.version == "1.0.0" 565 | 566 | 567 | def test_frozen_model_dict_modification(graph: Graph, EX: Namespace): 568 | """Test that frozen models prevent dictionary field modification.""" 569 | 570 | class Config(BaseRdfModel): 571 | rdf_type = EX.Config 572 | _rdf_namespace = EX 573 | 574 | model_config = ConfigDict(frozen=True) 575 | 576 | settings: dict[str, str] = Field(default_factory=dict) 577 | 578 | d = Describer(graph=graph, about=EX.config1) 579 | d.rdftype(EX.Config) 580 | d.value(EX.settings, Literal('{"theme": "dark", "language": "en"}')) 581 | 582 | config = Config.parse_graph(graph, EX.config1) 583 | 584 | # In Pydantic v2, frozen models raise ValidationError for any modification 585 | assert config.settings == {"theme": "dark", "language": "en"} 586 | with pytest.raises(ValidationError) as exc_info: 587 | config.settings = {"theme": "light"} # Try to replace entire dict 588 | assert "frozen" in str(exc_info.value).lower() 589 | 590 | # Original data should be unchanged 591 | assert config.settings == {"theme": "dark", "language": "en"} 592 | 593 | 594 | def test_article_subclass_parsing(graph: Graph, EX: Namespace): 595 | """Test parsing of Article subclass with its specific fields.""" 596 | 597 | class BaseContent(BaseRdfModel): 598 | """Base class for content-related test models.""" 599 | 600 | rdf_type = EX.Content 601 | _rdf_namespace = EX 602 | 603 | title: str 604 | created_at: str 605 | author: str 606 | 607 | class Article(BaseContent): 608 | rdf_type = EX.Article 609 | 610 | content: str 611 | tags: list[str] = Field(default_factory=list) 612 | 613 | d = Describer(graph=graph, about=EX.article1) 614 | d.rdftype(EX.Article) 615 | d.value(EX.title, Literal("My First Article")) 616 | d.value(EX.created_at, Literal("2024-03-15")) 617 | d.value(EX.author, Literal("John Doe")) 618 | d.value(EX.content, Literal("This is the article content")) 619 | d.value(EX.tags, Literal("python")) 620 | d.value(EX.tags, Literal("rdf")) 621 | 622 | article = Article.parse_graph(graph, EX.article1) 623 | assert article.title == "My First Article" 624 | assert article.created_at == "2024-03-15" 625 | assert article.author == "John Doe" 626 | assert article.content == "This is the article content" 627 | assert set(article.tags) == {"python", "rdf"} 628 | 629 | 630 | def test_video_subclass_parsing(graph: Graph, EX: Namespace): 631 | """Test parsing of Video subclass with its specific fields.""" 632 | 633 | class BaseContent(BaseRdfModel): 634 | """Base class for content-related test models.""" 635 | 636 | rdf_type = EX.Content 637 | _rdf_namespace = EX 638 | 639 | title: str 640 | created_at: str 641 | author: str 642 | 643 | class Video(BaseContent): 644 | rdf_type = EX.Video 645 | 646 | duration: int 647 | url: str 648 | 649 | d = Describer(graph=graph, about=EX.video1) 650 | d.rdftype(EX.Video) 651 | d.value(EX.title, Literal("My Tutorial Video")) 652 | d.value(EX.created_at, Literal("2024-03-16")) 653 | d.value(EX.author, Literal("Jane Smith")) 654 | d.value(EX.duration, Literal(300)) 655 | d.value(EX.url, Literal("https://example.com/video1")) 656 | 657 | video = Video.parse_graph(graph, EX.video1) 658 | assert video.title == "My Tutorial Video" 659 | assert video.created_at == "2024-03-16" 660 | assert video.author == "Jane Smith" 661 | assert video.duration == 300 662 | assert video.url == "https://example.com/video1" 663 | 664 | 665 | def test_base_class_type_validation(graph: Graph, EX: Namespace): 666 | """Test that base class cannot parse entities of specific types.""" 667 | 668 | class BaseContent(BaseRdfModel): 669 | """Base class for content-related test models.""" 670 | 671 | rdf_type = EX.Content 672 | _rdf_namespace = EX 673 | 674 | title: str 675 | created_at: str 676 | author: str 677 | 678 | d = Describer(graph=graph, about=EX.content1) 679 | d.rdftype(EX.Article) 680 | d.value(EX.title, Literal("Test")) 681 | d.value(EX.created_at, Literal("2024-03-17")) 682 | d.value(EX.author, Literal("Test Author")) 683 | 684 | with pytest.raises(ValueError) as exc_info: 685 | BaseContent.parse_graph(graph, EX.content1) 686 | assert "type" in str(exc_info.value).lower() 687 | 688 | 689 | def test_custom_serialization_format(graph: Graph, EX: Namespace): 690 | """Test that custom serialization produces the expected format.""" 691 | from datetime import datetime 692 | 693 | class Event(BaseRdfModel): 694 | rdf_type = EX.Event 695 | _rdf_namespace = EX 696 | 697 | name: str 698 | timestamp: datetime 699 | metadata: dict[str, str] = Field(default_factory=dict) 700 | 701 | @model_serializer 702 | def serialize_model(self) -> dict: 703 | return { 704 | "name": self.name, 705 | "timestamp": self.timestamp.isoformat(), 706 | "metadata": self.metadata, 707 | "uri": str(self.uri), 708 | } 709 | 710 | d = Describer(graph=graph, about=EX.event1) 711 | d.rdftype(EX.Event) 712 | d.value(EX.name, Literal("System Update")) 713 | d.value(EX.timestamp, Literal("2024-03-15T14:30:00")) 714 | d.value(EX.metadata, Literal('{"status": "completed", "duration": "5m"}')) 715 | 716 | event = Event.parse_graph(graph, EX.event1) 717 | serialized = event.model_dump() 718 | 719 | assert serialized == { 720 | "name": "System Update", 721 | "timestamp": "2024-03-15T14:30:00", 722 | "metadata": {"status": "completed", "duration": "5m"}, 723 | "uri": str(EX.event1), 724 | } 725 | 726 | 727 | def test_serialization_type_preservation(graph: Graph, EX: Namespace): 728 | """Test that original types are preserved after serialization/deserialization.""" 729 | from datetime import datetime 730 | 731 | class Event(BaseRdfModel): 732 | rdf_type = EX.Event 733 | _rdf_namespace = EX 734 | 735 | timestamp: datetime 736 | 737 | d = Describer(graph=graph, about=EX.event1) 738 | d.rdftype(EX.Event) 739 | d.value(EX.timestamp, Literal("2024-03-15T14:30:00")) 740 | 741 | event = Event.parse_graph(graph, EX.event1) 742 | 743 | assert isinstance(event.timestamp, datetime) 744 | assert event.timestamp.year == 2024 745 | assert event.timestamp.month == 3 746 | assert event.timestamp.day == 15 747 | assert event.timestamp.hour == 14 748 | assert event.timestamp.minute == 30 749 | 750 | 751 | @pytest.mark.xfail( 752 | reason="Multiple values for a single-valued field are not yet supported; should pick one or raise a clear error" 753 | ) 754 | def test_multiple_values_single_field(graph: Graph, EX: Namespace): 755 | """Test behavior when RDF has multiple values for a single-valued field.""" 756 | 757 | class Person(BaseRdfModel): 758 | rdf_type = EX.Person 759 | _rdf_namespace = EX 760 | 761 | name: str # Single-valued field 762 | 763 | # Create entity with multiple values for name 764 | d = Describer(graph=graph, about=EX.person1) 765 | d.rdftype(EX.Person) 766 | d.value(EX.name, Literal("John")) 767 | d.value(EX.name, Literal("Johnny")) # Second value for same predicate 768 | 769 | # Expected behavior: should pick the first value or raise a clear error 770 | person = Person.parse_graph(graph, EX.person1) 771 | assert person.name in {"John", "Johnny"} 772 | 773 | 774 | @pytest.mark.xfail(reason="Language-tagged literals are not yet supported; should handle language alternatives") 775 | def test_language_tagged_literals(graph: Graph, EX: Namespace): 776 | """Test handling of language-tagged literals in RDF.""" 777 | 778 | class MultiLingualContent(BaseRdfModel): 779 | rdf_type = EX.Content 780 | _rdf_namespace = EX 781 | 782 | title: str # No language handling in basic field 783 | 784 | d = Describer(graph=graph, about=EX.content1) 785 | d.rdftype(EX.Content) 786 | # Add same title in different languages 787 | graph.add((EX.content1, EX.title, Literal("Hello", lang="en"))) 788 | graph.add((EX.content1, EX.title, Literal("Bonjour", lang="fr"))) 789 | 790 | # Expected behavior: should pick one value or provide language alternatives 791 | content = MultiLingualContent.parse_graph(graph, EX.content1) 792 | assert content.title in {"Hello", "Bonjour"} 793 | 794 | 795 | @pytest.mark.xfail(reason="Blank nodes are not yet supported; should handle complex structures") 796 | def test_blank_node_complex_structure(graph: Graph, EX: Namespace): 797 | """Test handling of complex blank node structures.""" 798 | from rdflib import BNode 799 | 800 | class Address(BaseRdfModel): 801 | rdf_type = EX.Address 802 | _rdf_namespace = EX 803 | 804 | street: str 805 | city: str 806 | 807 | class Person(BaseRdfModel): 808 | rdf_type = EX.Person 809 | _rdf_namespace = EX 810 | 811 | name: str 812 | address: Annotated[Address, WithPredicate(EX.address)] 813 | 814 | # Create a person with address as blank node 815 | person_node = EX.person1 816 | address_node = BNode() 817 | 818 | # Add triples using blank node 819 | graph.add((person_node, EX.rdftype, EX.Person)) 820 | graph.add((person_node, EX.name, Literal("John Doe"))) 821 | graph.add((person_node, EX.address, address_node)) 822 | graph.add((address_node, EX.rdftype, EX.Address)) 823 | graph.add((address_node, EX.street, Literal("123 Main St"))) 824 | graph.add((address_node, EX.city, Literal("Springfield"))) 825 | 826 | # This should work - blank nodes should be handled 827 | person = Person.parse_graph(graph, EX.person1) 828 | assert person.name == "John Doe" 829 | assert person.address.street == "123 Main St" 830 | assert person.address.city == "Springfield" 831 | 832 | 833 | def test_multiple_rdf_types(graph: Graph, EX: Namespace): 834 | """Test handling of resources with multiple rdf:types.""" 835 | 836 | class Employee(BaseRdfModel): 837 | rdf_type = EX.Employee 838 | _rdf_namespace = EX 839 | name: str 840 | 841 | # Create entity with multiple types 842 | d = Describer(graph=graph, about=EX.person1) 843 | d.rdftype(EX.Employee) 844 | d.rdftype(EX.Manager) # Additional type 845 | d.value(EX.name, Literal("John Doe")) 846 | 847 | # Current implementation might not handle this well 848 | employee = Employee.parse_graph(graph, EX.person1) 849 | assert employee.name == "John Doe" # Should still work if one type matches 850 | 851 | 852 | @pytest.mark.xfail(reason="RDF lists are not yet supported; should preserve order from rdf:List") 853 | def test_rdf_list_ordering(graph: Graph, EX: Namespace): 854 | """Test handling of RDF ordered lists (rdf:List).""" 855 | from rdflib import RDF, URIRef 856 | 857 | class Playlist(BaseRdfModel): 858 | rdf_type = EX.Playlist 859 | _rdf_namespace = EX 860 | 861 | name: str 862 | tracks: list[str] # Should preserve order from rdf:List 863 | 864 | # Create a playlist with ordered tracks using rdf:List 865 | playlist_uri = EX.playlist1 866 | graph.add((playlist_uri, EX.rdftype, EX.Playlist)) 867 | graph.add((playlist_uri, EX.name, Literal("My Playlist"))) 868 | 869 | # Create an RDF List for tracks 870 | track1 = URIRef("http://music.example.com/track1") 871 | track2 = URIRef("http://music.example.com/track2") 872 | track3 = URIRef("http://music.example.com/track3") 873 | 874 | # Create the list structure 875 | list_head = BNode() 876 | graph.add((playlist_uri, EX.tracks, list_head)) 877 | 878 | current = list_head 879 | for track in [track1, track2, track3]: 880 | graph.add((current, RDF.first, track)) 881 | if track != track3: 882 | next_node = BNode() 883 | graph.add((current, RDF.rest, next_node)) 884 | current = next_node 885 | else: 886 | graph.add((current, RDF.rest, RDF.nil)) 887 | 888 | # Expected behavior: tracks should be in order 889 | playlist = Playlist.parse_graph(graph, EX.playlist1) 890 | assert playlist.name == "My Playlist" 891 | assert playlist.tracks == [track1, track2, track3] 892 | 893 | 894 | @pytest.mark.xfail(reason="RDF reification is not yet supported; should parse reified statements") 895 | def test_reification_handling(graph: Graph, EX: Namespace): 896 | """Test handling of RDF reification (statements about statements).""" 897 | from rdflib import RDF 898 | 899 | class Statement(BaseRdfModel): 900 | rdf_type = RDF.Statement 901 | _rdf_namespace = EX 902 | 903 | subject: str 904 | predicate: str 905 | object: str 906 | confidence: float # A property of the statement itself 907 | 908 | # Create a base statement 909 | base_statement = EX.statement1 910 | graph.add((base_statement, RDF.type, RDF.Statement)) 911 | graph.add((base_statement, RDF.subject, EX.JohnDoe)) 912 | graph.add((base_statement, RDF.predicate, EX.knows)) 913 | graph.add((base_statement, RDF.object, EX.JaneSmith)) 914 | graph.add((base_statement, EX.confidence, Literal(0.9))) 915 | 916 | # Expected behavior: should parse the reified statement 917 | statement = Statement.parse_graph(graph, base_statement) 918 | assert statement.subject == str(EX.JohnDoe) 919 | assert statement.predicate == str(EX.knows) 920 | assert statement.object == str(EX.JaneSmith) 921 | assert statement.confidence == 0.9 922 | 923 | 924 | @pytest.mark.xfail(reason="Custom RDF datatypes are not yet supported; should parse custom datatypes") 925 | def test_custom_datatype_literals(graph: Graph, EX: Namespace): 926 | """Test handling of RDF literals with custom datatypes.""" 927 | import datetime 928 | 929 | from rdflib import XSD 930 | 931 | class CustomDatatypes(BaseRdfModel): 932 | rdf_type = EX.CustomDatatypes 933 | _rdf_namespace = EX 934 | 935 | coordinate: str # Should be a custom geo:point datatype 936 | date: datetime.date # Should be xsd:date 937 | 938 | # Create custom datatype URIs 939 | GEO = Namespace("http://www.w3.org/2003/01/geo/wgs84_pos#") 940 | point_literal = Literal("45.123,-122.456", datatype=GEO.point) 941 | date_literal = Literal("2024-03-20", datatype=XSD.date) 942 | 943 | d = Describer(graph=graph, about=EX.data1) 944 | d.rdftype(EX.CustomDatatypes) 945 | graph.add((EX.data1, EX.coordinate, point_literal)) 946 | graph.add((EX.data1, EX.date, date_literal)) 947 | 948 | # Expected behavior: should parse custom datatypes as strings 949 | data = CustomDatatypes.parse_graph(graph, EX.data1) 950 | assert data.coordinate == "45.123,-122.456" 951 | assert data.date == datetime.date(2024, 3, 20) 952 | 953 | 954 | @pytest.mark.xfail(reason="RDF container membership properties are not yet supported; should parse container items") 955 | def test_container_membership(graph: Graph, EX: Namespace): 956 | """Test handling of RDF container membership properties.""" 957 | from rdflib import RDF 958 | 959 | class Container(BaseRdfModel): 960 | rdf_type = RDF.Bag # Using RDF Bag as an example 961 | _rdf_namespace = EX 962 | 963 | items: list[str] 964 | 965 | # Create a Bag container 966 | container_uri = EX.container1 967 | graph.add((container_uri, RDF.type, RDF.Bag)) 968 | 969 | # Add items using container membership properties (_1, _2, _3, etc.) 970 | for i, item in enumerate(["item1", "item2", "item3"], start=1): 971 | member_prop = RDF[f"_{i}"] # Creates properties like rdf:_1, rdf:_2, etc. 972 | graph.add((container_uri, member_prop, Literal(item))) 973 | 974 | # Expected behavior: should parse all items in order 975 | container = Container.parse_graph(graph, container_uri) 976 | assert container.items == ["item1", "item2", "item3"] 977 | 978 | 979 | def test_datatype_promotion(graph: Graph, EX: Namespace): 980 | """Test handling of RDF literal datatype promotion/coercion.""" 981 | 982 | class Numbers(BaseRdfModel): 983 | rdf_type = EX.Numbers 984 | _rdf_namespace = EX 985 | 986 | integer_field: int 987 | decimal_field: float 988 | any_number: float 989 | 990 | d = Describer(graph=graph, about=EX.numbers1) 991 | d.rdftype(EX.Numbers) 992 | 993 | # Add an integer that could be promoted to decimal 994 | graph.add((EX.numbers1, EX.integer_field, Literal("42", datatype=XSD.integer))) 995 | graph.add((EX.numbers1, EX.decimal_field, Literal("42.0", datatype=XSD.decimal))) 996 | # Add an integer to a float field - should be promoted 997 | graph.add((EX.numbers1, EX.any_number, Literal("42", datatype=XSD.integer))) 998 | 999 | numbers = Numbers.parse_graph(graph, EX.numbers1) 1000 | assert isinstance(numbers.integer_field, int) 1001 | assert isinstance(numbers.decimal_field, float) 1002 | assert isinstance(numbers.any_number, float) 1003 | assert numbers.any_number == 42.0 1004 | 1005 | 1006 | @pytest.mark.xfail(reason="Blank nodes are not yet supported") 1007 | def test_blank_node_identity(graph: Graph, EX: Namespace): 1008 | """Test handling of blank node identity and equivalence.""" 1009 | 1010 | class NodeContainer(BaseRdfModel): 1011 | rdf_type = EX.NodeContainer 1012 | _rdf_namespace = EX 1013 | 1014 | name: str 1015 | related: Annotated[list["NodeContainer"], WithPredicate(EX.related)] 1016 | 1017 | NodeContainer.model_rebuild() 1018 | 1019 | # Create two containers that reference the same blank node 1020 | container1_uri = EX.container1 1021 | container2_uri = EX.container2 1022 | shared_blank_node = BNode() 1023 | 1024 | # Add data about the blank node 1025 | graph.add((shared_blank_node, RDF.type, EX.NodeContainer)) 1026 | graph.add((shared_blank_node, EX.name, Literal("Shared Node"))) 1027 | 1028 | # Both containers relate to the same blank node 1029 | graph.add((container1_uri, RDF.type, EX.NodeContainer)) 1030 | graph.add((container1_uri, EX.name, Literal("Container 1"))) 1031 | graph.add((container1_uri, EX.related, shared_blank_node)) 1032 | 1033 | graph.add((container2_uri, RDF.type, EX.NodeContainer)) 1034 | graph.add((container2_uri, EX.name, Literal("Container 2"))) 1035 | graph.add((container2_uri, EX.related, shared_blank_node)) 1036 | 1037 | # Parse both containers 1038 | container1 = NodeContainer.parse_graph(graph, container1_uri) 1039 | container2 = NodeContainer.parse_graph(graph, container2_uri) 1040 | 1041 | # The related blank nodes should have the same content but might not be the same object 1042 | assert len(container1.related) == 1 1043 | assert len(container2.related) == 1 1044 | assert container1.related[0].name == "Shared Node" 1045 | assert container2.related[0].name == "Shared Node" 1046 | # This might fail because we create separate objects for the same blank node 1047 | assert container1.related[0] is not container2.related[0] 1048 | 1049 | 1050 | @pytest.mark.xfail(reason="We don't support SPARQL-like property paths.") 1051 | def test_property_paths(graph: Graph, EX: Namespace): 1052 | """Test handling of complex property path patterns.""" 1053 | 1054 | class Organization(BaseRdfModel): 1055 | rdf_type = EX.Organization 1056 | _rdf_namespace = EX 1057 | 1058 | name: str 1059 | # These would be nice to have but aren't supported 1060 | all_employees: Annotated[list["Person"], WithPredicate(EX.department / EX.employee)] # type: ignore # Path expression 1061 | matrix_managers: Annotated[ 1062 | list["Person"], WithPredicate(EX.department / EX.manager | EX.project / EX.leader) # type: ignore 1063 | ] # Alternative paths 1064 | 1065 | class Person(BaseRdfModel): 1066 | rdf_type = EX.Person 1067 | _rdf_namespace = EX 1068 | name: str 1069 | 1070 | Organization.model_rebuild() 1071 | Person.model_rebuild() 1072 | 1073 | # Create a complex organizational structure 1074 | org = EX.org1 1075 | dept1 = BNode() 1076 | dept2 = BNode() 1077 | proj1 = BNode() 1078 | 1079 | # Add basic org data 1080 | graph.add((org, RDF.type, EX.Organization)) 1081 | graph.add((org, EX.name, Literal("Test Org"))) 1082 | 1083 | # Add departments and employees 1084 | graph.add((org, EX.department, dept1)) 1085 | graph.add((org, EX.department, dept2)) 1086 | graph.add((org, EX.project, proj1)) 1087 | 1088 | # Add people 1089 | for i, uri in enumerate([EX.person1, EX.person2, EX.person3]): 1090 | graph.add((uri, RDF.type, EX.Person)) 1091 | graph.add((uri, EX.name, Literal(f"Person {i}"))) 1092 | 1093 | # Add relationships 1094 | graph.add((dept1, EX.employee, EX.person1)) 1095 | graph.add((dept2, EX.employee, EX.person2)) 1096 | graph.add((dept1, EX.manager, EX.person3)) 1097 | graph.add((proj1, EX.leader, EX.person2)) 1098 | 1099 | # Try to parse with property paths 1100 | org = Organization.parse_graph(graph, org) 1101 | 1102 | # Should find all employees through departments 1103 | assert len(org.all_employees) == 2 1104 | 1105 | # Should find all managers/leaders through either departments or projects 1106 | assert len(org.matrix_managers) == 2 1107 | --------------------------------------------------------------------------------