pax_global_header 0000666 0000000 0000000 00000000064 14613034133 0014510 g ustar 00root root 0000000 0000000 52 comment=6095d9dc710a8901a4e0b7be92f59486576a2c81
python-odmantic-1.0.2/ 0000775 0000000 0000000 00000000000 14613034133 0014625 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/.codecov.yml 0000664 0000000 0000000 00000000104 14613034133 0017043 0 ustar 00root root 0000000 0000000 codecov:
require_ci_to_pass: yes
notify:
after_n_builds: 14
python-odmantic-1.0.2/.darglint 0000664 0000000 0000000 00000000144 14613034133 0016431 0 ustar 00root root 0000000 0000000 [darglint]
# Allow one line docstrings without arg spec
strictness = short
docstring_style = google
python-odmantic-1.0.2/.devcontainer/ 0000775 0000000 0000000 00000000000 14613034133 0017364 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/.devcontainer/Dockerfile 0000664 0000000 0000000 00000001017 14613034133 0021355 0 ustar 00root root 0000000 0000000 FROM mcr.microsoft.com/devcontainers/python:3.8
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
netcat-openbsd \
git-lfs \
&& apt-get clean autoclean \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/* \
&& rm -f /var/cache/apt/archives/*.deb
# Install task
RUN curl -sL https://taskfile.dev/install.sh | sh
ENV PATH /root/.bin/:/root/.local/bin/:${PATH}
# Install devtools
RUN python3.8 -m pip install flit tox pre-commit
# Allow flit install as root
ENV FLIT_ROOT_INSTALL 1
python-odmantic-1.0.2/.devcontainer/devcontainer.json 0000664 0000000 0000000 00000002057 14613034133 0022744 0 ustar 00root root 0000000 0000000 // Update the VARIANT arg in docker-compose.yml to pick a Node.js version: 10, 12, 14
{
"name": "Python3 & Mongo DB",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
// Set *default* container specific settings.json values on container create.
"settings": {
"terminal.integrated.shell.linux": "/bin/bash"
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"njpwerner.autodocstring",
"ryanluker.vscode-coverage-gutters",
"ms-python.python",
"ms-python.vscode-pylance",
"littlefoxteam.vscode-python-test-adapter",
"hbenl.vscode-test-explorer"
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [8000, 8080, 27017],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "bash -i -c 'task setup'"
// Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
// "remoteUser": "node"
}
python-odmantic-1.0.2/.devcontainer/docker-compose.yml 0000664 0000000 0000000 00000001003 14613034133 0023013 0 ustar 00root root 0000000 0000000 version: "3"
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- ..:/workspace:cached
# Overrides default command so things don't shut down after the process ends.
command: sleep infinity
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
network_mode: service:db
db:
image: mongo:latest
restart: unless-stopped
volumes:
- mongodb-data:/data/db
volumes:
mongodb-data:
python-odmantic-1.0.2/.gitattributes 0000664 0000000 0000000 00000000052 14613034133 0017515 0 ustar 00root root 0000000 0000000 *.png filter=lfs diff=lfs merge=lfs -text
python-odmantic-1.0.2/.github/ 0000775 0000000 0000000 00000000000 14613034133 0016165 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/.github/ISSUE_TEMPLATE/ 0000775 0000000 0000000 00000000000 14613034133 0020350 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/.github/ISSUE_TEMPLATE/bug.md 0000664 0000000 0000000 00000001137 14613034133 0021451 0 ustar 00root root 0000000 0000000 ---
name: Bug
about: Create a bug report to help the project
labels: bug
---
# Bug
_A clear and concise description of what the bug is._
### Current Behavior
... _Steps to reproduce the bug_ ...
### Expected behavior
... _A clear and concise description of what you expected to happen._ ...
### Environment
- ODMantic version: ...
- MongoDB version: ...
- Pydantic infos (output of `python -c "import pydantic.utils; print(pydantic.utils.version_info())`):
```
...
```
- Version of additional modules (if relevant):
- ...
**Additional context**
_Add any other context about the problem here._
python-odmantic-1.0.2/.github/ISSUE_TEMPLATE/feature.md 0000664 0000000 0000000 00000001226 14613034133 0022326 0 ustar 00root root 0000000 0000000 ---
name: Feature request
about: Suggest a new idea for the project
labels: enhancement
---
# Feature request
### Context
_Is your feature request related to a problem? Please describe with a clear and concise description of what the problem is. Ex. I'm always frustrated when ..._
### Solution
_Describe the solution you'd like with a clear and concise description of what you want to happen._
#### Alternative solutions
_Describe alternatives you've considered with clear and concise description of any alternative solutions or features you've considered._
### Additional context
_Add any other context or screenshots about the feature request here._
python-odmantic-1.0.2/.github/Taskfile.yml 0000664 0000000 0000000 00000002255 14613034133 0020456 0 ustar 00root root 0000000 0000000 version: "3"
silent: false
vars:
VERSION_FILE: ./__version__.txt
RELEASE_NOTE_FILE: ./__release_notes__.md
RELEASE_BRANCH: "master"
CURRENT_BRANCH:
sh: git rev-parse --symbolic-full-name --abbrev-ref HEAD
RELEASE_COMMIT_FILES: "pyproject.toml CHANGELOG.md"
tasks:
default:
preconditions:
- sh: which gh
msg: gh not found
- sh: "[ {{.CURRENT_BRANCH}} = {{.RELEASE_BRANCH}} ]"
msg: "Please switch to {{.RELEASE_BRANCH}} to create a release"
cmds:
- task: prepare-workspace
- task: publish-release
- task: clean
prepare-workspace:
cmds:
- python .github/release.py
publish-release:
vars:
RELEASE_NOTE:
sh: cat {{.RELEASE_NOTE_FILE}}
NEW_VERSION:
sh: cat {{.VERSION_FILE}}
cmds:
- git add {{.RELEASE_COMMIT_FILES}}
- git commit -m "Release {{.NEW_VERSION}} 🚀"
- git push
- git tag v{{.NEW_VERSION}}
- git push --tags
- gh release create -d --target {{.RELEASE_BRANCH}} --title v{{.NEW_VERSION}} --notes-file {{.RELEASE_NOTE_FILE}} v{{.NEW_VERSION}}
clean:
cmds:
- rm -f {{.VERSION_FILE}}
- rm -f {{.RELEASE_NOTE_FILE}}
python-odmantic-1.0.2/.github/dependabot.yml 0000664 0000000 0000000 00000000307 14613034133 0021015 0 ustar 00root root 0000000 0000000 version: 2
updates:
- package-ecosystem: pip
directory: "/"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
commit-message:
prefix: "⬆️"
python-odmantic-1.0.2/.github/latest-changes.jinja2 0000664 0000000 0000000 00000000143 14613034133 0022164 0 ustar 00root root 0000000 0000000 - {{pr.title}} ([#{{pr.number}}]({{pr.html_url}}) by [@{{pr.user.login}}]({{pr.user.html_url}}))
python-odmantic-1.0.2/.github/release.py 0000664 0000000 0000000 00000011523 14613034133 0020161 0 ustar 00root root 0000000 0000000 import datetime
import importlib.metadata
import os
from enum import Enum
import typer
from click.types import Choice
from semver import VersionInfo
class BumpType(str, Enum):
major = "major"
minor = "minor"
patch = "patch"
def get_current_version() -> VersionInfo:
version = importlib.metadata.version("odmantic")
return VersionInfo.parse(version)
def get_new_version(current_version: VersionInfo, bump_type: BumpType) -> VersionInfo:
if bump_type == BumpType.major:
return current_version.bump_major()
if bump_type == BumpType.minor:
return current_version.bump_minor()
if bump_type == BumpType.patch:
return current_version.bump_patch()
raise NotImplementedError("Unhandled bump type")
PYPROJECT_PATH = "./pyproject.toml"
def update_pyproject(current_version: VersionInfo, new_version: VersionInfo) -> None:
with open(PYPROJECT_PATH) as f:
content = f.read()
new_content = content.replace(
f'version = "{current_version}"', f'version = "{new_version}"'
)
if content == new_content:
typer.secho("Couldn't bump version in pyproject.toml", fg=typer.colors.RED)
raise typer.Exit(1)
with open(PYPROJECT_PATH, "w") as f:
f.write(new_content)
typer.secho("Version updated with success", fg=typer.colors.GREEN)
RELEASE_NOTE_PATH = "./__release_notes__.md"
def get_release_notes() -> str:
with open("./CHANGELOG.md", "r") as f:
while not f.readline().strip() == "## [Unreleased]":
pass
content = ""
while not (line := f.readline().strip()).startswith("## "):
content = content + line + "\n"
return content
def save_release_notes(release_notes: str) -> None:
if os.path.exists(RELEASE_NOTE_PATH):
typer.secho(
f"Release note file {RELEASE_NOTE_PATH} already exists", fg=typer.colors.RED
)
raise typer.Exit(1)
with open(RELEASE_NOTE_PATH, "w") as f:
f.write(release_notes)
typer.secho("Release note file generated with success", fg=typer.colors.GREEN)
CHANGELOG_PATH = "./CHANGELOG.md"
def update_changelog(current_version: VersionInfo, new_version: VersionInfo) -> None:
today = datetime.date.today()
date_str = f"{today.year}-{today.month:02d}-{today.day:02d}"
with open(CHANGELOG_PATH, "r") as f:
content = f.read()
# Add version header
content = content.replace(
"## [Unreleased]", ("## [Unreleased]\n\n" f"## [{new_version}] - {date_str}")
)
# Add version links
content = content.replace(
f"[unreleased]: https://github.com/art049/odmantic/compare/v{current_version}...HEAD",
(
f"[{new_version}]: https://github.com/art049/odmantic/compare/v{current_version}...v{new_version}\n"
f"[unreleased]: https://github.com/art049/odmantic/compare/v{new_version}...HEAD"
),
)
with open(CHANGELOG_PATH, "w") as f:
f.write(content)
typer.secho("Changelog updated with success", fg=typer.colors.GREEN)
VERSION_FILE_PATH = "__version__.txt"
def create_version_file(new_version: VersionInfo) -> None:
if os.path.exists(VERSION_FILE_PATH):
typer.secho(
f"Version file {VERSION_FILE_PATH} already exists", fg=typer.colors.RED
)
raise typer.Exit(1)
with open(VERSION_FILE_PATH, "w") as f:
f.write(str(new_version))
def summarize(
current_version: VersionInfo,
new_version: VersionInfo,
bump_type: BumpType,
release_notes: str,
) -> None:
typer.secho("Release summary:", fg=typer.colors.BLUE, bold=True)
typer.secho(f" Version bump: {bump_type.upper()}", bold=True)
typer.secho(f" Version change: {current_version} -> {new_version}", bold=True)
typer.confirm("Continue to release notes preview ?", abort=True, default=True)
release_header = typer.style(
f"RELEASE NOTE {new_version}\n\n", fg=typer.colors.BLUE, bold=True
)
typer.echo_via_pager(release_header + release_notes)
typer.confirm("Continue ?", abort=True, default=True)
def main() -> None:
current_version = get_current_version()
typer.secho(f"Current version: {current_version}", bold=True)
bump_type: BumpType = typer.prompt(
typer.style("Release type ?", fg=typer.colors.BLUE, bold=True),
type=Choice(list(BumpType.__members__)),
default=BumpType.patch,
show_choices=True,
)
new_version = get_new_version(current_version, bump_type)
release_notes = get_release_notes()
summarize(current_version, new_version, bump_type, release_notes)
save_release_notes(release_notes)
update_pyproject(current_version, new_version)
update_changelog(current_version, new_version)
create_version_file(new_version)
typer.confirm("Additionnal release commit files staged ?", abort=True, default=True)
if __name__ == "__main__":
typer.run(main)
python-odmantic-1.0.2/.github/workflows/ 0000775 0000000 0000000 00000000000 14613034133 0020222 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/.github/workflows/ci.yml 0000664 0000000 0000000 00000013172 14613034133 0021344 0 ustar 00root root 0000000 0000000 name: build
on:
push:
branches: [master]
pull_request:
branches: [master]
schedule:
- cron: "0 2 * * *"
jobs:
static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.8"
- uses: pre-commit/action@v2.0.0
with:
extra_args: --all-files
compatibility-tests:
runs-on: ubuntu-latest
continue-on-error: true
strategy:
matrix:
python-version:
- "3.8"
- "3.9"
- "3.10"
- "3.11"
#- "3.12" # FIXME: async-asgi-testclient doesn't support Python 3.12 yet
pydantic-version:
- "2.5.2"
motor-version:
- "3.1.1"
- "3.2.0"
- "3.3.2"
steps:
- uses: actions/checkout@v4
- name: Mongo Service
id: mongo-service
uses: art049/mongodb-cluster-action@v0
with:
version: "4.4"
mode: standalone
- name: "Set up Python ${{ matrix.python-version }}"
uses: actions/setup-python@v5
with:
python-version: "${{ matrix.python-version }}"
- name: Cache environment
uses: actions/cache@v2
id: cache
with:
path: ${{ env.pythonLocation }}
key: env-compatibility-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.pydantic-version }}-${{ matrix.motor-version }}-${{ hashFiles('pyproject.toml') }}
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: |
pip install ".[test]"
pip install "pydantic==${{ matrix.pydantic-version }}" "motor==${{ matrix.motor-version }}"
- name: Run compatibility checks.
run: |
python -c "import motor; print(motor.version)" 1>&2
python -c "import pydantic; print(pydantic.VERSION)" 1>&2
python -m pytest -q -rs
tests:
runs-on: ubuntu-latest
continue-on-error: true
strategy:
matrix:
python-version:
- "3.8"
- "3.9"
- "3.10"
- "3.11"
mongo-version:
- "4.4"
- "5"
- "6"
mongo-mode:
- standalone
include:
- python-version: 3.11
mongo-version: 4.0
mongo-mode: replicaSet
- python-version: 3.11
mongo-version: 4.2
mongo-mode: sharded
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: "pyproject.toml"
- name: Mongo Service
id: mongo-service
uses: art049/mongodb-cluster-action@v0
with:
version: ${{ matrix.mongo-version }}
mode: ${{ matrix.mongo-mode }}
- name: Install dependencies
run: |
pip install flit
pip install ".[test]"
- name: Run all tests
run: |
set -e
coverage run -m pytest -v
coverage report -m
coverage xml
env:
TEST_MONGO_URI: ${{ steps.mongo-service.outputs.connection-string }}
TEST_MONGO_MODE: ${{ matrix.mongo-mode }}
- uses: codecov/codecov-action@v3
if: github.event_name != 'schedule' # Don't report coverage for nightly builds
with:
file: ./coverage.xml
flags: tests-${{ matrix.python-version }}-${{ matrix.mongo-version }}-${{ matrix.mongo-mode }}
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
integrated-realworld-test:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
path: odmantic-current
- uses: actions/checkout@v4
with:
repository: art049/fastapi-odmantic-realworld-example
submodules: recursive
path: fastapi-odmantic-realworld-example
- name: Install poetry and flit
run: |
pipx install poetry
pipx install flit
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "poetry"
- name: Install dependencies (w/o ODMantic)
working-directory: fastapi-odmantic-realworld-example
run: |
echo "$(grep -v 'odmantic =' ./pyproject.toml)" > pyproject.toml
poetry lock --no-update
poetry install
- name: Install current ODMantic version
working-directory: fastapi-odmantic-realworld-example
run: poetry run pip install ../odmantic-current/
- name: Start the MongoDB instance
uses: art049/mongodb-cluster-action@v0
id: mongodb-cluster-action
- name: Start the FastAPI server
working-directory: fastapi-odmantic-realworld-example
run: |
./scripts/start.sh &
# Wait for the server
while ! curl "http://localhost:8000/health" > /dev/null 2>&1
do
sleep 1;
done
echo "Server ready."
env:
MONGO_URI: ${{ steps.mongodb-cluster-action.outputs.connection-string }}
- name: Run realworld backend tests
working-directory: fastapi-odmantic-realworld-example
run: ./realworld/api/run-api-tests.sh
env:
APIURL: http://localhost:8000
all-ci-checks:
needs:
- static-analysis
- compatibility-tests
- tests
- integrated-realworld-test
runs-on: ubuntu-latest
steps:
- run: echo "All CI checks passed."
python-odmantic-1.0.2/.github/workflows/codspeed.yml 0000664 0000000 0000000 00000002122 14613034133 0022530 0 ustar 00root root 0000000 0000000 name: CodSpeed
on:
# Run on pushes to the main branch
push:
branches:
- "master" # or "main"
# Run on pull requests
pull_request:
# `workflow_dispatch` allows CodSpeed to trigger backtest
# performance analysis in order to generate initial data.
workflow_dispatch:
jobs:
benchmarks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.12
cache: pip
cache-dependency-path: "pyproject.toml"
- name: Mongo Service
id: mongo-service
uses: art049/mongodb-cluster-action@v0
with:
version: "4.2"
mode: "sharded"
- name: Install dependencies
run: |
pip install flit
pip install ".[test]"
- name: Run benches
uses: CodSpeedHQ/action@v2
with:
run: pytest tests/integration/benchmarks --codspeed
env:
TEST_MONGO_URI: ${{ steps.mongo-service.outputs.connection-string }}
TEST_MONGO_MODE: "sharded"
python-odmantic-1.0.2/.github/workflows/docs-preview.yml 0000664 0000000 0000000 00000002302 14613034133 0023351 0 ustar 00root root 0000000 0000000 name: docs-preview
on:
- pull_request
jobs:
deploy-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
lfs: true
- name: Set up Python 3.8
uses: actions/setup-python@v2
with:
python-version: 3.8
cache: pip
cache-dependency-path: "pyproject.toml"
- name: Install dependencies
run: |
pip install flit
pip install ".[doc]"
- name: Build documentation
run: mkdocs build -f ./mkdocs.yml
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@v1.1
id: deployment
with:
publish-dir: "./site"
production-branch: master
github-token: ${{ secrets.GITHUB_TOKEN }}
deploy-message: "#${{ github.event.number }}: ${{ github.event.pull_request.title }}"
enable-pull-request-comment: true
enable-commit-comment: false
overwrites-pull-request-comment: true
alias: docs-preview-${{ github.event.number }}
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
timeout-minutes: 1
python-odmantic-1.0.2/.github/workflows/docs.yml 0000664 0000000 0000000 00000001376 14613034133 0021704 0 ustar 00root root 0000000 0000000 name: docs
on:
release:
types:
- published
- released
- edited
workflow_dispatch:
jobs:
deploy-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
lfs: true
- name: Set up Python 3.8
uses: actions/setup-python@v2
with:
python-version: 3.8
cache: pip
cache-dependency-path: "pyproject.toml"
- name: Install dependencies
run: |
pip install flit
pip install ".[doc]"
- name: Build documentation
run: mkdocs build -f ./mkdocs.yml
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./site
python-odmantic-1.0.2/.github/workflows/latest-changes.yml 0000664 0000000 0000000 00000001320 14613034133 0023643 0 ustar 00root root 0000000 0000000 name: latest-changes
on:
pull_request_target:
branches:
- master
types:
- closed
workflow_dispatch:
inputs:
number:
description: PR number
required: true
jobs:
latest-changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
token: ${{ secrets.GH_W_TOKEN }}
- name: Disable LFS hooks
run: rm .git/hooks/post-commit .git/hooks/pre-push
- uses: docker://tiangolo/latest-changes:0.0.3
with:
token: ${{ secrets.GH_W_TOKEN }}
template_file: ./.github/latest-changes.jinja2
latest_changes_file: ./CHANGELOG.md
latest_changes_header: '## \[Unreleased\]\n\n'
python-odmantic-1.0.2/.github/workflows/release.yml 0000664 0000000 0000000 00000001025 14613034133 0022363 0 ustar 00root root 0000000 0000000 name: Release
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
main:
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install dependencies
run: pip install flit
- name: Build the package
run: flit build
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
python-odmantic-1.0.2/.gitignore 0000664 0000000 0000000 00000002364 14613034133 0016622 0 ustar 00root root 0000000 0000000 # Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
poetry.lock
.task
__release_notes__.md
__version__.txt
.testmondata
python-odmantic-1.0.2/.gitmodules 0000664 0000000 0000000 00000000202 14613034133 0016774 0 ustar 00root root 0000000 0000000 [submodule ".mongodb-cluster-action"]
path = .mongodb-cluster-action
url = https://github.com/art049/mongodb-cluster-action.git
python-odmantic-1.0.2/.mongodb-cluster-action/ 0000775 0000000 0000000 00000000000 14613034133 0021262 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/.pre-commit-config.yaml 0000664 0000000 0000000 00000002741 14613034133 0021112 0 ustar 00root root 0000000 0000000 # See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
default_language_version:
python: python3.8
node: 15.4.0
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
exclude: "^.github/latest-changes.jinja2"
- id: check-yaml
exclude: "^mkdocs.yml"
- id: check-added-large-files
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.2.1
hooks:
- id: prettier
exclude: "^docs/.*"
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.4.1
hooks:
- id: mypy
exclude: "^docs/.*"
additional_dependencies:
- pydantic>=2.0.0
- motor~=3.0.0
- types-pytz~=2022.1.1
args: [--no-pretty, --show-error-codes]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.3
hooks:
- id: ruff
exclude: "^docs/.*|.github/release.py"
- id: ruff-format
exclude: "^docs/.*|.github/release.py"
- repo: https://github.com/pycqa/pydocstyle
rev: 6.1.1 # pick a git hash / tag to point to
hooks:
- id: pydocstyle
files: "^odmantic/"
additional_dependencies:
- toml
- repo: https://github.com/terrencepreilly/darglint
rev: v1.8.1
hooks:
- id: darglint
files: "^odmantic/"
stages: [] # Only run in CI with --all since it's slow
python-odmantic-1.0.2/.prettierignore 0000664 0000000 0000000 00000000032 14613034133 0017663 0 ustar 00root root 0000000 0000000 docs/**/*.md
CHANGELOG.md
python-odmantic-1.0.2/.vscode/ 0000775 0000000 0000000 00000000000 14613034133 0016166 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/.vscode/extensions.json 0000664 0000000 0000000 00000000412 14613034133 0021255 0 ustar 00root root 0000000 0000000 {
"recommendations": [
"njpwerner.autodocstring",
"ryanluker.vscode-coverage-gutters",
"ms-python.python",
"ms-python.vscode-pylance",
"littlefoxteam.vscode-python-test-adapter",
"hbenl.vscode-test-explorer",
"charliermarsh.ruff"
]
}
python-odmantic-1.0.2/.vscode/launch.json 0000664 0000000 0000000 00000001054 14613034133 0020333 0 ustar 00root root 0000000 0000000 {
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Current File",
"type": "python",
"request": "launch",
"program": "${file}",
"console": "internalConsole"
},
{
"name": "Debug Tests",
"type": "python",
"request": "test",
"console": "internalConsole",
"justMyCode": false
}
]
}
python-odmantic-1.0.2/.vscode/settings.json 0000664 0000000 0000000 00000000764 14613034133 0020730 0 ustar 00root root 0000000 0000000 {
"editor.rulers": [88],
"python.envFile": "${workspaceFolder}/.env",
"python.pythonPath": "${workspaceFolder}/.venv/bin/python3.8",
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
},
"python.testing.pytestEnabled": true,
"editor.formatOnSave": true,
"files.exclude": {
".venv/": false,
".pytest_cache/": true,
".mypy_cache/": true
},
"python.languageServer": "Pylance",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff"
}
}
python-odmantic-1.0.2/CHANGELOG.md 0000664 0000000 0000000 00000052757 14613034133 0016456 0 ustar 00root root 0000000 0000000 # Changelog
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.0.2] - 2024-04-26
### Fixed
- fix: support pydantic 2.7 ([#462](https://github.com/art049/odmantic/pull/462) by [@adriencaccia](https://github.com/adriencaccia))
### Internals
- chore(bench): update CodSpeed/action to v2 ([#461](https://github.com/art049/odmantic/pull/461) by [@adriencaccia](https://github.com/adriencaccia))
- Fix dev container environment ([#438](https://github.com/art049/odmantic/pull/438) by [@Kludex](https://github.com/Kludex) and [@art049](https://github.com/art049))
## [1.0.1] - 2024-03-18
### Fixed
- Optional and Union generic types definition issues ([#416](https://github.com/art049/odmantic/pull/416) by [@netomi](https://github.com/netomi))
- Remove continuously changing example for DateTime objects ([#406](https://github.com/art049/odmantic/pull/406) by [@Mokto](https://github.com/Mokto))
### Added
- Support the `examples` property in Field descriptors ([#404](https://github.com/art049/odmantic/pull/404) by [@Mokto](https://github.com/Mokto))
### Internals
- Fix Pydantic docs URLs ([#366](https://github.com/art049/odmantic/pull/366) by [@aminalaee](https://github.com/aminalaee))
- Add a test with a model when defining multiple optional fields ([#426](https://github.com/art049/odmantic/pull/426) by [@art049](https://github.com/art049))
- Bump ruff and use ruff format ([#425](https://github.com/art049/odmantic/pull/425) by [@art049](https://github.com/art049))
## [1.0.0] - 2023-12-13
I'm excited to announce ODMantic v1.0.0, with Pydantic v2 support! 🎉
This release brings a range of changes that are aligned with the new Pydantic architecture.
Keeping a maintainable and healthy codebase was especially important.
Thus from now on, ODMantic will not support Python 3.7, Pydantic V1, and Motor 2.x anymore.
Overall, integrating with Pydantic v2 brings around **30% performance improvements** on
common operations. ⚡️⚡️⚡️
We have a lot of room to improve the performance further now that we only support Pydantic v2.
There is also a 300% 👀 improvement on the bulk saves crafted by @tiangolo that will be merged soon! 🚀
Special thanks to @tiangolo for his help on this release and for saving me a lot of time
figuring out a particularly annoying bug!
Check out the **[migration guide](https://art049.github.io/odmantic/migration_guide/)** to
upgrade your codebase and enjoy this new release!
### Breaking changes
- Support for Python 3.7, Pydantic v1 and Motor 2.x has been dropped
- `Optional[T]` doesn't have a `None` implicit default value anymore
- `Model.copy` doesn't support the `exclude` and `include` kwargs anymore
- `odmantic.Field` doesn't accept extra kwargs anymore since it's slated to be removed in Pydantic
- The `Config` class is no longer supported and the `model_config` dict should be used instead
- `DocumentParsingError` is no longer a subclass of `ValidationError`
- The `__bson__` class method is no longer supported to define BSON encoders on custom types. The new method to customize BSON encoding is to use the `WithBSONSerializer` annotation.
- Decimals (`decimal.Decimal` and `bson.Decimal128`) are now serialized as strings in JSON documents
- Custom JSON encoders(defined with the `json_encoders` config option) are no longer effective on `odmantic.bson` types. Annotated types with `pydantic.PlainSerializer` should be used instead.
### Removals
- `AIOEngineDependency` has been removed since it was deprecated in v0.2.0 in favor of a global engine object
- Defining the collection with `__collection__` has been removed since it was deprecated in v0.3.0 in favor of the `collection` config option
### Deprecations
_We comply with the new Pydantic method naming, prefixing them with `model_`_
- `Model.dict` has been deprecated in favor of `Model.model_dump`
- `Model.doc` has been deprecated in favor of `Model.model_dump_doc`
- `Model.parse_doc` has been deprecated in favor of `Model.model_validate_doc`
- `Model.update` has been deprecated in favor of `Model.model_update`
- `Model.copy` has been deprecated in favor of `Model.model_copy`
---
#### Details
- Integrate with Pydantic V2([#361](https://github.com/art049/odmantic/pull/361) and [#377](https://github.com/art049/odmantic/pull/377) by [@art049](https://github.com/art049))
- Update CI to use GitHub Actions matrix instead of tox, upgrade minimum Pydantic to 1.10.8 as needed by the tests ([#376](https://github.com/art049/odmantic/pull/376) by [@tiangolo](https://github.com/tiangolo))
- Add benchmarks on common sync operations ([#362](https://github.com/art049/odmantic/pull/362) by [@art049](https://github.com/art049))
## [0.9.2] - 2023-01-03
### Fixed
- Properly handle literals among generic types ([#313](https://github.com/art049/odmantic/pull/313) by [@art049](https://github.com/art049))
### Internals
- Pin tox to fix CI ([#308](https://github.com/art049/odmantic/pull/308) by [@tiangolo](https://github.com/tiangolo))
## [0.9.1] - 2022-11-24
### Fixed
- Bump motor version ([#296](https://github.com/art049/odmantic/pull/296) by [@valeriiduz](https://github.com/valeriiduz))
### Internals
- Migrate to ruff ([#299](https://github.com/art049/odmantic/pull/299) by [@art049](https://github.com/art049))
## [0.9.0] - 2022-09-25
#### Added
- Create new generic types to support generic collection types ([#240](https://github.com/art049/odmantic/pull/240) by [@erny](https://github.com/erny) & [@art049](https://github.com/art049))
Thus, it's now possible to define models like this in python **3.9+** 🚀:
```python
class User(Model):
scopes: list[str]
friendsIds: list[ObjectId]
skills: set[str]
```
- Allow using generators with `in_` and `not_in` ([#270](https://github.com/art049/odmantic/pull/270) by [@art049](https://github.com/art049))
#### Fixed
- Fix `EmbeddedModel` generics definition with a custom `key_name` ([#269](https://github.com/art049/odmantic/pull/269) by [@art049](https://github.com/art049))
- Raise a `TypeError` when defining a `Reference` in a generic(List, Dict, Tuple, ...) containing EmbeddedModels ([#269](https://github.com/art049/odmantic/pull/269) by [@art049](https://github.com/art049))
## [0.8.0] - 2022-09-09
#### Added
- Allow Index definition ([feature documentation](https://art049.github.io/odmantic/modeling/#indexes)) ([#255](https://github.com/art049/odmantic/pull/255) by [@art049](https://github.com/art049))
- Allow using the `Config.extra` attribute from pydantic ([#259](https://github.com/art049/odmantic/pull/259) by [@art049](https://github.com/art049))
#### Fixed
- Fix embedded models parsing with custom `key_name` ([#262](https://github.com/art049/odmantic/pull/262) by [@iXB3](https://github.com/iXB3))
- Fix `engine.save` using an embedded model as a primary key ([#258](https://github.com/art049/odmantic/pull/258) by [@art049](https://github.com/art049))
- Fix engine creation typo in the documentation ([#257](https://github.com/art049/odmantic/pull/257) by [@art049](https://github.com/art049))
## [0.7.1] - 2022-09-02
#### Fixed
- Fix dataclass transform constructor type hints ([#249](https://github.com/art049/odmantic/pull/249) by [@art049](https://github.com/art049))
#### Internals
- Update Mongo version in the CI build matrix ([#247](https://github.com/art049/odmantic/pull/247) by [@art049](https://github.com/art049))
## [0.7.0] - 2022-08-30
#### Added
- Add new SyncEngine, support async and sync code ([#231](https://github.com/art049/odmantic/pull/231) by [@tiangolo](https://github.com/tiangolo))
- Sync engine documentation ([#238](https://github.com/art049/odmantic/pull/238) by [@art049](https://github.com/art049))
- Friendly interface for session and transactions ([#244](https://github.com/art049/odmantic/pull/244) by [@art049](https://github.com/art049))
- Implement the `engine.remove` method to allow instance deletion from a query ([#147](https://github.com/art049/odmantic/pull/147) & [#237](https://github.com/art049/odmantic/pull/237) by [@joeriddles](https://github.com/joeriddles) & [@art049](https://github.com/art049))
#### Internals
- Remove unnecessary Python 3.6 type fixes ([#243](https://github.com/art049/odmantic/pull/243) by [@art049](https://github.com/art049))
- Switch Mongo action to art049/mongodb-cluster-action ([#245](https://github.com/art049/odmantic/pull/245) by [@art049](https://github.com/art049))
- Add Realworld API integrated test ([#246](https://github.com/art049/odmantic/pull/246) by [@art049](https://github.com/art049))
## [0.6.0] - 2022-08-24
#### Breaking Changes
- Drop support for Python 3.6 ([#230](https://github.com/art049/odmantic/pull/230) by [@tiangolo](https://github.com/tiangolo))
#### Added
- Upgrade types and add support for instance autocompletion with `dataclass_transform` (VS Code autocompletion) ([#230](https://github.com/art049/odmantic/pull/230) by [@tiangolo](https://github.com/tiangolo))
- Support Python 3.10 ([#235](https://github.com/art049/odmantic/pull/235) by [@art049](https://github.com/art049))
#### Fixed
- Fix using the shared session when updating a document ([#227](https://github.com/art049/odmantic/pull/227) by [@tiangolo](https://github.com/tiangolo))
- Fix `EmbeddedModel` deep copy mutability ([#239](https://github.com/art049/odmantic/pull/239) by [@art049](https://github.com/art049))
- Allow models to contain string-based datetime fields that indicate UTC ([#136](https://github.com/art049/odmantic/pull/136) by [@kfox](https://github.com/kfox))
- Fix `Reference` usage with non the non default primary key ([#184](https://github.com/art049/odmantic/pull/184) by [@dynalz](https://github.com/dynalz))
- Fix `key_name` use on EmbeddedModels ([#195](https://github.com/art049/odmantic/pull/195) by [@jvanegmond](https://github.com/jvanegmond))
#### Internals
- Support Python 3.10 in tox ([#236](https://github.com/art049/odmantic/pull/236) by [@art049](https://github.com/art049))
- Fix missing f string in an exception message ([#222](https://github.com/art049/odmantic/pull/222) by [@voglster](https://github.com/voglster))
- Finalize flit migration ([#232](https://github.com/art049/odmantic/pull/232) by [@art049](https://github.com/art049))
## [0.5.0] - 2022-06-01
- Support motor 3.0 ([#224](https://github.com/art049/odmantic/pull/224) by [@art049](https://github.com/art049))
- Support pydantic 1.9.0 ([#218](https://github.com/art049/odmantic/pull/218) by [@art049](https://github.com/art049))
## [0.4.0] - 2022-04-23
#### Added
- Update and copy methods:
- Updating multiple fields at once is now directly possible from a pydantic model or
a dictionary ([feature documentation](https://art049.github.io/odmantic/engine/#patching-multiple-fields-at-once),
[sample use case with FastAPI](https://art049.github.io/odmantic/usage_fastapi/#updating-a-tree))
- Changing the primary field of an instance is now easier
([documentation](https://art049.github.io/odmantic/engine/#changing-the-primary-field))
- Patch and copy Model instances ([#39](https://github.com/art049/odmantic/pull/39) by [@art049](https://github.com/art049))
#### Fixed
- Update example in README ([#192](https://github.com/art049/odmantic/pull/192) by [@jasper-moment](https://github.com/jasper-moment))
- Update README.md ([#129](https://github.com/art049/odmantic/pull/129) by [@Kludex](https://github.com/Kludex))
#### Internals
- ⬆️ Update motor requirement from >=2.1.0,<2.5.0 to >=2.1.0,<2.6.0 ([#160](https://github.com/art049/odmantic/pull/160) by [@dependabot[bot]](https://github.com/apps/dependabot))
- ⬆️ Update typer requirement from ^0.3.2 to ^0.4.1 ([#214](https://github.com/art049/odmantic/pull/214) by [@dependabot[bot]](https://github.com/apps/dependabot))
- ⬆️ Update mypy requirement from ^0.910 to ^0.942 ([#215](https://github.com/art049/odmantic/pull/215) by [@dependabot[bot]](https://github.com/apps/dependabot))
- ⬆️ Update fastapi requirement from >=0.61.1,<0.67.0 to >=0.61.1,<0.69.0 ([#166](https://github.com/art049/odmantic/pull/166) by [@dependabot[bot]](https://github.com/apps/dependabot))
- ⬆️ Update fastapi requirement from >=0.61.1,<0.64.0 to >=0.61.1,<0.67.0 ([#150](https://github.com/art049/odmantic/pull/150) by [@dependabot[bot]](https://github.com/apps/dependabot))
- ⬆️ Update mypy requirement from ^0.812 to ^0.910 ([#142](https://github.com/art049/odmantic/pull/142) by [@dependabot[bot]](https://github.com/apps/dependabot))
- ⬆️ Update pytest-asyncio requirement from ^0.14.0 to ^0.15.0 ([#125](https://github.com/art049/odmantic/pull/125) by [@dependabot[bot]](https://github.com/apps/dependabot))
- ⬆️ Update motor requirement from >=2.1.0,<2.4.0 to >=2.1.0,<2.5.0 ([#124](https://github.com/art049/odmantic/pull/124) by [@dependabot[bot]](https://github.com/apps/dependabot))
- ⬆️ Update importlib-metadata requirement from >=1,<4 to >=1,<5 ([#126](https://github.com/art049/odmantic/pull/126) by [@dependabot[bot]](https://github.com/apps/dependabot))
- ⬆️ Update pydocstyle requirement from ^5.1.1 to ^6.0.0 ([#119](https://github.com/art049/odmantic/pull/119) by [@dependabot[bot]](https://github.com/apps/dependabot))
- ⬆️ Update isort requirement from ~=5.7.0 to ~=5.8.0 ([#122](https://github.com/art049/odmantic/pull/122) by [@dependabot[bot]](https://github.com/apps/dependabot))
- ⬆️ Update flake8 requirement from ~=3.8.4 to ~=3.9.0 ([#116](https://github.com/art049/odmantic/pull/116) by [@dependabot[bot]](https://github.com/apps/dependabot))
## [0.3.5] - 2021-05-12
#### Security
- Change allowed pydantic versions to handle [CVE-2021-29510](https://github.com/samuelcolvin/pydantic/security/advisories/GHSA-5jqp-qgf6-3pvh) by [@art049](https://github.com/art049)
## [0.3.4] - 2021-03-04
#### Fixed
- Fix modified mark clearing on save for nested models ([#88](https://github.com/art049/odmantic/pull/88) by [@Olegt0rr](https://github.com/Olegt0rr))
- Don't replace default field description for ObjectId ([#82](https://github.com/art049/odmantic/pull/82) by [@Olegt0rr](https://github.com/Olegt0rr))
#### Internals
- Support pydantic 1.8 ([#113](https://github.com/art049/odmantic/pull/113) by [@art049](https://github.com/art049))
- Add nightly builds ([#114](https://github.com/art049/odmantic/pull/114) by [@art049](https://github.com/art049))
- CI Matrix with Standalone instances, ReplicaSets and Sharded clusters ([#91](https://github.com/art049/odmantic/pull/91) by [@art049](https://github.com/art049))
- Update mkdocstrings requirement from ^0.14.0 to ^0.15.0 ([#110](https://github.com/art049/odmantic/pull/110) by [@dependabot[bot]](https://github.com/apps/dependabot))
- Update mkdocs-material requirement from ^6.0.2 to ^7.0.3 ([#111](https://github.com/art049/odmantic/pull/111) by [@dependabot[bot]](https://github.com/apps/dependabot))
- Update mypy requirement from ^0.800 to ^0.812 ([#106](https://github.com/art049/odmantic/pull/106) by [@dependabot[bot]](https://github.com/apps/dependabot))
## [0.3.3] - 2021-02-13
#### Fixed
- Remove `bypass_document_validation` save option to avoid `Not Authorized` errors ([#85](https://github.com/art049/odmantic/pull/85) by [@Olegt0rr](https://github.com/Olegt0rr))
- Fix microseconds issue: use truncation instead of round ([#100](https://github.com/art049/odmantic/pull/100) by [@erny](https://github.com/erny))
- Add py.typed to ship typing information for mypy ([#101](https://github.com/art049/odmantic/pull/101) by [@art049](https://github.com/art049))
- Fix datetime field default example value naiveness ([#103](https://github.com/art049/odmantic/pull/103) by [@art049](https://github.com/art049))
#### Internals
- Update pytz requirement from ^2020.1 to ^2021.1 ([#98](https://github.com/art049/odmantic/pull/98) by [@dependabot[bot]](https://github.com/apps/dependabot))
- Update mkdocstrings requirement from ^0.13.2 to ^0.14.0 ([#92](https://github.com/art049/odmantic/pull/92) by [@dependabot[bot]](https://github.com/apps/dependabot))
- Update mypy requirement from ^0.790 to ^0.800 ([#97](https://github.com/art049/odmantic/pull/97) by [@dependabot[bot]](https://github.com/apps/dependabot))
- Update isort requirement from ~=5.6.4 to ~=5.7.0 ([#90](https://github.com/art049/odmantic/pull/90) by [@dependabot[bot]](https://github.com/apps/dependabot))
- Update fastapi requirement from >=0.61.1,<0.63.0 to >=0.61.1,<0.64.0 ([#84](https://github.com/art049/odmantic/pull/84) by [@dependabot[bot]](https://github.com/apps/dependabot))
## [0.3.2] - 2020-12-15
#### Added
- Fix embedded model field update ([#77](https://github.com/art049/odmantic/pull/77) by [@art049](https://github.com/art049))
- Fix `datetime` bson inheritance issue ([#78](https://github.com/art049/odmantic/pull/78) by [@Olegt0rr](https://github.com/Olegt0rr))
#### Internals
- Migrate to the updated prettier precommit hook ([#74](https://github.com/art049/odmantic/pull/74) by [@art049](https://github.com/art049))
- Fix tox dependency install ([#72](https://github.com/art049/odmantic/pull/72) by [@art049](https://github.com/art049))
- Update uvicorn requirement from ^0.12.1 to ^0.13.0 ([#67](https://github.com/art049/odmantic/pull/67) by [@dependabot[bot]](https://github.com/apps/dependabot))
- Update mypy requirement from ^0.782 to ^0.790 ([#48](https://github.com/art049/odmantic/pull/48) by [@dependabot[bot]](https://github.com/apps/dependabot-preview))
- Update importlib-metadata requirement from ^1.0 to >=1,<4 ([#54](https://github.com/art049/odmantic/pull/54) by [@dependabot[bot]](https://github.com/apps/dependabot))
- Update flake8 requirement from ==3.8.3 to ==3.8.4 ([#47](https://github.com/art049/odmantic/pull/47) by [@dependabot[bot]](https://github.com/apps/dependabot-preview))
- Update fastapi requirement from ^0.61.1 to >=0.61.1,<0.63.0 ([#59](https://github.com/art049/odmantic/pull/59) by [@dependabot[bot]](https://github.com/apps/dependabot))
## [0.3.1] - 2020-11-16
#### Added
- Add `schema_extra` config option ([#41](https://github.com/art049/odmantic/pull/41) by [@art049](https://github.com/art049))
#### Fixed
- Fix `setattr` error on a manually initialized EmbeddedModel ([#40](https://github.com/art049/odmantic/pull/40) by [@art049](https://github.com/art049))
## [0.3.0] - 2020-11-09
#### Deprecated
- Deprecate usage of `__collection__` to customize the collection name. Prefer the
`collection` Config option ([more
details](https://art049.github.io/odmantic/modeling/#collection))
#### Added
- Allow parsing document with unset fields defaults ([documentation](https://art049.github.io/odmantic/raw_query_usage/#advanced-parsing-behavior)) ([#28](https://github.com/art049/odmantic/pull/28) by [@art049](https://github.com/art049))
- Integration with Pydantic `Config` class ([#37](https://github.com/art049/odmantic/pull/37) by [@art049](https://github.com/art049)):
- It's now possible to define custom `json_encoders` on the Models
- Some other `Config` options provided by Pydantic are now available ([more
details](https://art049.github.io/odmantic/modeling/#advanced-configuration))
- Support CPython 3.9 ([#32](https://github.com/art049/odmantic/pull/32) by
[@art049](https://github.com/art049))
- Unpin pydantic to support 1.7.0 ([#29](https://github.com/art049/odmantic/pull/29) by
[@art049](https://github.com/art049))
## [0.2.1] - 2020-10-25
#### Fixed
- Fix combined use of `skip` and `limit` with `engine.find` (#25 by @art049)
## [0.2.0] - 2020-10-25
#### Deprecated
- Deprecate `AIOEngineDependency` to prefer a global engine object, [more
details](https://art049.github.io/odmantic/usage_fastapi/#building-the-engine) (#21 by
@art049)
#### Added
- [Add sorting support](https://art049.github.io/odmantic/querying/#sorting) (#17 by @adriencaccia)
- Support motor 2.3.0 (#20 by @art049)
#### Fixed
- Fix FastAPI usage with References (#19 by @art049)
#### Docs
- Adding a CONTRIBUTING.md file to the root directory with link to docs (#8 by @sanders41)
- Raw Query Usage Documentation Fix (#10 by @adeelsohailahmed)
- Update Filtering to include Bitwise Operator Warning (#24 by @adeelsohailahmed)
## [0.1.0] - 2020-10-19
#### Initial Release
[0.1.0]: https://github.com/art049/odmantic/releases/tag/v0.1.0
[0.2.0]: https://github.com/art049/odmantic/compare/v0.1.0...v0.2.0
[0.2.1]: https://github.com/art049/odmantic/compare/v0.2.0...v0.2.1
[0.3.0]: https://github.com/art049/odmantic/compare/v0.2.1...v0.3.0
[0.3.1]: https://github.com/art049/odmantic/compare/v0.3.0...v0.3.1
[0.3.2]: https://github.com/art049/odmantic/compare/v0.3.1...v0.3.2
[0.3.3]: https://github.com/art049/odmantic/compare/v0.3.2...v0.3.3
[0.3.4]: https://github.com/art049/odmantic/compare/v0.3.3...v0.3.4
[0.3.5]: https://github.com/art049/odmantic/compare/v0.3.4...v0.3.5
[0.4.0]: https://github.com/art049/odmantic/compare/v0.3.5...v0.4.0
[0.5.0]: https://github.com/art049/odmantic/compare/v0.4.0...v0.5.0
[0.6.0]: https://github.com/art049/odmantic/compare/v0.5.0...v0.6.0
[0.7.0]: https://github.com/art049/odmantic/compare/v0.6.0...v0.7.0
[0.7.1]: https://github.com/art049/odmantic/compare/v0.7.0...v0.7.1
[0.8.0]: https://github.com/art049/odmantic/compare/v0.7.1...v0.8.0
[0.9.0]: https://github.com/art049/odmantic/compare/v0.8.0...v0.9.0
[0.9.1]: https://github.com/art049/odmantic/compare/v0.9.0...v0.9.1
[0.9.2]: https://github.com/art049/odmantic/compare/v0.9.1...v0.9.2
[1.0.0]: https://github.com/art049/odmantic/compare/v0.9.2...v1.0.0
[1.0.1]: https://github.com/art049/odmantic/compare/v1.0.0...v1.0.1
[1.0.2]: https://github.com/art049/odmantic/compare/v1.0.1...v1.0.2
[unreleased]: https://github.com/art049/odmantic/compare/v1.0.2...HEAD
python-odmantic-1.0.2/CODE_OF_CONDUCT.md 0000664 0000000 0000000 00000012147 14613034133 0017431 0 ustar 00root root 0000000 0000000 # Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
arty049@protonmail.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
python-odmantic-1.0.2/CONTRIBUTING.md 0000664 0000000 0000000 00000000165 14613034133 0017060 0 ustar 00root root 0000000 0000000 Please see the [contributing guidelines](https://art049.github.io/odmantic/contributing/) on the documentation site.
python-odmantic-1.0.2/LICENSE 0000664 0000000 0000000 00000001351 14613034133 0015632 0 ustar 00root root 0000000 0000000 ISC License
Copyright (c) 2020, Arthur Pastel
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
python-odmantic-1.0.2/README.md 0000664 0000000 0000000 00000023334 14613034133 0016111 0 ustar 00root root 0000000 0000000 ODMantic
[](https://github.com/art049/odmantic/actions/workflows/ci.yml)
[](https://codecov.io/gh/art049/odmantic)

[](https://pypi.org/project/odmantic)
[](https://codspeed.io/art049/odmantic)
---
**Documentation**: [https://art049.github.io/odmantic/](https://art049.github.io/odmantic/)
---
Sync and Async ODM (Object Document Mapper) for MongoDB based on standard Python type hints. Built on top of Pydantic for model
definition and validation.
Core features:
- **Simple**: define your model by typing your fields using Python types, build queries
using Python comparison operators
- **Developer experience**: field/method autocompletion, type hints, data validation,
performing database operations with a functional API
- **Fully typed**: leverage static analysis to reduce runtime issues
- **AsyncIO support**: works well with ASGI frameworks (FastAPI, quart, sanic, Starlette, ...) but works also perfectly in synchronous environments
- **Serialization**: built-in JSON serialization and JSON schema generation
## Requirements
**Python**: 3.8 and later (tested against 3.8, 3.9, 3.10 and 3.11)
**Pydantic**: 2.5 and later
**MongoDB**: 4.0 and later
## Installation
```shell
pip install odmantic
```
## Example
> To enjoy an async context without any code boilerplate, you can reproduce the
> following steps using the AsyncIO REPL (only for Python 3.8+).
>
> ```
> python3.8 -m asyncio
> ```
>
> If you are using an earlier version of Python, you can use href="https://ipython.readthedocs.io/en/stable/install/index.html"
> target="_blank">IPython which provide an Autoawait feature (starting from Python
> 3.6).
### Define your first model
```python
from typing import Optional
from odmantic import Field, Model
class Publisher(Model):
name: str
founded: int = Field(ge=1440)
location: Optional[str] = None
```
By defining the `Publisher` class, we've just created an ODMantic model 🎉. In this
example, the model will represent book publishers.
This model contains three fields:
- `name`: This is the name of the Publisher. This is a simple string field without any
specific validation, but it will be required to build a new Publisher.
- `founded`: This is the year of foundation of the Publisher. Since the printing press was invented in 1440, it would be handy to allow only values above 1440. The
`ge` keyword argument passed to the Field is exactly doing this. The model will
require a founded value greater or equal than 1440.
- `location`: This field will contain the country code of the Publisher. Defining this
field as `Optional` with a `None` default value makes it a non required field that
will be set automatically when not specified.
The collection name has been defined by ODMantic as well. In this case it will be
`publisher`.
### Create some instances
```python
instances = [
Publisher(name="HarperCollins", founded=1989, location="US"),
Publisher(name="Hachette Livre", founded=1826, location="FR"),
Publisher(name="Lulu", founded=2002)
]
```
We defined three instances of the Publisher model. They all have a `name` property as it
was required. All the foundations years are later than 1440. The last publisher has no
location specified so by default this field is set to `None` (it will be stored as
`null` in the database).
For now, those instances only exists locally. We will persist them in a database in the
next step.
### Populate the database with your instances
> For the next steps, you'll need to start a local MongoDB server.The easiest way is
> to use docker. Simply run the next command in a terminal (closing the terminal will
> terminate the MongoDB instance and remove the container).
>
> ```shell
> docker run --rm -p 27017:27017 mongo
> ```
First, let's connect to the database using the engine. In ODMantic, every database
operation is performed using the engine object.
```python
from odmantic import AIOEngine
engine = AIOEngine()
```
By default, the `AIOEngine` (stands for AsyncIOEngine) automatically tries to connect to a
MongoDB instance running locally (on port 27017). Since we didn't provide any database name, it will use
the database named `test` by default.
The next step is to persist the instances we created before. We can perform this
operation using the `AIOEngine.save_all` method.
```python
await engine.save_all(instances)
```
Most of the engine I/O methods are asynchronous, hence the `await` keyword used here.
Once the operation is complete, we should be able to see our created documents in the
database. You can use Compass or RoboMongo if you'd like to have a graphical interface.
Another possibility is to use `mongo` CLI directly:
```shell
mongo --eval "db.publisher.find({})"
```
Output:
```js
connecting to: mongodb://127.0.0.1:27017
{
"_id": ObjectId("5f67b331514d6855bc5c54c9"),
"founded": 1989,
"location": "US",
"name": "HarperCollins"
},
{
"_id": ObjectId("5f67b331514d6855bc5c54ca"),
"founded":1826,
"location": "FR",
"name": "Hachette Livre"
},
{
"_id": ObjectId("5f67b331514d6855bc5c54cb"),
"founded": 2002,
"location": null,
"name": "Lulu"
}
```
The created instances are stored in the `test` database under the `publisher` collection.
We can see that an `_id` field has been added to each document. MongoDB need this field
to act as a primary key. Actually, this field is added by ODMantic and you can access it
under the name `id`.
```python
print(instances[0].id)
#> ObjectId("5f67b331514d6855bc5c54c9")
```
### Find instances matching a criteria
Since we now have some documents in the database, we can start building some queries.
First, let's find publishers created before the 2000s:
```python
early_publishers = await engine.find(Publisher, Publisher.founded <= 2000)
print(early_publishers)
#> [Publisher(name="HarperCollins", founded=1989, location="US),
#> Publisher(name="Hachette Livre", founded=1826, location="FR")]
```
Here, we called the `engine.find` method. The first argument we need to specify is the
Model class we want to query on (in our case `Publisher`). The second argument is the
actual query. Similarly to SQLAlchemy, you can build ODMantic queries using the regular python
operators.
When awaited, the `engine.find` method will return the list of matching instances stored
in the database.
Another possibility is to query for at most one instance. For example, if we want to
retrieve a publisher from Canada (CA):
```python
ca_publisher = await engine.find_one(Publisher, Publisher.location == "CA")
print(ca_publisher)
#> None
```
Here the result is `None` because no matching instances have been found in the database.
The `engine.find_one` method returns an instance if one exists in the database
otherwise, it will return `None`.
### Modify an instance
Finally, let's edit some instances. For example, we can set the `location` for the
publisher named `Lulu`.
First, we need to gather the instance from the database:
```python
lulu = await engine.find_one(Publisher, Publisher.name == "Lulu")
print(lulu)
#> Publisher(name="Lulu", founded=2002, location=None)
```
We still have the same instance, with no location set. We can change this field:
```python
lulu.location = "US"
print(lulu)
#> Publisher(name="Lulu", founded=2002, location="US)
```
The location has been changed locally but the last step to persist this change is to
save the document:
```python
await engine.save(lulu)
```
We can now check the database state:
```shell
mongo --eval "db.publisher.find({name: 'Lulu'})"
```
Output:
```js hl_lines="5"
connecting to: mongodb://127.0.0.1:27017
{
"_id": ObjectId("5f67b331514d6855bc5c54cb"),
"founded": 2002,
"location": "US",
"name": "Lulu"
}
```
The document have been successfully updated !
Now, what if we would like to change the foundation date with an invalid one (before 1440) ?
```python
lulu.founded = 1000
#> ValidationError: 1 validation error for Publisher
#> founded
#> ensure this value is greater than 1440
#> (type=value_error.number.not_gt; limit_value=1440)
```
This will raise an exception as it's not matching the model definition.
### Next steps
If you already have experience with Pydantic and FastAPI, the [Usage with FastAPI](https://art049.github.io/odmantic/usage_fastapi/) example sould be interesting for you to get kickstarted.
Otherwise, to get started on more advanced practices like relations and building more
advanced queries, you can directly check the other sections of the
[documentation](https://art049.github.io/odmantic/).
If you wish to contribute to the project (Thank you! :smiley:), you can have a look to the
[Contributing](https://art049.github.io/odmantic/contributing/) section of the
documentation.
## License
This project is licensed under the terms of the ISC license.
python-odmantic-1.0.2/SECURITY.md 0000664 0000000 0000000 00000001371 14613034133 0016420 0 ustar 00root root 0000000 0000000 # Security Policy
## Supported Versions
The latest production release of ODMantic is supported.
## Reporting a Vulnerability
If you think you found a vulnerability, and even if you are not sure about it, please report it right away by sending an email to: _arthur[dot]pastel[at]gmail[dot]com_
Please try to be as explicit as possible, describing all the steps and example code to reproduce the security issue.
I will try to answer as soon as possible and will keep you informed about the progress of the resolution.
## Responsible Disclosure
Please do not disclose the vulnerability publicly until it has been fixed and published.
I will try to fix the vulnerability as soon as possible and will keep you informed about the progress of the resolution.
python-odmantic-1.0.2/Taskfile.yml 0000664 0000000 0000000 00000004671 14613034133 0017122 0 ustar 00root root 0000000 0000000 # https://taskfile.dev
version: "3"
silent: true
includes:
release: ./.github
mongodb:
taskfile: ./.mongodb-cluster-action/Taskfile.yml
dir: .mongodb-cluster-action
optional: true
tasks:
full-test:
desc: Run the tests against all supported versions.
deps:
- task: "mongodb:check"
cmds:
- tox --parallel auto
test:
desc: |
Run the tests with the current version.
deps:
- task: "mongodb:check"
cmds:
- python -m pytest -rs -n auto
bench:
desc: |
Run the benches with the current version.
deps:
- task: "mongodb:check"
cmds:
- python -m pytest --benchmark-enable --benchmark-only
default:
desc: |
Run the tests related to changes with the current version.
deps:
- task: mongodb
cmds:
- python -m pytest -rs --testmon
coverage:
desc: Get the test coverage (xml and html) with the current version.
deps:
- task: "mongodb:check"
cmds:
- coverage run -m pytest -rs
- coverage report -m
- coverage xml
- 'echo "Generated XML report: ./coverage.xml"'
- coverage html
- 'echo "Generated HTML report: ./htmlcov/index.html"'
docs:
desc: Start the local documentation server.
cmds:
- mkdocs serve -f ./mkdocs.yml
lint:
desc: Run the linting checks.
cmds:
- pre-commit run --all-files
format:
desc: Format the code (and imports).
cmds:
- python -m isort odmantic tests
- python -m black odmantic tests
setup:
desc: Configure the development environment.
cmds:
- task: setup:git-lfs
- task: setup:git-submodules
- task: setup:pre-commit-hook
- task: setup:deps-setup
setup:git-lfs:
cmds:
- git lfs install
- git lfs pull
status:
- test -d .git/lfs/
setup:git-submodules:
cmds:
- git submodule update --init
status:
- test -f .mongodb-cluster-action/README.md
setup:pre-commit-hook:
cmds:
- pre-commit install
status:
- test -f .git/hooks/pre-commit
setup:deps-setup:
deps:
- task: setup:flit
cmds:
- flit install --deps=all --python python
sources:
- pyproject.toml
setup:flit:
cmds:
- pip install flit
status:
- which flit
clean:
cmds:
- rm -rf dist/
- rm -rf htmlcov/ ./.coverage ./coverage.xml
- rm -rf .task/ ./__release_notes__.md ./__version__.txt
python-odmantic-1.0.2/dependabot.yml 0000664 0000000 0000000 00000000150 14613034133 0017451 0 ustar 00root root 0000000 0000000 version: 2
groups:
dev-dependencies:
dependency-type: development
applies-to: version-updates
python-odmantic-1.0.2/docs/ 0000775 0000000 0000000 00000000000 14613034133 0015555 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/__init__.py 0000664 0000000 0000000 00000000015 14613034133 0017662 0 ustar 00root root 0000000 0000000 # Helps mypy
python-odmantic-1.0.2/docs/api_reference/ 0000775 0000000 0000000 00000000000 14613034133 0020344 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/api_reference/bson.md 0000664 0000000 0000000 00000001644 14613034133 0021634 0 ustar 00root root 0000000 0000000 This module provides helpers to build Pydantic Models containing BSON objects.
## Pydantic model helpers
::: odmantic.bson.BaseBSONModel
selection:
members:
-
::: odmantic.bson.BSON_TYPES_ENCODERS
Encoders required to encode BSON fields (can be used in the Pydantic Model's `Config.json_encoders` parameter). See [pydantic: JSON Encoders](https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.json_encoders){:target=blank_} for more details.
## Custom BSON serializer annotation
::: odmantic.bson.WithBsonSerializer
## Pydantic type helpers
Those helpers inherit directly from their respective `bson` types. They add the field
validation logic required by Pydantic to work with them. On top of this, the appropriate JSON schemas are generated for them.
::: odmantic.bson.ObjectId
::: odmantic.bson.Int64
::: odmantic.bson.Decimal128
::: odmantic.bson.Binary
::: odmantic.bson.Regex
python-odmantic-1.0.2/docs/api_reference/config.md 0000664 0000000 0000000 00000000042 14613034133 0022127 0 ustar 00root root 0000000 0000000 ::: odmantic.config.ODMConfigDict
python-odmantic-1.0.2/docs/api_reference/engine.md 0000664 0000000 0000000 00000000175 14613034133 0022136 0 ustar 00root root 0000000 0000000 ::: odmantic.engine.AIOEngine
::: odmantic.engine.AIOCursor
::: odmantic.engine.SyncEngine
::: odmantic.engine.SyncCursor
python-odmantic-1.0.2/docs/api_reference/exceptions.md 0000664 0000000 0000000 00000000414 14613034133 0023046 0 ustar 00root root 0000000 0000000 ::: odmantic.exceptions.BaseEngineException
::: odmantic.exceptions.DocumentNotFoundError
::: odmantic.exceptions.DocumentParsingError
selection:
members:
-
::: odmantic.exceptions.DuplicateKeyError
selection:
members:
-
python-odmantic-1.0.2/docs/api_reference/field.md 0000664 0000000 0000000 00000000031 14613034133 0021743 0 ustar 00root root 0000000 0000000 ::: odmantic.field.Field
python-odmantic-1.0.2/docs/api_reference/index.md 0000664 0000000 0000000 00000000031 14613034133 0021767 0 ustar 00root root 0000000 0000000 ::: odmantic.index.Index
python-odmantic-1.0.2/docs/api_reference/model.md 0000664 0000000 0000000 00000000525 14613034133 0021770 0 ustar 00root root 0000000 0000000 ::: odmantic.model._BaseODMModel
selection:
members:
- model_validate_doc
- model_dump_doc
- model_update
- model_copy
- model_dump
::: odmantic.model.Model
selection:
members:
-
::: odmantic.model.EmbeddedModel
selection:
members:
-
python-odmantic-1.0.2/docs/api_reference/query.md 0000664 0000000 0000000 00000000720 14613034133 0022032 0 ustar 00root root 0000000 0000000 ::: odmantic.query.QueryExpression
## Logical Operators
::: odmantic.query.and_
::: odmantic.query.or_
::: odmantic.query.nor_
## Comparison Operators
::: odmantic.query.eq
::: odmantic.query.ne
::: odmantic.query.gt
::: odmantic.query.gte
::: odmantic.query.lt
::: odmantic.query.lte
::: odmantic.query.in_
::: odmantic.query.not_in
::: odmantic.query.match
## Sort helpers
::: odmantic.query.SortExpression
::: odmantic.query.asc
::: odmantic.query.desc
python-odmantic-1.0.2/docs/api_reference/reference.md 0000664 0000000 0000000 00000000041 14613034133 0022617 0 ustar 00root root 0000000 0000000 ::: odmantic.reference.Reference
python-odmantic-1.0.2/docs/api_reference/session.md 0000664 0000000 0000000 00000000325 14613034133 0022351 0 ustar 00root root 0000000 0000000
::: odmantic.session.AIOSession
::: odmantic.engine.AIOTransaction
::: odmantic.session.AIOSessionBase
::: odmantic.engine.SyncSession
::: odmantic.engine.SyncTransaction
::: odmantic.engine.SyncSessionBase
python-odmantic-1.0.2/docs/api_reference/templates/ 0000775 0000000 0000000 00000000000 14613034133 0022342 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/api_reference/templates/python/ 0000775 0000000 0000000 00000000000 14613034133 0023663 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/api_reference/templates/python/material/ 0000775 0000000 0000000 00000000000 14613034133 0025461 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/api_reference/templates/python/material/properties.html 0000664 0000000 0000000 00000000430 14613034133 0030540 0 ustar 00root root 0000000 0000000 {% if properties %}
{% for property in properties %} {% if property != "pydantic-model" %}
{{ property }}
{% endif %} {% endfor %}
{% endif %}
python-odmantic-1.0.2/docs/changelog.md 0000777 0000000 0000000 00000000000 14613034133 0022045 2../CHANGELOG.md ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/contributing.md 0000664 0000000 0000000 00000012540 14613034133 0020610 0 ustar 00root root 0000000 0000000 # Contributing
## Sharing feedback
This project is still quite new and therefore having your feedback will really help to
prioritize relevant feature developments :rocket:.
The easiest way to share feedback and discuss about the project is to join the [Gitter
chatroom](https://gitter.im/odmantic/community?utm_source=share-link&utm_medium=link&utm_campaign=share-link){:target=blank_}.
If you want to contribute (thanks a lot ! :smiley:), you can open an
[issue](https://github.com/art049/odmantic/issues/new){:target=blank_} on Github.
Before creating a non obvious (typo, documentation fix) Pull Request, please make sure
to open an issue.
## Developing locally
### With the VSCode's [devcontainer](https://code.visualstudio.com/docs/remote/containers){:target=blank_} feature
This feature will make the tools/environment installation very simple as you will develop
in a container that has already been configured to run this project.
Here are the steps:
1. Clone the repository and open it with [Visual Studio
Code](https://code.visualstudio.com/){:target=blank_}.
2. Make sure that the [Remote -
Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers){:target=blank_}
(`ms-vscode-remote.remote-containers`) extension is installed.
3. Run the `Remote-Container: Reopen in Container` command (press `Ctrl`+`Shift`+`P` and
then type the command).
4. After the setup script completes, the environment is ready. You can start the local
development :fire:.
You can go to the [development tasks](#running-development-tasks) section to see the
available `task` commands.
!!! note "MongoDB container"
In this containerized development environment, a MongoDB instance should already be
running as a part of the development `docker-compose.yml` file internally used by
VSCode.
### Regular environment setup
#### Installing the tools
- [Git LFS](https://git-lfs.github.com/){:target=blank_}: used to store documentation assets in the repository
- [Docker](https://docs.docker.com/get-docker/){:target=blank_}: used to run a local MongoDB instance
- [Docker Compose](https://docs.docker.com/compose/install/){:target=blank_} (Optional): used to run a local MongoDB cluster (replica set or shards)
- [Task](https://taskfile.dev){:target=blank_}: task manager
!!! tip "Installing python based development tools"
In order to install the devtools written in python, it's recommended to use [pipx](https://pipxproject.github.io/pipx/){:target=blank_}.
```shell
python3 -m pip install --user pipx
python3 -m pipx ensurepath
```
- [flit](https://flit.pypa.io/en/latest/){:target=blank_}: packaging system and dependency
manager
```shell
pipx install flit
```
- [tox](https://tox.readthedocs.io/en/latest/){:target=blank_}: multi-environment test runner
```shell
pipx install tox
```
- [pre-commit](https://pre-commit.com/){:target=blank_}: pre commit hook manager
```shell
pipx install pre-commit
```
!!! tip "Python versions"
If you want to test the project with multiple python versions, you'll need to
install them manually.
You can use [pyenv](https://github.com/pyenv/pyenv){:target=blank_} to
install them easily.
```shell
# Install the versions
pyenv install "3.7.9"
pyenv install "3.8.9"
pyenv install "3.9.0"
# Make the versions available locally in the project
pyenv local 3.8.6 3.7.9 3.9.0
```
#### Configuring the local environment
```shell
task setup
```
### Running development tasks
The following tasks are available for the project:
* `task setup`: Configure the development environment.
* `task lint`: Run the linting checks.
* `task format`: Format the code (and imports).
* `mongodb:standalone-docker`: Start a standalone MongoDB instance using a docker container
* `mongodb:standalone-docker:down`: Stop the standalone instance
* `mongodb:replica-compose`: Start a replica set MongoDB cluster using docker-compose
* `mongodb:replica-compose:down`: Stop the replica set cluster
* `mongodb:sharded-compose`: Start a sharded MongoDB cluster using docker-compose
* `mongodb:sharded-compose:down`: Stop the sharded MongoDB cluster
* `task test`: Run the tests with the current version.
* `task full-test`: Run the tests against all supported versions.
* `task coverage`: Get the test coverage (xml and html) with the current version.
* `task docs`: Start the local documentation server.
python-odmantic-1.0.2/docs/css/ 0000775 0000000 0000000 00000000000 14613034133 0016345 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/css/extra.css 0000664 0000000 0000000 00000000052 14613034133 0020177 0 ustar 00root root 0000000 0000000 code {
--md-code-fg-color: #4cae4fbb;
}
python-odmantic-1.0.2/docs/engine.md 0000664 0000000 0000000 00000030222 14613034133 0017343 0 ustar 00root root 0000000 0000000 # Engine
This engine documentation present how to work with both the Sync ([SyncEngine][odmantic.engine.SyncEngine]) and the Async ([AIOEngine][odmantic.engine.AIOEngine]) engines. The methods available for both are very close but the main difference is that the Async engine exposes coroutines instead of functions for the Sync engine.
## Creating the engine
In the previous examples, we created the engine using default parameters:
- MongoDB: running on `localhost` port `27017`
- Database name: `test`
It's possible to provide a custom client ([AsyncIOMotorClient](https://motor.readthedocs.io/en/stable/api-asyncio/asyncio_motor_client.html){:target=blank_} or [PyMongoClient](https://pymongo.readthedocs.io/en/stable/api/pymongo/mongo_client.html){:target=blank_}) to the engine constructor. In the same way, the database name can be changed using the `database` keyword argument.
{{ async_sync_snippet("engine", "engine_creation.py") }}
For additional information about the MongoDB connection strings, see [this
section](https://docs.mongodb.com/manual/reference/connection-string/){:target=blank_}
of the MongoDB documentation.
!!! tip "Usage with DNS SRV records"
If you decide to use the [DNS Seed List Connection
Format](https://docs.mongodb.com/manual/reference/connection-string/#dns-seed-list-connection-format){:target=blank}
(i.e `mongodb+srv://...`), you will need to install the
[dnspython](https://pypi.org/project/dnspython/){:target=blank_} package.
## Create
There are two ways of persisting instances to the database (i.e creating new documents):
- `engine.save`: to save a single instance
- `engine.save_all`: to save multiple instances at
once
{{ async_sync_snippet("engine", "create.py", hl_lines="12 19") }}
??? abstract "Resulting documents in the `player` collection"
```json
{
"_id": ObjectId("5f85f36d6dfecacc68428a46"),
"game": "World of Warcraft",
"name": "Leeroy Jenkins"
}
{
"_id": ObjectId("5f85f36d6dfecacc68428a47"),
"game": "Counter-Strike",
"name": "Shroud"
}
{
"_id": ObjectId("5f85f36d6dfecacc68428a49"),
"game": "Starcraft",
"name": "TLO"
}
{
"_id": ObjectId("5f85f36d6dfecacc68428a48"),
"game": "Starcraft",
"name": "Serral"
}
```
!!! tip "Referenced instances"
When calling `engine.save` or
`engine.save_all`, the referenced models will are persisted
as well.
!!! warning "Upsert behavior"
The `save` and `save_all` methods behave as upsert operations ([more
details](engine.md#update)). Hence, you might overwrite documents if you save
instances with an existing primary key already existing in the database.
## Read
!!! note "Examples database content"
The next examples will consider that you have a `player` collection populated with
the documents previously created.
### Fetch a single instance
As with regular MongoDB driver, you can use the
`engine.find_one` method to get at most one
instance of a specific Model. This method will either return an instance matching the
specified criteriums or `None` if no instances have been found.
{{ async_sync_snippet("engine", "fetch_find_one.py", hl_lines="11 15-17") }}
!!! info "Missing values in documents"
While parsing the MongoDB documents into Model instances, ODMantic will use the
provided default values to populate the missing fields.
See [this section](raw_query_usage.md#advanced-parsing-behavior) for more details about document parsing.
!!! tip "Fetch using `sort`"
We can use the `sort` parameter to fetch the `Player` instance with
the first `name` in ascending order:
```python
await engine.find_one(Player, sort=Player.name)
```
Find out more on `sort` in [the dedicated section](querying.md#sorting).
### Fetch multiple instances
To get more than one instance from the database at once, you can use the
`engine.find` method.
This method will return a cursor: an [AIOCursor][odmantic.engine.AIOCursor] object for the [AIOEngine][odmantic.engine.AIOEngine] and a [SyncCursor][odmantic.engine.SyncCursor] object for the [SyncEngine][odmantic.engine.SyncEngine].
Those cursors can mainly be used in two different ways:
#### Usage as an iterator
{{ async_sync_snippet("engine", "fetch_async_for.py", hl_lines="11") }}
!!! tip "Ordering instances"
The `sort` parameter allows to order the query in ascending or descending order on
a single or multiple fields.
```python
engine.find(Player, sort=(Player.name, Player.game.desc()))
```
Find out more on `sort` in [the dedicated section](querying.md#sorting).
#### Usage as an awaitable/list
Even if the iterator usage should be preferred, in some cases it might be required
to gather all the documents from the database before processing them.
{{ async_sync_snippet("engine", "fetch_await.py", hl_lines="11") }}
!!! note "Pagination"
When using [AIOEngine.find][odmantic.engine.AIOEngine.find] or [SyncEngine.find][odmantic.engine.SyncEngine.find]
you can as well use the `skip` and `limit` keyword arguments , respectively to skip
a specified number of instances and to limit the number of fetched instances.
!!! tip "Referenced instances"
When calling `engine.find` or `engine.find_one`, the referenced models will
be recursively resolved as well by design.
!!! info "Passing the model class to `find` and `find_one`"
When using the method to retrieve instances from the database, you have to specify
the Model you want to query on as the first positional parameter. Internally, this
enables ODMantic to properly type the results.
### Count instances
You can count instances in the database by using the `engine.count` method and as with
other read methods, it's still possible to use this method with filtering queries.
{{ async_sync_snippet("engine", "count.py", hl_lines="11 14 17") }}
!!! tip "Combining multiple queries in read operations"
While using [find][odmantic.engine.AIOEngine.find],
[find_one][odmantic.engine.AIOEngine.find_one] or
[count][odmantic.engine.AIOEngine.count], you may pass as many queries as you want
as positional arguments. Those will be implicitly combined as single
[and_][odmantic.query.and_] query.
## Update
Updating an instance in the database can be done by modifying the instance locally and
saving it again to the database.
The `engine.save` and `engine.save_all` methods are actually behaving as
`upsert` operations. In other words, if the instance already exists it will be updated.
Otherwise, the related document will be created in the database.
### Modifying one field
Modifying a single field can be achieved by directly changing the instance attribute and
saving the instance.
{{ async_sync_snippet("engine", "update.py", hl_lines="13-14") }}
???+abstract "Resulting documents in the `player` collection"
```json hl_lines="6-10"
{
"_id": ObjectId("5f85f36d6dfecacc68428a46"),
"game": "World of Warcraft",
"name": "Leeroy Jenkins"
}
{
"_id": ObjectId("5f85f36d6dfecacc68428a47"),
"game": "Valorant",
"name": "Shroud"
}
{
"_id": ObjectId("5f85f36d6dfecacc68428a49"),
"game": "Starcraft",
"name": "TLO"
}
{
"_id": ObjectId("5f85f36d6dfecacc68428a48"),
"game": "Starcraft",
"name": "Serral"
}
```
### Patching multiple fields at once
The easiest way to change multiple fields at once is to use the
[Model.model_update][odmantic.model._BaseODMModel.model_update] method. This method will take either a
Pydantic object or a dictionary and update the matching fields of the instance.
#### From a Pydantic Model
{{ async_sync_snippet("engine", "patch_multiple_fields_pydantic.py", hl_lines="19-21 25 27 30 33") }}
#### From a dictionary
{{ async_sync_snippet("engine", "patch_multiple_fields_dict.py", hl_lines="16 18 21 24") }}
!!! abstract "Resulting document associated to the player"
```json hl_lines="3 4"
{
"_id": ObjectId("5f85f36d6dfecacc68428a49"),
"game": "Starcraft II",
"name": "TheLittleOne"
}
```
### Changing the primary field
Directly changing the primary field value as explained above is not
possible and a `NotImplementedError` exception will be raised if you try to do so.
The easiest way to change an instance primary field is to perform a local copy of the
instance using the [Model.copy][odmantic.model._BaseODMModel.model_copy] method.
{{ async_sync_snippet("engine", "primary_key_update.py", hl_lines="18 20 22") }}
!!! abstract "Resulting document associated to the player"
```json hl_lines="2"
{
"_id": ObjectId("ffffffffffffffffffffffff"),
"game": "Valorant",
"name": "Shroud"
}
```
!!! danger "Update data used with the copy"
The data updated by the copy method is not validated: you should **absolutely**
trust this data.
## Delete
### Delete a single instance
You can delete instance by passing them to the `engine.delete` method.
{{ async_sync_snippet("engine", "delete.py", hl_lines="14") }}
### Remove
You can delete instances that match a filter by using the
`engine.remove` method.
{{ async_sync_snippet("engine", "remove.py", hl_lines="11") }}
#### Just one
You can limit `engine.remove` to removing only one
instance by passing `just_one`.
{{ async_sync_snippet("engine", "remove_just_one.py", hl_lines="12") }}
## Consistency
### Using a Session
!!! Tip "Why are sessions needed ?"
A session is a way to
guarantee that the data you read is consistent with the data you write.
This is especially useful when you need to perform multiple operations on the
same data.
See [this document](https://www.mongodb.com/docs/manual/core/read-isolation-consistency-recency/#causal-consistency){:target=blank_} for more details on causal consistency.
You can create a session by using the `engine.session` method. This method will return
either a [SyncSession][odmantic.session.SyncSession] or an
[AIOSession][odmantic.session.AIOSession] object, depending on the type of engine used.
Those session objects are context manager and can be used along with the `with` or the
`async with` keywords. Once the context is entered the `session` object exposes the same
database operation methods as the related `engine` object but execute each operation in
the session context.
{{ async_sync_snippet("engine", "save_with_session.py", hl_lines="13-23") }}
!!! Tip "Directly using driver sessions"
Every single engine method also accepts a `session` parameter. You can use this
parameter to provide an existing driver (motor or PyMongo) session that you created
manually.
!!! Tip "Accessing the underlying driver session object"
The `session.get_driver_session` method exposes the underlying driver session. This
is useful if you want to use the driver session directly to perform raw operations.
### Using a Transaction
!!! Tip "Why are transactions needed ?"
A transaction is a mechanism that allows you to execute multiple operations in a
single atomic operation. This is useful when you want to ensure that a set of
operations is atomicly performed on a specific document.
!!! Error "MongoDB transaction support"
Transactions are only supported in a replica sets (Mongo 4.0+) or sharded clusters
with replication enabled (Mongo 4.2+), if you use them in a standalone MongoDB
instance an error will be raised.
You can create a transaction directly from the engine by using the `engine.transaction`
method. This methods will either return a
[SyncTransaction][odmantic.session.SyncTransaction] or an
[AIOTransaction][odmantic.session.AIOTransaction] object. As for sessions, transaction
objects exposes the same database operation methods as the related `engine` object but
execute each operation in a transactional context.
In order to terminate a transaction you must either call the `commit` method to persist
all the changes or call the `abort` method to drop the changes introduced in the
transaction.
{{ async_sync_snippet("engine", "save_with_transaction.py", hl_lines="11-13 18-21") }}
It is also possible to create a transaction within an existing session by using
the `session.transaction` method:
{{ async_sync_snippet("engine", "transaction_from_session.py", hl_lines="11-19") }}
python-odmantic-1.0.2/docs/examples_src/ 0000775 0000000 0000000 00000000000 14613034133 0020242 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/examples_src/__init__.py 0000664 0000000 0000000 00000000000 14613034133 0022341 0 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/examples_src/engine/ 0000775 0000000 0000000 00000000000 14613034133 0021507 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/examples_src/engine/__init__.py 0000664 0000000 0000000 00000000015 14613034133 0023614 0 ustar 00root root 0000000 0000000 # Helps mypy
python-odmantic-1.0.2/docs/examples_src/engine/async/ 0000775 0000000 0000000 00000000000 14613034133 0022624 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/examples_src/engine/async/__init__.py 0000664 0000000 0000000 00000000015 14613034133 0024731 0 ustar 00root root 0000000 0000000 # Helps mypy
python-odmantic-1.0.2/docs/examples_src/engine/async/count.py 0000664 0000000 0000000 00000000562 14613034133 0024331 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model
class Player(Model):
name: str
game: str
engine = AIOEngine()
player_count = await engine.count(Player)
print(player_count)
#> 4
cs_count = await engine.count(Player, Player.game == "Counter-Strike")
print(cs_count)
#> 1
valorant_count = await engine.count(Player, Player.game == "Valorant")
print(valorant_count)
#> 0
python-odmantic-1.0.2/docs/examples_src/engine/async/create.py 0000664 0000000 0000000 00000000603 14613034133 0024440 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model
class Player(Model):
name: str
game: str
engine = AIOEngine()
leeroy = Player(name="Leeroy Jenkins", game="World of Warcraft")
await engine.save(leeroy)
players = [
Player(name="Shroud", game="Counter-Strike"),
Player(name="Serral", game="Starcraft"),
Player(name="TLO", game="Starcraft"),
]
await engine.save_all(players)
python-odmantic-1.0.2/docs/examples_src/engine/async/delete.py 0000664 0000000 0000000 00000000315 14613034133 0024437 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model
class Player(Model):
name: str
game: str
engine = AIOEngine()
players = await engine.find(Player)
for player in players:
await engine.delete(player)
python-odmantic-1.0.2/docs/examples_src/engine/async/engine_creation.py 0000664 0000000 0000000 00000000307 14613034133 0026327 0 ustar 00root root 0000000 0000000 from motor.motor_asyncio import AsyncIOMotorClient
from odmantic import AIOEngine
client = AsyncIOMotorClient("mongodb://localhost:27017/")
engine = AIOEngine(client=client, database="example_db")
python-odmantic-1.0.2/docs/examples_src/engine/async/fetch_async_for.py 0000664 0000000 0000000 00000000506 14613034133 0026333 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model
class Player(Model):
name: str
game: str
engine = AIOEngine()
async for player in engine.find(Player, Player.game == "Starcraft"):
print(repr(player))
#> Player(id=ObjectId(...), name='TLO', game='Starcraft')
#> Player(id=ObjectId(...), name='Serral', game='Starcraft')
python-odmantic-1.0.2/docs/examples_src/engine/async/fetch_await.py 0000664 0000000 0000000 00000000543 14613034133 0025456 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model
class Player(Model):
name: str
game: str
engine = AIOEngine()
players = await engine.find(Player, Player.game != "Starcraft")
print(players)
#> [
#> Player(id=ObjectId(...), name="Leeroy Jenkins", game="World of Warcraft"),
#> Player(id=ObjectId(...), name="Shroud", game="Counter-Strike"),
#> ]
python-odmantic-1.0.2/docs/examples_src/engine/async/fetch_find_one.py 0000664 0000000 0000000 00000000606 14613034133 0026132 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model
class Player(Model):
name: str
game: str
engine = AIOEngine()
player = await engine.find_one(Player, Player.name == "Serral")
print(repr(player))
#> Player(id=ObjectId(...), name="Serral", game="Starcraft")
another_player = await engine.find_one(
Player, Player.name == "Player_Not_Stored_In_Database"
)
print(another_player)
#> None
python-odmantic-1.0.2/docs/examples_src/engine/async/patch_multiple_fields_dict.py 0000664 0000000 0000000 00000001134 14613034133 0030540 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model
class Player(Model):
name: str
game: str
engine = AIOEngine()
player_tlo = await engine.find_one(Player, Player.name == "TLO")
print(repr(player_tlo))
#> Player(id=ObjectId(...), name='TLO', game='Starcraft')
# Create the patch dictionary containing the new values
patch_object = {"name": "TheLittleOne", "game": "Starcraft II"}
# Update the local instance
player_tlo.model_update(patch_object)
print(repr(player_tlo))
#> Player(id=ObjectId(...), name='TheLittleOne', game='Starcraft II')
# Finally persist the instance
await engine.save(player_tlo)
python-odmantic-1.0.2/docs/examples_src/engine/async/patch_multiple_fields_pydantic.py 0000664 0000000 0000000 00000001417 14613034133 0031434 0 ustar 00root root 0000000 0000000 from pydantic import BaseModel
from odmantic import AIOEngine, Model
class Player(Model):
name: str
game: str
engine = AIOEngine()
player_tlo = await engine.find_one(Player, Player.name == "TLO")
print(repr(player_tlo))
#> Player(id=ObjectId(...), name='TLO', game='Starcraft')
# Create the structure of the patch object with pydantic
class PatchPlayerSchema(BaseModel):
name: str
game: str
# Create the patch object containing the new values
patch_object = PatchPlayerSchema(name="TheLittleOne", game="Starcraft II")
# Apply the patch to the instance
player_tlo.model_update(patch_object)
print(repr(player_tlo))
#> Player(id=ObjectId(...), name='TheLittleOne', game='Starcraft II')
# Finally persist again the new instance
await engine.save(player_tlo)
python-odmantic-1.0.2/docs/examples_src/engine/async/primary_key_update.py 0000664 0000000 0000000 00000001021 14613034133 0027065 0 ustar 00root root 0000000 0000000 from bson import ObjectId
from odmantic import AIOEngine, Model
class Player(Model):
name: str
game: str
engine = AIOEngine()
shroud = await engine.find_one(Player, Player.name == "Shroud")
print(shroud.id)
#> 5f86074f6dfecacc68428a62
new_id = ObjectId("ffffffffffffffffffffffff")
# Copy the player instance with a new primary key
new_shroud = shroud.copy(update={"id": new_id})
# Delete the initial player instance
await engine.delete(shroud)
# Finally persist again the new instance
await engine.save(new_shroud)
python-odmantic-1.0.2/docs/examples_src/engine/async/remove.py 0000664 0000000 0000000 00000000317 14613034133 0024474 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model
class Player(Model):
name: str
game: str
engine = AIOEngine()
delete_count = await engine.remove(Player, Player.game == "Warzone")
print(delete_count)
#> 2
python-odmantic-1.0.2/docs/examples_src/engine/async/remove_just_one.py 0000664 0000000 0000000 00000000344 14613034133 0026402 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model
class Player(Model):
name: str
game: str
engine = AIOEngine()
delete_count = await engine.remove(
Player, Player.game == "Warzone", just_one=True
)
print(delete_count)
#> 1
python-odmantic-1.0.2/docs/examples_src/engine/async/save_with_session.py 0000664 0000000 0000000 00000001002 14613034133 0026723 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model
class Player(Model):
name: str
game: str
engine = AIOEngine()
leeroy = Player(name="Leeroy Jenkins", game="World of Warcraft")
async with engine.session() as session:
await session.save_all(
[
Player(name="Shroud", game="Counter-Strike"),
Player(name="Serral", game="Starcraft"),
Player(name="TLO", game="Starcraft"),
]
)
player_count = await session.count(Player)
print(player_count)
#> 3
python-odmantic-1.0.2/docs/examples_src/engine/async/save_with_transaction.py 0000664 0000000 0000000 00000001046 14613034133 0027575 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model
class Player(Model):
name: str
game: str
engine = AIOEngine()
async with engine.transaction() as transaction:
await transaction.save(Player(name="Leeroy Jenkins", game="WoW"))
await transaction.commit()
print(engine.count(Player))
#> 1
async with engine.transaction() as transaction:
await transaction.save(Player(name="Shroud", game="Counter-Strike"))
await transaction.save(Player(name="Serral", game="Starcraft"))
await transaction.abort()
print(engine.count(Player))
#> 1
python-odmantic-1.0.2/docs/examples_src/engine/async/transaction_from_session.py 0000664 0000000 0000000 00000001135 14613034133 0030311 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model
class Player(Model):
name: str
game: str
engine = AIOEngine()
async with engine.session() as session:
leeroy = await session.save(Player(name="Leeroy Jenkins", game="WoW"))
shroud = await session.save(Player(name="Shroud", game="Counter-Strike"))
async with session.transaction() as transaction:
leeroy.game = "Fortnite"
await transaction.save(leeroy)
shroud.game = "Fortnite"
await transaction.save(shroud)
await transaction.commit()
print(await engine.count(Player, Player.game == "Fortnite"))
#> 2
python-odmantic-1.0.2/docs/examples_src/engine/async/update.py 0000664 0000000 0000000 00000000410 14613034133 0024453 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model
class Player(Model):
name: str
game: str
engine = AIOEngine()
shroud = await engine.find_one(Player, Player.name == "Shroud")
print(shroud.game)
#> Counter-Strike
shroud.game = "Valorant"
await engine.save(shroud)
python-odmantic-1.0.2/docs/examples_src/engine/sync/ 0000775 0000000 0000000 00000000000 14613034133 0022463 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/examples_src/engine/sync/__init__.py 0000664 0000000 0000000 00000000015 14613034133 0024570 0 ustar 00root root 0000000 0000000 # Helps mypy
python-odmantic-1.0.2/docs/examples_src/engine/sync/count.py 0000664 0000000 0000000 00000000542 14613034133 0024166 0 ustar 00root root 0000000 0000000 from odmantic import Model, SyncEngine
class Player(Model):
name: str
game: str
engine = SyncEngine()
player_count = engine.count(Player)
print(player_count)
#> 4
cs_count = engine.count(Player, Player.game == "Counter-Strike")
print(cs_count)
#> 1
valorant_count = engine.count(Player, Player.game == "Valorant")
print(valorant_count)
#> 0
python-odmantic-1.0.2/docs/examples_src/engine/sync/create.py 0000664 0000000 0000000 00000000571 14613034133 0024303 0 ustar 00root root 0000000 0000000 from odmantic import SyncEngine, Model
class Player(Model):
name: str
game: str
engine = SyncEngine()
leeroy = Player(name="Leeroy Jenkins", game="World of Warcraft")
engine.save(leeroy)
players = [
Player(name="Shroud", game="Counter-Strike"),
Player(name="Serral", game="Starcraft"),
Player(name="TLO", game="Starcraft"),
]
engine.save_all(players)
python-odmantic-1.0.2/docs/examples_src/engine/sync/delete.py 0000664 0000000 0000000 00000000303 14613034133 0024273 0 ustar 00root root 0000000 0000000 from odmantic import SyncEngine, Model
class Player(Model):
name: str
game: str
engine = SyncEngine()
players = engine.find(Player)
for player in players:
engine.delete(player)
python-odmantic-1.0.2/docs/examples_src/engine/sync/engine_creation.py 0000664 0000000 0000000 00000000257 14613034133 0026172 0 ustar 00root root 0000000 0000000 from pymongo import MongoClient
from odmantic import SyncEngine
client = MongoClient("mongodb://localhost:27017/")
engine = SyncEngine(client=client, database="example_db")
python-odmantic-1.0.2/docs/examples_src/engine/sync/fetch_async_for.py 0000664 0000000 0000000 00000000502 14613034133 0026166 0 ustar 00root root 0000000 0000000 from odmantic import Model, SyncEngine
class Player(Model):
name: str
game: str
engine = SyncEngine()
for player in engine.find(Player, Player.game == "Starcraft"):
print(repr(player))
#> Player(id=ObjectId(...), name='TLO', game='Starcraft')
#> Player(id=ObjectId(...), name='Serral', game='Starcraft')
python-odmantic-1.0.2/docs/examples_src/engine/sync/fetch_await.py 0000664 0000000 0000000 00000000545 14613034133 0025317 0 ustar 00root root 0000000 0000000 from odmantic import SyncEngine, Model
class Player(Model):
name: str
game: str
engine = SyncEngine()
players = list(engine.find(Player, Player.game != "Starcraft"))
print(players)
#> [
#> Player(id=ObjectId(...), name="Leeroy Jenkins", game="World of Warcraft"),
#> Player(id=ObjectId(...), name="Shroud", game="Counter-Strike"),
#> ]
python-odmantic-1.0.2/docs/examples_src/engine/sync/fetch_find_one.py 0000664 0000000 0000000 00000000574 14613034133 0025775 0 ustar 00root root 0000000 0000000 from odmantic import Model, SyncEngine
class Player(Model):
name: str
game: str
engine = SyncEngine()
player = engine.find_one(Player, Player.name == "Serral")
print(repr(player))
#> Player(id=ObjectId(...), name="Serral", game="Starcraft")
another_player = engine.find_one(
Player, Player.name == "Player_Not_Stored_In_Database"
)
print(another_player)
#> None
python-odmantic-1.0.2/docs/examples_src/engine/sync/patch_multiple_fields_dict.py 0000664 0000000 0000000 00000001122 14613034133 0030374 0 ustar 00root root 0000000 0000000 from odmantic import Model, SyncEngine
class Player(Model):
name: str
game: str
engine = SyncEngine()
player_tlo = engine.find_one(Player, Player.name == "TLO")
print(repr(player_tlo))
#> Player(id=ObjectId(...), name='TLO', game='Starcraft')
# Create the patch dictionary containing the new values
patch_object = {"name": "TheLittleOne", "game": "Starcraft II"}
# Update the local instance
player_tlo.model_update(patch_object)
print(repr(player_tlo))
#> Player(id=ObjectId(...), name='TheLittleOne', game='Starcraft II')
# Finally persist the instance
engine.save(player_tlo)
python-odmantic-1.0.2/docs/examples_src/engine/sync/patch_multiple_fields_pydantic.py 0000664 0000000 0000000 00000001405 14613034133 0031270 0 ustar 00root root 0000000 0000000 from pydantic import BaseModel
from odmantic import Model, SyncEngine
class Player(Model):
name: str
game: str
engine = SyncEngine()
player_tlo = engine.find_one(Player, Player.name == "TLO")
print(repr(player_tlo))
#> Player(id=ObjectId(...), name='TLO', game='Starcraft')
# Create the structure of the patch object with pydantic
class PatchPlayerSchema(BaseModel):
name: str
game: str
# Create the patch object containing the new values
patch_object = PatchPlayerSchema(name="TheLittleOne", game="Starcraft II")
# Apply the patch to the instance
player_tlo.model_update(patch_object)
print(repr(player_tlo))
#> Player(id=ObjectId(...), name='TheLittleOne', game='Starcraft II')
# Finally persist again the new instance
engine.save(player_tlo)
python-odmantic-1.0.2/docs/examples_src/engine/sync/primary_key_update.py 0000664 0000000 0000000 00000001001 14613034133 0026722 0 ustar 00root root 0000000 0000000 from bson import ObjectId
from odmantic import SyncEngine, Model
class Player(Model):
name: str
game: str
engine = SyncEngine()
shroud = engine.find_one(Player, Player.name == "Shroud")
print(shroud.id)
#> 5f86074f6dfecacc68428a62
new_id = ObjectId("ffffffffffffffffffffffff")
# Copy the player instance with a new primary key
new_shroud = shroud.copy(update={"id": new_id})
# Delete the initial player instance
engine.delete(shroud)
# Finally persist again the new instance
engine.save(new_shroud)
python-odmantic-1.0.2/docs/examples_src/engine/sync/remove.py 0000664 0000000 0000000 00000000313 14613034133 0024327 0 ustar 00root root 0000000 0000000 from odmantic import SyncEngine, Model
class Player(Model):
name: str
game: str
engine = SyncEngine()
delete_count = engine.remove(Player, Player.game == "Warzone")
print(delete_count)
#> 2
python-odmantic-1.0.2/docs/examples_src/engine/sync/remove_just_one.py 0000664 0000000 0000000 00000000340 14613034133 0026235 0 ustar 00root root 0000000 0000000 from odmantic import Model, SyncEngine
class Player(Model):
name: str
game: str
engine = SyncEngine()
delete_count = engine.remove(
Player, Player.game == "Warzone", just_one=True
)
print(delete_count)
#> 1
python-odmantic-1.0.2/docs/examples_src/engine/sync/save_with_session.py 0000664 0000000 0000000 00000000762 14613034133 0026576 0 ustar 00root root 0000000 0000000 from odmantic import SyncEngine, Model
class Player(Model):
name: str
game: str
engine = SyncEngine()
leeroy = Player(name="Leeroy Jenkins", game="World of Warcraft")
with engine.session() as session:
session.save_all(
[
Player(name="Shroud", game="Counter-Strike"),
Player(name="Serral", game="Starcraft"),
Player(name="TLO", game="Starcraft"),
]
)
player_count = session.count(Player)
print(player_count)
#> 3
python-odmantic-1.0.2/docs/examples_src/engine/sync/save_with_transaction.py 0000664 0000000 0000000 00000000776 14613034133 0027445 0 ustar 00root root 0000000 0000000 from odmantic import Model, SyncEngine
class Player(Model):
name: str
game: str
engine = SyncEngine()
with engine.transaction() as transaction:
transaction.save(Player(name="Leeroy Jenkins", game="WoW"))
transaction.commit()
print(engine.count(Player))
#> 1
with engine.transaction() as transaction:
transaction.save(Player(name="Shroud", game="Counter-Strike"))
transaction.save(Player(name="Serral", game="Starcraft"))
transaction.abort()
print(engine.count(Player))
#> 1
python-odmantic-1.0.2/docs/examples_src/engine/sync/transaction_from_session.py 0000664 0000000 0000000 00000001057 14613034133 0030153 0 ustar 00root root 0000000 0000000 from odmantic import Model, SyncEngine
class Player(Model):
name: str
game: str
engine = SyncEngine()
with engine.session() as session:
leeroy = session.save(Player(name="Leeroy Jenkins", game="WoW"))
shroud = session.save(Player(name="Shroud", game="Counter-Strike"))
with session.transaction() as transaction:
leeroy.game = "Fortnite"
transaction.save(leeroy)
shroud.game = "Fortnite"
transaction.save(shroud)
transaction.commit()
print(engine.count(Player, Player.game == "Fortnite"))
#> 2
python-odmantic-1.0.2/docs/examples_src/engine/sync/update.py 0000664 0000000 0000000 00000000376 14613034133 0024325 0 ustar 00root root 0000000 0000000 from odmantic import SyncEngine, Model
class Player(Model):
name: str
game: str
engine = SyncEngine()
shroud = engine.find_one(Player, Player.name == "Shroud")
print(shroud.game)
#> Counter-Strike
shroud.game = "Valorant"
engine.save(shroud)
python-odmantic-1.0.2/docs/examples_src/fields/ 0000775 0000000 0000000 00000000000 14613034133 0021510 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/examples_src/fields/__init__.py 0000664 0000000 0000000 00000000015 14613034133 0023615 0 ustar 00root root 0000000 0000000 # Helps mypy
python-odmantic-1.0.2/docs/examples_src/fields/async/ 0000775 0000000 0000000 00000000000 14613034133 0022625 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/examples_src/fields/async/custom_key_name.py 0000664 0000000 0000000 00000000255 14613034133 0026363 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Field, Model
class Player(Model):
name: str = Field(key_name="username")
engine = AIOEngine()
await engine.save(Player(name="Jack"))
python-odmantic-1.0.2/docs/examples_src/fields/async/custom_primary_field.py 0000664 0000000 0000000 00000000374 14613034133 0027423 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Field, Model
class Player(Model):
name: str = Field(primary_field=True)
leeroy = Player(name="Leeroy Jenkins")
print(repr(leeroy))
#> Player(name="Leeroy Jenkins")
engine = AIOEngine()
await engine.save(leeroy)
python-odmantic-1.0.2/docs/examples_src/fields/async/indexed_field.py 0000664 0000000 0000000 00000000266 14613034133 0025766 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Field, Model
class Player(Model):
name: str
score: int = Field(index=True)
engine = AIOEngine()
await engine.configure_database([Player])
python-odmantic-1.0.2/docs/examples_src/fields/async/unique_field.py 0000664 0000000 0000000 00000000712 14613034133 0025650 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Field, Model
class Player(Model):
name: str = Field(unique=True)
engine = AIOEngine()
await engine.configure_database([Player])
leeroy = Player(name="Leeroy")
await engine.save(leeroy)
another_leeroy = Player(name="Leeroy")
await engine.save(another_leeroy)
#> Raises odmantic.exceptions.DuplicateKeyError:
#> Duplicate key error for: Player.
#> Instance: id=ObjectId('6314b4c25a19444bfe0c0be5') name='Leeroy'
python-odmantic-1.0.2/docs/examples_src/fields/container_dict.py 0000664 0000000 0000000 00000001061 14613034133 0025045 0 ustar 00root root 0000000 0000000 from typing import Dict, Union
from odmantic import Model
class SimpleDictModel(Model):
field: dict
print(SimpleDictModel(field={18: "a string", True: 42, 18.3: [1, 2, 3]}).field)
#> {18: 'a string', True: 42, 18.3: [1, 2, 3]}
class IntStrDictModel(Model):
field: Dict[int, str]
print(IntStrDictModel(field={1: "one", 2: "two"}).field)
#> {1: 'one', 2: 'two'}
class IntBoolStrDictModel(Model):
field: Dict[int, Union[bool, str]]
print(IntBoolStrDictModel(field={0: False, 1: True, 3: "three"}).field)
#> {0: False, 1: True, 3: 'three'}
python-odmantic-1.0.2/docs/examples_src/fields/container_list.py 0000664 0000000 0000000 00000001104 14613034133 0025073 0 ustar 00root root 0000000 0000000 from typing import List, Union
from odmantic import Model
class SimpleListModel(Model):
field: list
print(SimpleListModel(field=[1, "a", True]).field)
#> [1, 'a', True]
print(SimpleListModel(field=(1, "a", True)).field)
#> [1, 'a', True]
class IntListModel(Model):
field: List[int]
print(IntListModel(field=[1, 5]).field)
#> [1, 5]
print(IntListModel(field=(1, 5)).field)
#> [1, 5]
class IntStrListModel(Model):
field: List[Union[int, str]]
print(IntStrListModel(field=[1, "b"]).field)
#> [1, 'b']
print(IntStrListModel(field=(1, "b")).field)
#> [1, 'b']
python-odmantic-1.0.2/docs/examples_src/fields/container_tuple.py 0000664 0000000 0000000 00000001116 14613034133 0025254 0 ustar 00root root 0000000 0000000 from typing import Tuple
from odmantic import Model
class SimpleTupleModel(Model):
field: tuple
print(SimpleTupleModel(field=[1, "a", True]).field)
#> (1, 'a', True)
print(SimpleTupleModel(field=(1, "a", True)).field)
#> (1, 'a', True)
class TwoIntTupleModel(Model):
field: Tuple[int, int]
print(SimpleTupleModel(field=(1, 10)).field)
#> (1, 10)
print(SimpleTupleModel(field=[1, 10]).field)
#> (1, 10)
class IntTupleModel(Model):
field: Tuple[int, ...]
print(IntTupleModel(field=(1,)).field)
#> (1,)
print(IntTupleModel(field=[1, 2, 3, 10]).field)
#> (1, 2, 3, 10)
python-odmantic-1.0.2/docs/examples_src/fields/custom_bson_serialization.py 0000664 0000000 0000000 00000002070 14613034133 0027351 0 ustar 00root root 0000000 0000000 from typing import Annotated
from odmantic import AIOEngine, Model, WithBsonSerializer
class ASCIISerializedAsBinaryBase(str):
@classmethod
def __get_validators__(cls):
yield cls.validate
@classmethod
def validate(cls, v):
if isinstance(v, bytes): # Handle data coming from MongoDB
return v.decode("ascii")
if not isinstance(v, str):
raise TypeError("string required")
if not v.isascii():
raise ValueError("Only ascii characters are allowed")
return v
def serialize_ascii_to_bytes(v: ASCIISerializedAsBinaryBase) -> bytes:
# We can encode this string as ascii since it contains
# only ascii characters
bytes_ = v.encode("ascii")
return bytes_
ASCIISerializedAsBinary = Annotated[
ASCIISerializedAsBinaryBase, WithBsonSerializer(serialize_ascii_to_bytes)
]
class Example(Model):
field: ASCIISerializedAsBinary
engine = AIOEngine()
await engine.save(Example(field="hello world"))
fetched = await engine.find_one(Example)
print(fetched.field)
#> hello world
python-odmantic-1.0.2/docs/examples_src/fields/custom_field_validators.py 0000664 0000000 0000000 00000002553 14613034133 0026774 0 ustar 00root root 0000000 0000000 from typing import ClassVar
from pydantic import ValidationError, validator
from odmantic import Model
class SmallRectangle(Model):
MAX_SIDE_SIZE: ClassVar[float] = 10
length: float
width: float
@validator("width", "length")
def check_small_sides(cls, v):
if v > cls.MAX_SIDE_SIZE:
raise ValueError(f"side is greater than {cls.MAX_SIDE_SIZE}")
return v
@validator("width")
def check_width_length(cls, width, values, **kwargs):
length = values.get("length")
if length is not None and width > length:
raise ValueError("width can't be greater than length")
return width
print(SmallRectangle(length=2, width=1))
#> id=ObjectId('5f81e3c073103f509f97e374'), length=2.0, width=1.0
try:
SmallRectangle(length=2)
except ValidationError as e:
print(e)
"""
1 validation error for SmallRectangle
width
field required (type=value_error.missing)
"""
try:
SmallRectangle(length=2, width=3)
except ValidationError as e:
print(e)
"""
1 validation error for SmallRectangle
width
width can't be greater than length (type=value_error)
"""
try:
SmallRectangle(length=40, width=3)
except ValidationError as e:
print(e)
"""
1 validation error for SmallRectangle
length
side is greater than 10 (type=value_error)
"""
python-odmantic-1.0.2/docs/examples_src/fields/default_value.py 0000664 0000000 0000000 00000000305 14613034133 0024700 0 ustar 00root root 0000000 0000000 from odmantic import Model
class Player(Model):
name: str
level: int = 0
p = Player(name="Dash")
print(repr(p))
#> Player(id=ObjectId('5f7cd4be16af832772f1615e'), name='Dash', level=0)
python-odmantic-1.0.2/docs/examples_src/fields/default_value_field.py 0000664 0000000 0000000 00000000341 14613034133 0026043 0 ustar 00root root 0000000 0000000 from odmantic import Field, Model
class Player(Model):
name: str
level: int = Field(default=1, ge=1)
p = Player(name="Dash")
print(repr(p))
#> Player(id=ObjectId('5f7cdbfbb54a94e9e8717c77'), name='Dash', level=1)
python-odmantic-1.0.2/docs/examples_src/fields/enum.py 0000664 0000000 0000000 00000000756 14613034133 0023036 0 ustar 00root root 0000000 0000000 from enum import Enum
from odmantic import AIOEngine, Model
class TreeKind(str, Enum):
BIG = "big"
SMALL = "small"
class Tree(Model):
name: str
kind: TreeKind
sequoia = Tree(name="Sequoia", kind=TreeKind.BIG)
print(sequoia.kind)
#> TreeKind.BIG
print(sequoia.kind == "big")
#> True
spruce = Tree(name="Spruce", kind="small")
print(spruce.kind)
#> TreeKind.SMALL
print(spruce.kind == TreeKind.SMALL)
#> True
engine = AIOEngine()
await engine.save_all([sequoia, spruce])
python-odmantic-1.0.2/docs/examples_src/fields/inconsistent_enum_1.py 0000664 0000000 0000000 00000000217 14613034133 0026046 0 ustar 00root root 0000000 0000000 from enum import Enum, auto
class Color(Enum):
RED = auto()
BLUE = auto()
print(Color.RED.value)
#> 1
print(Color.BLUE.value)
#> 2
python-odmantic-1.0.2/docs/examples_src/fields/inconsistent_enum_2.py 0000664 0000000 0000000 00000000300 14613034133 0026040 0 ustar 00root root 0000000 0000000 from enum import Enum, auto
class Color(Enum):
RED = auto()
GREEN = auto()
BLUE = auto()
print(Color.RED.value)
#> 1
print(Color.GREEN.value)
#> 2
print(Color.BLUE.value)
#> 3
python-odmantic-1.0.2/docs/examples_src/fields/objectid.py 0000664 0000000 0000000 00000000400 14613034133 0023637 0 ustar 00root root 0000000 0000000 from odmantic import Model
class Player(Model):
name: str
leeroy = Player(name="Leeroy Jenkins")
print(leeroy.id)
#> ObjectId('5ed50fcad11d1975aa3d7a28')
print(repr(leeroy))
#> Player(id=ObjectId('5ed50fcad11d1975aa3d7a28'), name="Leeroy Jenkins")
python-odmantic-1.0.2/docs/examples_src/fields/optional.py 0000664 0000000 0000000 00000000257 14613034133 0023713 0 ustar 00root root 0000000 0000000 from typing import Optional
from odmantic import Model
class Person(Model):
name: str
age: Optional[int] = None
john = Person(name="John")
print(john.age)
#> None
python-odmantic-1.0.2/docs/examples_src/fields/sync/ 0000775 0000000 0000000 00000000000 14613034133 0022464 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/examples_src/fields/sync/custom_key_name.py 0000664 0000000 0000000 00000000251 14613034133 0026216 0 ustar 00root root 0000000 0000000 from odmantic import SyncEngine, Field, Model
class Player(Model):
name: str = Field(key_name="username")
engine = SyncEngine()
engine.save(Player(name="Jack"))
python-odmantic-1.0.2/docs/examples_src/fields/sync/custom_primary_field.py 0000664 0000000 0000000 00000000370 14613034133 0027256 0 ustar 00root root 0000000 0000000 from odmantic import SyncEngine, Field, Model
class Player(Model):
name: str = Field(primary_field=True)
leeroy = Player(name="Leeroy Jenkins")
print(repr(leeroy))
#> Player(name="Leeroy Jenkins")
engine = SyncEngine()
engine.save(leeroy)
python-odmantic-1.0.2/docs/examples_src/fields/sync/indexed_field.py 0000664 0000000 0000000 00000000262 14613034133 0025621 0 ustar 00root root 0000000 0000000 from odmantic import Field, Model, SyncEngine
class Player(Model):
name: str
score: int = Field(index=True)
engine = SyncEngine()
engine.configure_database([Player])
python-odmantic-1.0.2/docs/examples_src/fields/sync/unique_field.py 0000664 0000000 0000000 00000000672 14613034133 0025514 0 ustar 00root root 0000000 0000000 from odmantic import Field, Model, SyncEngine
class Player(Model):
name: str = Field(unique=True)
engine = SyncEngine()
engine.configure_database([Player])
leeroy = Player(name="Leeroy")
engine.save(leeroy)
another_leeroy = Player(name="Leeroy")
engine.save(another_leeroy)
#> Raises odmantic.exceptions.DuplicateKeyError:
#> Duplicate key error for: Player.
#> Instance: id=ObjectId('6314b4c25a19444bfe0c0be5') name='Leeroy'
python-odmantic-1.0.2/docs/examples_src/fields/union.py 0000664 0000000 0000000 00000000360 14613034133 0023211 0 ustar 00root root 0000000 0000000 from typing import Union
from odmantic import Model
class Thing(Model):
ref_id: Union[int, str]
thing_1 = Thing(ref_id=42)
print(thing_1.ref_id)
#> 42
thing_2 = Thing(ref_id="i am a string")
print(thing_2.ref_id)
#> i am a string
python-odmantic-1.0.2/docs/examples_src/fields/validation_field_descriptor.py 0000664 0000000 0000000 00000001015 14613034133 0027612 0 ustar 00root root 0000000 0000000 from typing import List
from odmantic import Field, Model
class ExampleModel(Model):
small_int: int = Field(le=10)
big_int: int = Field(gt=1000)
even_int: int = Field(multiple_of=2)
small_float: float = Field(lt=10)
big_float: float = Field(ge=1e10)
short_string: str = Field(max_length=10)
long_string: str = Field(min_length=100)
string_starting_with_the: str = Field(regex=r"^The")
short_str_list: List[str] = Field(max_items=5)
long_str_list: List[str] = Field(min_items=15)
python-odmantic-1.0.2/docs/examples_src/fields/validation_strict_types.py 0000664 0000000 0000000 00000000305 14613034133 0027026 0 ustar 00root root 0000000 0000000 from pydantic import StrictBool, StrictFloat, StrictStr
from odmantic import Model
class ExampleModel(Model):
strict_bool: StrictBool
strict_float: StrictFloat
strict_str: StrictStr
python-odmantic-1.0.2/docs/examples_src/modeling/ 0000775 0000000 0000000 00000000000 14613034133 0022040 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/examples_src/modeling/__init__.py 0000664 0000000 0000000 00000000015 14613034133 0024145 0 ustar 00root root 0000000 0000000 # Helps mypy
python-odmantic-1.0.2/docs/examples_src/modeling/async/ 0000775 0000000 0000000 00000000000 14613034133 0023155 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/examples_src/modeling/async/index_creation.py 0000664 0000000 0000000 00000000220 14613034133 0026514 0 ustar 00root root 0000000 0000000 # ... Continuation of the previous snippet ...
from odmantic import AIOEngine
engine = AIOEngine()
await engine.configure_database([Product])
python-odmantic-1.0.2/docs/examples_src/modeling/compound_index.py 0000664 0000000 0000000 00000000562 14613034133 0025430 0 ustar 00root root 0000000 0000000 from odmantic import Field, Index, Model
class Product(Model):
name: str = Field(index=True)
stock: int
category: str
sku: str = Field(unique=True)
model_config = {
"indexes": lambda: [
Index(Product.name, Product.stock, name="name_stock_index"),
Index(Product.name, Product.category, unique=True),
]
}
python-odmantic-1.0.2/docs/examples_src/modeling/compound_index_sort_order.py 0000664 0000000 0000000 00000000371 14613034133 0027670 0 ustar 00root root 0000000 0000000 from datetime import datetime
from odmantic import Index, Model
from odmantic.query import asc, desc
class Event(Model):
username: str
date: datetime
model_config = {"indexes": lambda: [Index(asc(Event.username), desc(Event.date))]}
python-odmantic-1.0.2/docs/examples_src/modeling/custom_text_index.py 0000664 0000000 0000000 00000000436 14613034133 0026162 0 ustar 00root root 0000000 0000000 import pymongo
from odmantic import Model
class Post(Model):
title: str
content: str
model_config = {
"indexes": lambda: [
pymongo.IndexModel(
[(+Post.title, pymongo.TEXT), (+Post.content, pymongo.TEXT)]
)
]
}
python-odmantic-1.0.2/docs/examples_src/modeling/custom_validators.py 0000664 0000000 0000000 00000002675 14613034133 0026166 0 ustar 00root root 0000000 0000000 from typing import ClassVar
from pydantic import ValidationError, model_validator
from odmantic import Model
class SmallRectangle(Model):
MAX_AREA: ClassVar[float] = 9
length: float
width: float
@model_validator(mode="before")
def check_width_length(cls, values):
length = values.get("length", 0)
width = values.get("width", 0)
if width > length:
raise ValueError("width can't be greater than length")
return values
@model_validator(mode="before")
def check_area(cls, values):
length = values.get("length", 0)
width = values.get("width", 0)
if length * width > cls.MAX_AREA:
raise ValueError(f"area is greater than {cls.MAX_AREA}")
return values
print(SmallRectangle(length=2, width=1))
# > id=ObjectId('5f81e3c073103f509f97e374'), length=2.0, width=1.0
try:
SmallRectangle(length=2)
except ValidationError as e:
print(e)
"""
1 validation error for SmallRectangle
width
field required (type=value_error.missing)
"""
try:
SmallRectangle(length=2, width=3)
except ValidationError as e:
print(e)
"""
1 validation error for SmallRectangle
Value error, width can't be greater than length
"""
try:
SmallRectangle(length=4, width=3)
except ValidationError as e:
print(e)
"""
1 validation error for SmallRectangle
__root__
Value error, area is greater than 9
"""
python-odmantic-1.0.2/docs/examples_src/modeling/many_to_many.py 0000664 0000000 0000000 00000001136 14613034133 0025105 0 ustar 00root root 0000000 0000000 from typing import List
from bson import ObjectId
from odmantic import AIOEngine, Model
class Author(Model):
name: str
class Book(Model):
title: str
pages: int
author_ids: List[ObjectId]
david = Author(name="David Beazley")
brian = Author(name="Brian K. Jones")
python_cookbook = Book(
title="Python Cookbook", pages=706, author_ids=[david.id, brian.id]
)
python_essentials = Book(
title="Python Essential Reference", pages=717, author_ids=[brian.id]
)
engine = AIOEngine()
await engine.save_all((david, brian))
await engine.save_all((python_cookbook, python_essentials))
python-odmantic-1.0.2/docs/examples_src/modeling/many_to_many_1.py 0000664 0000000 0000000 00000000472 14613034133 0025327 0 ustar 00root root 0000000 0000000 book = await engine.find_one(Book, Book.title == "Python Cookbook")
authors = await engine.find(Author, Author.id.in_(book.author_ids))
print(authors)
#> [
#> Author(id=ObjectId("5f7a37dc7311be1362e1da4e"), name="David Beazley"),
#> Author(id=ObjectId("5f7a37dc7311be1362e1da4f"), name="Brian K. Jones"),
#> ]
python-odmantic-1.0.2/docs/examples_src/modeling/many_to_one.py 0000664 0000000 0000000 00000001172 14613034133 0024722 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model, Reference
class Publisher(Model):
name: str
founded: int
location: str
class Book(Model):
title: str
pages: int
publisher: Publisher = Reference()
hachette = Publisher(name="Hachette Livre", founded=1826, location="FR")
harper = Publisher(name="HarperCollins", founded=1989, location="US")
books = [
Book(title="They Didn't See Us Coming", pages=304, publisher=hachette),
Book(title="This Isn't Happening", pages=256, publisher=hachette),
Book(title="Prodigal Summer", pages=464, publisher=harper),
]
engine = AIOEngine()
await engine.save_all(books)
python-odmantic-1.0.2/docs/examples_src/modeling/one_to_many.py 0000664 0000000 0000000 00000001237 14613034133 0024724 0 ustar 00root root 0000000 0000000 from typing import List
from odmantic import AIOEngine, EmbeddedModel, Model
class Address(EmbeddedModel):
street: str
city: str
state: str
zipcode: str
class Customer(Model):
name: str
addresses: List[Address]
customer = Customer(
name="John Doe",
addresses=[
Address(
street="1757 Birch Street",
city="Greenwood",
state="Indiana",
zipcode="46142",
),
Address(
street="262 Barnes Avenue",
city="Cincinnati",
state="Ohio",
zipcode="45216",
),
],
)
engine = AIOEngine()
await engine.save(customer)
python-odmantic-1.0.2/docs/examples_src/modeling/one_to_one.py 0000664 0000000 0000000 00000001101 14613034133 0024527 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, EmbeddedModel, Model
class CapitalCity(EmbeddedModel):
name: str
population: int
class Country(Model):
name: str
currency: str
capital_city: CapitalCity
countries = [
Country(
name="Switzerland",
currency="Swiss franc",
capital_city=CapitalCity(name="Bern", population=1035000),
),
Country(
name="Sweden",
currency="Swedish krona",
capital_city=CapitalCity(name="Stockholm", population=975904),
),
]
engine = AIOEngine()
await engine.save_all(countries)
python-odmantic-1.0.2/docs/examples_src/modeling/one_to_one_1.py 0000664 0000000 0000000 00000000415 14613034133 0024756 0 ustar 00root root 0000000 0000000 await engine.find_one(
Country, Country.capital_city.name == "Stockholm"
)
#> Country(
#> id=ObjectId("5f79d7e8b305f24ca43593e2"),
#> name="Sweden",
#> currency="Swedish krona",
#> capital_city=CapitalCity(name="Stockholm", population=975904),
#> )
python-odmantic-1.0.2/docs/examples_src/modeling/sync/ 0000775 0000000 0000000 00000000000 14613034133 0023014 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/examples_src/modeling/sync/index_creation.py 0000664 0000000 0000000 00000000214 14613034133 0026356 0 ustar 00root root 0000000 0000000 # ... Continuation of the previous snippet ...
from odmantic import SyncEngine
engine = SyncEngine()
engine.configure_database([Product])
python-odmantic-1.0.2/docs/examples_src/querying/ 0000775 0000000 0000000 00000000000 14613034133 0022105 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/examples_src/querying/__init__.py 0000664 0000000 0000000 00000000015 14613034133 0024212 0 ustar 00root root 0000000 0000000 # Helps mypy
python-odmantic-1.0.2/docs/examples_src/querying/and.py 0000664 0000000 0000000 00000000614 14613034133 0023222 0 ustar 00root root 0000000 0000000 from odmantic import Model, query
class Tree(Model):
name: str
size: float
(Tree.name == "Spruce") & (Tree.size <= 2)
#> QueryExpression(
#> {
#> "$and": (
#> QueryExpression({"name": {"$eq": "Spruce"}}),
#> QueryExpression({"size": {"$lte": 2}}),
#> )
#> }
#> )
query.and_(Tree.name == "Spruce", Tree.size <= 2)
#> ... same output ...
python-odmantic-1.0.2/docs/examples_src/querying/asc.py 0000664 0000000 0000000 00000000576 14613034133 0023235 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model, query
engine = AIOEngine()
class Tree(Model):
name: str
average_size: float
# The following queries are equivalent,
# they will sort `Tree` by ascending `average_size`
await engine.find(Tree, sort=Tree.average_size)
await engine.find(Tree, sort=Tree.average_size.asc())
await engine.find(Tree, sort=query.asc(Tree.average_size))
python-odmantic-1.0.2/docs/examples_src/querying/desc.py 0000664 0000000 0000000 00000000521 14613034133 0023373 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model, query
engine = AIOEngine()
class Tree(Model):
name: str
average_size: float
# The following queries are equivalent,
# they will sort `Tree` by descending `average_size`
await engine.find(Tree, sort=Tree.average_size.desc())
await engine.find(Tree, sort=query.desc(Tree.average_size))
python-odmantic-1.0.2/docs/examples_src/querying/embedded.py 0000664 0000000 0000000 00000000640 14613034133 0024210 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, EmbeddedModel, Model
class CapitalCity(EmbeddedModel):
name: str
population: int
class Country(Model):
name: str
currency: str
capital_city: CapitalCity
Country.capital_city.name == "Paris"
#> QueryExpression({'capital_city.name': {'$eq': 'Paris'}})
Country.capital_city.population > 10 ** 6
#> QueryExpression({'capital_city.population': {'$gt': 1000000}})
python-odmantic-1.0.2/docs/examples_src/querying/embedded_sort.py 0000664 0000000 0000000 00000000517 14613034133 0025262 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, EmbeddedModel, Model
from odmantic.query import desc
class CapitalCity(EmbeddedModel):
name: str
population: int
class Country(Model):
name: str
currency: str
capital_city: CapitalCity
engine = AIOEngine()
await engine.find(Country, sort=desc(Country.capital_city.population))
python-odmantic-1.0.2/docs/examples_src/querying/enum.py 0000664 0000000 0000000 00000000664 14613034133 0023431 0 ustar 00root root 0000000 0000000 from enum import Enum
from odmantic import Model, query
class TreeKind(str, Enum):
BIG = "big"
SMALL = "small"
class Tree(Model):
name: str
average_size: float
kind: TreeKind
Tree.kind == TreeKind.SMALL
#> QueryExpression({'kind': {'$eq': 'small'}})
Tree.kind.eq(TreeKind.SMALL)
#> QueryExpression({'kind': {'$eq': 'small'}})
query.eq(Tree.kind, TreeKind.SMALL)
#> QueryExpression({'kind': {'$eq': 'small'}})
python-odmantic-1.0.2/docs/examples_src/querying/equal.py 0000664 0000000 0000000 00000000472 14613034133 0023571 0 ustar 00root root 0000000 0000000 from odmantic import Model, query
class Tree(Model):
name: str
average_size: float
Tree.name == "Spruce"
#> QueryExpression({'name': {'$eq': 'Spruce'}})
Tree.name.eq("Spruce")
#> QueryExpression({'name': {'$eq': 'Spruce'}})
query.eq(Tree.name, "Spruce")
#> QueryExpression({'name': {'$eq': 'Spruce'}})
python-odmantic-1.0.2/docs/examples_src/querying/gt_e.py 0000664 0000000 0000000 00000001046 14613034133 0023376 0 ustar 00root root 0000000 0000000 from odmantic import Model, query
class Tree(Model):
name: str
average_size: float
Tree.average_size > 2
#> QueryExpression({'average_size': {'$gt': 2}})
Tree.average_size.gt(2)
#> QueryExpression({'average_size': {'$gt': 2}})
query.gt(Tree.average_size, 2)
#> QueryExpression({'average_size': {'$gt': 2}})
Tree.average_size >= 2
#> QueryExpression({'average_size': {'$gte': 2}})
Tree.average_size.gte(2)
#> QueryExpression({'average_size': {'$gte': 2}})
query.gte(Tree.average_size, 2)
#> QueryExpression({'average_size': {'$gte': 2}})
python-odmantic-1.0.2/docs/examples_src/querying/in.py 0000664 0000000 0000000 00000000436 14613034133 0023070 0 ustar 00root root 0000000 0000000 from odmantic import Model, query
class Tree(Model):
name: str
average_size: float
Tree.name.in_(["Spruce", "Pine"])
#> QueryExpression({'name': {'$in': ['Spruce', 'Pine']}})
query.in_(Tree.name, ["Spruce", "Pine"])
#> QueryExpression({'name': {'$in': ['Spruce', 'Pine']}})
python-odmantic-1.0.2/docs/examples_src/querying/lt_e.py 0000664 0000000 0000000 00000001046 14613034133 0023403 0 ustar 00root root 0000000 0000000 from odmantic import Model, query
class Tree(Model):
name: str
average_size: float
Tree.average_size < 2
#> QueryExpression({'average_size': {'$lt': 2}})
Tree.average_size.lt(2)
#> QueryExpression({'average_size': {'$lt': 2}})
query.lt(Tree.average_size, 2)
#> QueryExpression({'average_size': {'$lt': 2}})
Tree.average_size <= 2
#> QueryExpression({'average_size': {'$lte': 2}})
Tree.average_size.lte(2)
#> QueryExpression({'average_size': {'$lte': 2}})
query.lte(Tree.average_size, 2)
#> QueryExpression({'average_size': {'$lte': 2}})
python-odmantic-1.0.2/docs/examples_src/querying/match.py 0000664 0000000 0000000 00000000356 14613034133 0023557 0 ustar 00root root 0000000 0000000 from odmantic import Model, query
class Tree(Model):
name: str
Tree.name.match(r"^Spruce")
#> QueryExpression({'name': re.compile('^Spruce')})
query.match(Tree.name, r"^Spruce")
#> QueryExpression({'name': re.compile('^Spruce')})
python-odmantic-1.0.2/docs/examples_src/querying/multiple_sort.py 0000664 0000000 0000000 00000000473 14613034133 0025365 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model, query
engine = AIOEngine()
class Tree(Model):
name: str
average_size: float
# This query will first sort on ascending `average_size`, then
# on descending `name` when `average_size` is the same
await engine.find(Tree, sort=(Tree.average_size, Tree.name.desc()))
python-odmantic-1.0.2/docs/examples_src/querying/nor.py 0000664 0000000 0000000 00000000510 14613034133 0023251 0 ustar 00root root 0000000 0000000 from odmantic import Model, query
class Tree(Model):
name: str
size: float
query.nor_(Tree.name == "Spruce", Tree.size > 2)
#> QueryExpression(
#> {
#> "$nor": (
#> QueryExpression({"name": {"$eq": "Spruce"}}),
#> QueryExpression({"size": {"$gt": 2}}),
#> )
#> }
#> )
python-odmantic-1.0.2/docs/examples_src/querying/not_equal.py 0000664 0000000 0000000 00000000472 14613034133 0024451 0 ustar 00root root 0000000 0000000 from odmantic import Model, query
class Tree(Model):
name: str
average_size: float
Tree.name != "Spruce"
#> QueryExpression({'name': {'$ne': 'Spruce'}})
Tree.name.ne("Spruce")
#> QueryExpression({'name': {'$ne': 'Spruce'}})
query.ne(Tree.name, "Spruce")
#> QueryExpression({'name': {'$ne': 'Spruce'}})
python-odmantic-1.0.2/docs/examples_src/querying/not_in.py 0000664 0000000 0000000 00000000446 14613034133 0023751 0 ustar 00root root 0000000 0000000 from odmantic import Model, query
class Tree(Model):
name: str
average_size: float
Tree.name.not_in(["Spruce", "Pine"])
#> QueryExpression({'name': {'$nin': ['Spruce', 'Pine']}})
query.not_in(Tree.name, ["Spruce", "Pine"])
#> QueryExpression({'name': {'$nin': ['Spruce', 'Pine']}})
python-odmantic-1.0.2/docs/examples_src/querying/or.py 0000664 0000000 0000000 00000000607 14613034133 0023102 0 ustar 00root root 0000000 0000000 from odmantic import Model, query
class Tree(Model):
name: str
size: float
(Tree.name == "Spruce") | (Tree.size > 2)
#> QueryExpression(
#> {
#> "$or": (
#> QueryExpression({"name": {"$eq": "Spruce"}}),
#> QueryExpression({"size": {"$gt": 2}}),
#> )
#> }
#> )
query.or_(Tree.name == "Spruce", Tree.size > 2)
#> ... same output ...
python-odmantic-1.0.2/docs/examples_src/raw_query_usage/ 0000775 0000000 0000000 00000000000 14613034133 0023444 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/examples_src/raw_query_usage/__init__.py 0000664 0000000 0000000 00000000015 14613034133 0025551 0 ustar 00root root 0000000 0000000 # Helps mypy
python-odmantic-1.0.2/docs/examples_src/raw_query_usage/aggregation_example.py 0000664 0000000 0000000 00000002415 14613034133 0030022 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model
class Rectangle(Model):
length: float
width: float
rectangles = [
Rectangle(length=0.1, width=1),
Rectangle(length=3.5, width=1),
Rectangle(length=2.87, width=5.19),
Rectangle(length=1, width=10),
Rectangle(length=0.1, width=100),
]
engine = AIOEngine()
await engine.save_all(rectangles)
collection = engine.get_collection(Rectangle)
pipeline = []
# Add an area field
pipeline.append(
{
"$addFields": {
"area": {
"$multiply": [++Rectangle.length, ++Rectangle.width]
} # Compute the area remotely
}
}
)
# Filter only rectanges with an area lower than 10
pipeline.append({"$match": {"area": {"$lt": 10}}})
# Project to keep only the defined fields (this step is optional)
pipeline.append(
{
"$project": {
+Rectangle.length: True,
+Rectangle.width: True,
} # Specifying "area": False is unnecessary here
}
)
documents = await collection.aggregate(pipeline).to_list(length=None)
small_rectangles = [Rectangle.model_validate_doc(doc) for doc in documents]
print(small_rectangles)
#> [
#> Rectangle(id=ObjectId("..."), length=0.1, width=1.0),
#> Rectangle(id=ObjectId("..."), length=3.5, width=1.0),
#> ]
python-odmantic-1.0.2/docs/examples_src/raw_query_usage/collection_name.py 0000664 0000000 0000000 00000000167 14613034133 0027155 0 ustar 00root root 0000000 0000000 from odmantic import Model
class User(Model):
name: str
collection_name = +User
print(collection_name)
#> user
python-odmantic-1.0.2/docs/examples_src/raw_query_usage/create_from_raw.py 0000664 0000000 0000000 00000000506 14613034133 0027156 0 ustar 00root root 0000000 0000000 from bson import ObjectId
from odmantic import Field, Model
class User(Model):
name: str = Field(key_name="username")
document = {"username": "John", "_id": ObjectId("5f8352a87a733b8b18b0cb27")}
user = User.model_validate_doc(document)
print(repr(user))
#> User(id=ObjectId('5f8352a87a733b8b18b0cb27'), name='John')
python-odmantic-1.0.2/docs/examples_src/raw_query_usage/extract_from_existing.py 0000664 0000000 0000000 00000000337 14613034133 0030430 0 ustar 00root root 0000000 0000000 from odmantic import Field, Model
class User(Model):
name: str = Field(key_name="username")
user = User(name="John")
print(user.model_dump_doc())
#> {'username': 'John', '_id': ObjectId('5f8352a87a733b8b18b0cb27')}
python-odmantic-1.0.2/docs/examples_src/raw_query_usage/field_key_name.py 0000664 0000000 0000000 00000000243 14613034133 0026750 0 ustar 00root root 0000000 0000000 from odmantic import Field, Model
class User(Model):
name: str = Field(key_name="username")
print(+User.name)
#> username
print(++User.name)
#> $username
python-odmantic-1.0.2/docs/examples_src/raw_query_usage/motor_collection.py 0000664 0000000 0000000 00000001121 14613034133 0027364 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model
class User(Model):
name: str
engine = AIOEngine()
motor_collection = engine.get_collection(User)
print(motor_collection)
#> AsyncIOMotorCollection(
#> Collection(
#> Database(
#> MongoClient(
#> host=["localhost:27017"],
#> document_class=dict,
#> tz_aware=False,
#> connect=False,
#> driver=DriverInfo(name="Motor", version="2.2.0", platform="asyncio"),
#> ),
#> "test",
#> ),
#> "user",
#> )
#> )
python-odmantic-1.0.2/docs/examples_src/raw_query_usage/parse_with_unset_default.py 0000664 0000000 0000000 00000000537 14613034133 0031112 0 ustar 00root root 0000000 0000000 from bson import ObjectId
from odmantic import Model
class Player(Model):
name: str
level: int = 1
document = {"name": "Leeroy", "_id": ObjectId("5f8352a87a733b8b18b0cb27")}
user = Player.model_validate_doc(document)
print(repr(user))
#> Player(
#> id=ObjectId("5f8352a87a733b8b18b0cb27"),
#> name="Leeroy",
#> level=1,
#> )
python-odmantic-1.0.2/docs/examples_src/raw_query_usage/parse_with_unset_default_factory.py 0000664 0000000 0000000 00000001230 14613034133 0032630 0 ustar 00root root 0000000 0000000 from datetime import datetime
from bson import ObjectId
from odmantic import Model
from odmantic.exceptions import DocumentParsingError
from odmantic.field import Field
class User(Model):
name: str
created_at: datetime = Field(default_factory=datetime.utcnow)
document = {"name": "Leeroy", "_id": ObjectId("5f8352a87a733b8b18b0cb27")}
try:
User.model_validate_doc(document)
except DocumentParsingError as e:
print(e)
#> 1 validation error for User
#> created_at
#> key not found in document (type=value_error.keynotfoundindocument; key_name='created_at')
#> (User instance details: id=ObjectId('5f8352a87a733b8b18b0cb27'))
python-odmantic-1.0.2/docs/examples_src/raw_query_usage/parse_with_unset_default_factory_enabled.py 0000664 0000000 0000000 00000001162 14613034133 0034306 0 ustar 00root root 0000000 0000000 from datetime import datetime
from bson import ObjectId
from odmantic import Model
from odmantic.exceptions import DocumentParsingError
from odmantic.field import Field
class User(Model):
name: str
updated_at: datetime = Field(default_factory=datetime.utcnow)
model_config = {"parse_doc_with_default_factories": True}
document = {"name": "Leeroy", "_id": ObjectId("5f8352a87a733b8b18b0cb27")}
user = User.model_validate_doc(document)
print(repr(user))
#> User(
#> id=ObjectId("5f8352a87a733b8b18b0cb27"),
#> name="Leeroy",
#> updated_at=datetime.datetime(2020, 11, 8, 23, 28, 19, 980000),
#> )
python-odmantic-1.0.2/docs/examples_src/raw_query_usage/pymongo_collection.py 0000664 0000000 0000000 00000000635 14613034133 0027725 0 ustar 00root root 0000000 0000000 from odmantic import SyncEngine, Model
class User(Model):
name: str
engine = SyncEngine()
collection = engine.get_collection(User)
print(collection)
#> Collection(
#> Database(
#> MongoClient(
#> host=["localhost:27017"],
#> document_class=dict,
#> tz_aware=False,
#> connect=True,
#> ),
#> "test",
#> ),
#> "user",
#> )
python-odmantic-1.0.2/docs/examples_src/raw_query_usage/raw_query_filters.py 0000664 0000000 0000000 00000000170 14613034133 0027562 0 ustar 00root root 0000000 0000000 from odmantic import AIOEngine, Model
class Tree(Model):
name: str
average_size: float
engine = AIOEngine()
python-odmantic-1.0.2/docs/examples_src/raw_query_usage/raw_query_filters_1.py 0000664 0000000 0000000 00000000215 14613034133 0030002 0 ustar 00root root 0000000 0000000 engine.find(Tree, Tree.average_size > 2)
engine.find(Tree, {+Tree.average_size: {"$gt": 2}})
engine.find(Tree, {"average_size": {"$gt": 2}})
python-odmantic-1.0.2/docs/examples_src/usage_fastapi/ 0000775 0000000 0000000 00000000000 14613034133 0023055 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/examples_src/usage_fastapi/__init__.py 0000664 0000000 0000000 00000000015 14613034133 0025162 0 ustar 00root root 0000000 0000000 # Helps mypy
python-odmantic-1.0.2/docs/examples_src/usage_fastapi/base_example.py 0000664 0000000 0000000 00000001470 14613034133 0026056 0 ustar 00root root 0000000 0000000 from typing import List
from fastapi import FastAPI, HTTPException
from odmantic import AIOEngine, Model, ObjectId
class Tree(Model):
name: str
average_size: float
discovery_year: int
app = FastAPI()
engine = AIOEngine()
@app.put("/trees/", response_model=Tree)
async def create_tree(tree: Tree):
await engine.save(tree)
return tree
@app.get("/trees/", response_model=List[Tree])
async def get_trees():
trees = await engine.find(Tree)
return trees
@app.get("/trees/count", response_model=int)
async def count_trees():
count = await engine.count(Tree)
return count
@app.get("/trees/{id}", response_model=Tree)
async def get_tree_by_id(id: ObjectId):
tree = await engine.find_one(Tree, Tree.id == id)
if tree is None:
raise HTTPException(404)
return tree
python-odmantic-1.0.2/docs/examples_src/usage_fastapi/example_delete.py 0000664 0000000 0000000 00000001260 14613034133 0026403 0 ustar 00root root 0000000 0000000 import uvicorn
from fastapi import FastAPI, HTTPException
from odmantic import AIOEngine, Model, ObjectId
class Tree(Model):
name: str
average_size: float
discovery_year: int
app = FastAPI()
engine = AIOEngine()
@app.get("/trees/{id}", response_model=Tree)
async def get_tree_by_id(id: ObjectId):
tree = await engine.find_one(Tree, Tree.id == id)
if tree is None:
raise HTTPException(404)
return tree
@app.delete("/trees/{id}", response_model=Tree)
async def delete_tree_by_id(id: ObjectId):
tree = await engine.find_one(Tree, Tree.id == id)
if tree is None:
raise HTTPException(404)
await engine.delete(tree)
return tree
python-odmantic-1.0.2/docs/examples_src/usage_fastapi/example_update.py 0000664 0000000 0000000 00000001553 14613034133 0026430 0 ustar 00root root 0000000 0000000 from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from odmantic import AIOEngine, Model, ObjectId
class Tree(Model):
name: str
average_size: float
discovery_year: int
app = FastAPI()
engine = AIOEngine()
@app.get("/trees/{id}", response_model=Tree)
async def get_tree_by_id(id: ObjectId):
tree = await engine.find_one(Tree, Tree.id == id)
if tree is None:
raise HTTPException(404)
return tree
class TreePatchSchema(BaseModel):
name: str = None
average_size: float = None
discovery_year: float = None
@app.patch("/trees/{id}", response_model=Tree)
async def update_tree_by_id(id: ObjectId, patch: TreePatchSchema):
tree = await engine.find_one(Tree, Tree.id == id)
if tree is None:
raise HTTPException(404)
tree.model_update(patch)
await engine.save(tree)
return tree
python-odmantic-1.0.2/docs/examples_src/usage_pydantic/ 0000775 0000000 0000000 00000000000 14613034133 0023241 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/examples_src/usage_pydantic/__init__.py 0000664 0000000 0000000 00000000015 14613034133 0025346 0 ustar 00root root 0000000 0000000 # Helps mypy
python-odmantic-1.0.2/docs/examples_src/usage_pydantic/custom_encoders.py 0000664 0000000 0000000 00000000652 14613034133 0027012 0 ustar 00root root 0000000 0000000 from datetime import datetime
from odmantic.bson import BSON_TYPES_ENCODERS, BaseBSONModel, ObjectId
class M(BaseBSONModel):
id: ObjectId
date: datetime
model_config = {
"json_encoders": {
**BSON_TYPES_ENCODERS,
datetime: lambda dt: dt.year,
}
}
print(M(id=ObjectId(), date=datetime.utcnow()).model_dump_json())
#> {"id": "5fa3378c8fde3766574d874d", "date": 2020}
python-odmantic-1.0.2/docs/fields.md 0000664 0000000 0000000 00000030536 14613034133 0017354 0 ustar 00root root 0000000 0000000 # Fields
## The `id` field
The [`ObjectId` data type](https://docs.mongodb.com/manual/reference/method/ObjectId/){:target=blank_}
is the default primary key type used by MongoDB. An `ObjectId` comes with many
information embedded into it (timestamp, machine identifier, ...). Since by default,
MongoDB will create a field `_id` containing an `ObjectId` primary key, ODMantic will
bind it automatically to an implicit field named `id`.
```python hl_lines="9 10" linenums="1"
--8<-- "fields/objectid.py"
```
!!! info "ObjectId creation"
This `id` field will be generated on instance creation, before saving the instance
to the database. This helps to keep consistency between the instances persisted to
the database and the ones only created locally.
Even if this behavior is convenient, it is still possible to [define custom primary
keys](#primary-key).
## Field types
### Optional fields
By default, every single field will be required. To specify a field as non-required, the
easiest way is to use the `typing.Optional` generic type that will allow the field to
take the `None` value as well (it will be stored as `null` in the database) and to give
it a default value of `None`.
```python hl_lines="8" linenums="1"
--8<-- "fields/optional.py"
```
### Union fields
As explained in the [Python Typing
documentation](https://docs.python.org/3/library/typing.html#typing.Optional){:target=bank_},
`Optional[X]` is equivalent to `Union[X, None]`. That implies that the field type will
be either `X` or `None`.
It's possible to combine any kind of type using the `typîng.Union` type constructor. For
example if we want to allow both `string` and `int` in a field:
```python hl_lines="7" linenums="1"
--8<-- "fields/union.py"
```
!!! question "NoneType"
Internally python describes the type of the `None` object as `NoneType` but in
practice, `None` is used directly in type annotations ([more details](https://mypy.readthedocs.io/en/stable/kinds_of_types.html#optional-types-and-the-none-type){:target=bank_}).
### Enum fields
To define choices, it's possible to use the standard `enum` classes:
```python hl_lines="6-8 13" linenums="1"
--8<-- "fields/enum.py"
```
!!! abstract "Resulting documents in the collection `tree` after execution"
```json hl_lines="7"
{ "_id" : ObjectId("5f818f2dd5708527282c49b6"), "kind" : "big", "name" : "Sequoia" }
{ "_id" : ObjectId("5f818f2dd5708527282c49b7"), "kind" : "small", "name" : "Spruce" }
```
If you try to use a value not present in the allowed choices, a [ValidationError](https://docs.pydantic.dev/latest/usage/models/#error-handling){:target=blank_} exception will be raised.
!!! warning "Usage of `enum.auto`"
If you might add some values to an `Enum`, it's strongly recommended not to use the
`enum.auto` value generator. Depending on the order you add choices, it could
completely break the consistency with documents stored in the database.
??? example "Unwanted behavior example"
```python hl_lines="11-12" linenums="1"
--8<-- "fields/inconsistent_enum_1.py"
```
```python hl_lines="6 12-15" linenums="1"
--8<-- "fields/inconsistent_enum_2.py"
```
### Container fields
#### List
```python linenums="1"
--8<-- "fields/container_list.py"
```
!!! tip
It's possible to define element count constraints for a list field using the
[Field][odmantic.field.Field] descriptor.
#### Tuple
```python linenums="1"
--8<-- "fields/container_tuple.py"
```
#### Dict
!!! tip
For mapping types with already known keys, you can see the [embedded models
section](modeling.md#embedded-models).
```python linenums="1"
--8<-- "fields/container_dict.py"
```
!!! tip "Performance tip"
Whenever possible, try to avoid mutable container types (`List`, `Set`, ...) and
prefer their Immutable alternatives (`Tuple`, `FrozenSet`, ...). This will allow
ODMantic to speedup database writes by only saving the modified container fields.
### `BSON` types integration
ODMantic supports native python BSON types ([`bson`
package](https://api.mongodb.com/python/current/api/bson/index.html){:target=blank_}).
Those types can be used directly as field types:
- [`bson.ObjectId`](https://api.mongodb.com/python/current/api/bson/objectid.html){:target=blank_}
- [`bson.Int64`](https://api.mongodb.com/python/current/api/bson/int64.html){:target=blank_}
- [`bson.Decimal128`](https://api.mongodb.com/python/current/api/bson/decimal128.html){:target=blank_}
- [`bson.Regex`](https://api.mongodb.com/python/current/api/bson/regex.html){:target=blank_}
- [`bson.Binary`](https://api.mongodb.com/python/current/api/bson/binary.html#bson.binary.Binary){:target=blank_}
??? info "Generic python to BSON type map"
| Python type | BSON type | Comment |
| ---------------------- | :--------: | ------------------------------------------------------------ |
| `bson.ObjectId` | `objectId` |
| `bool` | `bool` | |
| `int` | `int` | value between -2^31 and 2^31 - 1 |
| `int` | `long` | value not between -2^31 and 2^31 - 1 |
| `bson.Int64` | `long` |
| `float` | `double` |
| `bson.Decimal128` | `decimal` | |
| `decimal.Decimal` | `decimal` |
| `str` | `string` |
| `typing.Pattern` | `regex` |
| `bson.Regex` | `regex` |
| `bytes` | `binData` |
| `bson.Binary` | `binData` |
| `datetime.datetime` | `date` | microseconds are truncated, only naive datetimes are allowed |
| `typing.Dict` | `object` |
| `typing.List` | `array` |
| `typing.Sequence` | `array` |
| `typing.Tuple[T, ...]` | `array` |
### Pydantic fields
Most of the types supported by pydantic are supported by ODMantic. See [pydantic:
Field Types](https://docs.pydantic.dev/latest/usage/types/types/){:target=bank_} for more
field types.
Unsupported fields:
- `typing.Callable`
Fields with a specific behavior:
- `datetime.datetime`: Only [naive datetime
objects](https://docs.python.org/3/library/datetime.html#determining-if-an-object-is-aware-or-naive){:target=blank_}
will be allowed as MongoDB doesn't store the timezone information. Also, the
microsecond information will be truncated.
## Customization
The field customization can mainly be performed using the [Field][odmantic.field.Field]
descriptor. This descriptor is here to define everything about the field except its
type.
### Default values
The easiest way to set a default value to a field is by assigning this default value
directly while defining the model.
```python hl_lines="6" linenums="1"
--8<-- "fields/default_value.py"
```
You can combine default values and an existing [Field][odmantic.field.Field]
descriptor using the `default` keyword argument.
``` python hl_lines="6" linenums="1"
--8<-- "fields/default_value_field.py"
```
!!! info "Default factory"
You may as well define a factory function instead of a value using the
`default_factory` argument of the [Field][odmantic.field.Field] descriptor.
By default, the default factories won't be used while parsing MongoDB documents.
It's possible to enable this behavior with the `parse_doc_with_default_factories`
[Config](modeling.md#advanced-configuration) option.
!!! warning "Default values validation"
Currently the default values are not validated yet during the model creation.
An inconsistent default value might raise a
[ValidationError](https://docs.pydantic.dev/latest/usage/models/#error-handling){:target=blank_}
while building an instance.
### Document structure
By default, the MongoDB documents fields will be named after the field name. It is
possible to override this naming policy by using the `key_name` argument in the
[Field][odmantic.field.Field] descriptor.
{{ async_sync_snippet("fields", "custom_key_name.py", hl_lines="5") }}
!!! abstract "Resulting documents in the collection `player` after execution"
```json hl_lines="3"
{
"_id": ObjectId("5ed50fcad11d1975aa3d7a28"),
"username": "Jack",
}
```
See [this section](#the-id-field) for more details about the `_id` field that has been added.
### Primary key
While ODMantic will by default populate the `id` field as a primary key, you can use any
other field as the primary key.
{{ async_sync_snippet("fields", "custom_primary_field.py", hl_lines="5") }}
!!! abstract "Resulting documents in the collection `player` after execution"
```json
{
"_id": "Leeroy Jenkins"
}
```
!!! info
The Mongo name of the primary field will be enforced to `_id` and you will not be
able to change it.
!!! warning
Using mutable types (Set, List, ...) as primary field might result in inconsistent
behaviors.
### Indexed fields
You can define an index on a single field by using the `index` argument of the
[Field][odmantic.field.Field] descriptor.
More details about index creation can be found in the
[Indexes](modeling.md#indexes) section.
{{ async_sync_snippet("fields", "indexed_field.py", hl_lines="6 10") }}
!!! warning
When using indexes, make sure to call the `configure_database` method
([AIOEngine.configure_database][odmantic.engine.AIOEngine.configure_database] or
[SyncEngine.configure_database][odmantic.engine.SyncEngine.configure_database]) to
persist the indexes to the database.
### Unique fields
In the same way, you can define unique constrains on a single field by using the
`unique` argument of the [Field][odmantic.field.Field] descriptor. This will ensure that
values of this fields are unique among all the instances saved in the database.
More details about unique index creation can be found in the
[Indexes](modeling.md#indexes) section.
{{ async_sync_snippet("fields", "unique_field.py", hl_lines="5 9 15-18") }}
!!! warning
When using indexes, make sure to call the `configure_database` method
([AIOEngine.configure_database][odmantic.engine.AIOEngine.configure_database] or
[SyncEngine.configure_database][odmantic.engine.SyncEngine.configure_database]) to
persist the indexes to the database.
## Validation
As ODMantic strongly relies on pydantic when it comes to data validation, most of the
validation features provided by pydantic are available:
- Add field validation constraints by using the [Field descriptor][odmantic.field.Field]
```python linenums="1"
--8<-- "fields/validation_field_descriptor.py"
```
- Use strict types to prevent to coercion from compatible types ([pydantic: Strict Types](https://docs.pydantic.dev/latest/usage/types/strict_types/){:target=blank_})
```python linenums="1"
--8<-- "fields/validation_strict_types.py"
```
- Define custom field validators ([pydantic:
Validators](https://docs.pydantic.dev/latest/usage/validators/){:target=blank_})
```python linenums="1"
--8<-- "fields/custom_field_validators.py"
```
- Define custom model validators: [more details](modeling.md#custom-model-validators)
## Custom field types
Exactly in the same way pydantic allows it, it's possible to define custom field types as well with ODMantic ([Pydantic: Custom data types](https://docs.pydantic.dev/latest/concepts/types/#custom-types){:target=blank_}).
Sometimes, it might be required to customize as well the field BSON serialization. In
order to do this, the field class will have to implement the `__bson__` class method.
```python linenums="1" hl_lines="11-12 20-24 27-29 32"
--8<-- "fields/custom_bson_serialization.py"
```
In this example, we decide to store string data manually encoded in the ASCII encoding.
The encoding is handled in the `__bson__` class method. On top of this, we handle the
decoding by attempting to decode `bytes` object in the `validate` method.
!!! abstract "Resulting documents in the collection `example` after execution"
```json hl_lines="3"
{
"_id" : ObjectId("5f81fa5e8adaf4bf33f05035"),
"field" : BinData(0,"aGVsbG8gd29ybGQ=")
}
```
!!! warning
When using custom bson serialization, it's important to handle as well the data
validation for data retrieved from Mongo. In the previous example it's done by
handling `bytes` objects in the validate method.
python-odmantic-1.0.2/docs/img/ 0000775 0000000 0000000 00000000000 14613034133 0016331 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/img/internals.excalidraw 0000664 0000000 0000000 00000310433 14613034133 0022401 0 ustar 00root root 0000000 0000000 {
"type": "excalidraw",
"version": 2,
"source": "https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor",
"elements": [
{
"type": "rectangle",
"version": 147,
"versionNonce": 128764279,
"isDeleted": false,
"id": "1QClEzXygZTqt76PxkWkH",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dotted",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 146.65989685058588,
"y": 30.25213623046875,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 238,
"height": 50,
"seed": 1347042135,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "yfG0e_3Wa-DCZDcem7x8q"
},
{
"id": "UHKGbTMVCX3xV-2j2EI8K",
"type": "arrow"
},
{
"id": "lD4XdHeiTgVFnGz0vty89",
"type": "arrow"
},
{
"id": "syo0XVss1rjwOn1YisePL",
"type": "arrow"
}
],
"updated": 1688229366345,
"link": null,
"locked": false
},
{
"type": "text",
"version": 94,
"versionNonce": 1173301423,
"isDeleted": false,
"id": "yfG0e_3Wa-DCZDcem7x8q",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 163.20000457763666,
"y": 42.75213623046875,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 204.91978454589844,
"height": 25,
"seed": 184948823,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214155,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "BaseModelMetaclass",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "1QClEzXygZTqt76PxkWkH",
"originalText": "BaseModelMetaclass",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "rectangle",
"version": 207,
"versionNonce": 2017728439,
"isDeleted": false,
"id": "_ARtVrDLJfSitpVtrp3xr",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 47.72650146484375,
"y": 330.26622772216797,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 158,
"height": 48,
"seed": 1927784151,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "nHwV4fYy_yVG7Loh6-ngL"
},
{
"id": "FEowXgbNW5ved7spvvO41",
"type": "arrow"
},
{
"id": "yg9fb8SkwTr4SYRdt2ZhT",
"type": "arrow"
}
],
"updated": 1688229366345,
"link": null,
"locked": false
},
{
"type": "text",
"version": 209,
"versionNonce": 846740929,
"isDeleted": false,
"id": "nHwV4fYy_yVG7Loh6-ngL",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 99.73652648925781,
"y": 341.76622772216797,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 53.979949951171875,
"height": 25,
"seed": 310016823,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214155,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "Model",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "_ARtVrDLJfSitpVtrp3xr",
"originalText": "Model",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "rectangle",
"version": 41,
"versionNonce": 93115895,
"isDeleted": false,
"id": "kWjrydqFbA0BV-hc-4YBW",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dotted",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 119.88507843017578,
"y": -86.73246765136719,
"strokeColor": "#a61e4d",
"backgroundColor": "transparent",
"width": 286.52425384521484,
"height": 60,
"seed": 1054861143,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "aml_HLVWOSo7schAkrqq6"
},
{
"id": "UHKGbTMVCX3xV-2j2EI8K",
"type": "arrow"
}
],
"updated": 1688229366346,
"link": null,
"locked": false
},
{
"type": "text",
"version": 28,
"versionNonce": 2122595023,
"isDeleted": false,
"id": "aml_HLVWOSo7schAkrqq6",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 185.52728652954102,
"y": -81.73246765136719,
"strokeColor": "#a61e4d",
"backgroundColor": "transparent",
"width": 155.23983764648438,
"height": 50,
"seed": 1144583831,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214155,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "\nModelMetaclass",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "kWjrydqFbA0BV-hc-4YBW",
"originalText": "\nModelMetaclass",
"lineHeight": 1.25,
"baseline": 44
},
{
"type": "rectangle",
"version": 64,
"versionNonce": 1200422679,
"isDeleted": false,
"id": "ZYQVZcX3tXLVZAnH3hWx0",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dotted",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 12.148128509521484,
"y": 140.1332015991211,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 212.24032592773438,
"height": 48.530487060546875,
"seed": 544714647,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "yWHeF-rNpJgjzhvd-MSa7"
},
{
"id": "lD4XdHeiTgVFnGz0vty89",
"type": "arrow"
},
{
"id": "yg9fb8SkwTr4SYRdt2ZhT",
"type": "arrow"
}
],
"updated": 1688229366346,
"link": null,
"locked": false
},
{
"type": "text",
"version": 57,
"versionNonce": 1192025505,
"isDeleted": false,
"id": "yWHeF-rNpJgjzhvd-MSa7",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 40.648372650146484,
"y": 151.89844512939453,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 155.23983764648438,
"height": 25,
"seed": 760087769,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214156,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "ModelMetaclass",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "ZYQVZcX3tXLVZAnH3hWx0",
"originalText": "ModelMetaclass",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "rectangle",
"version": 116,
"versionNonce": 1709900855,
"isDeleted": false,
"id": "o0iF0wULG3DdAi8FGxZZf",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dotted",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 281.195556640625,
"y": 133.90399932861328,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 264,
"height": 54,
"seed": 1122191513,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "7JDlJOsCac6X1GeJdx5QI"
},
{
"id": "syo0XVss1rjwOn1YisePL",
"type": "arrow"
},
{
"id": "ZjJ6g8_13kITs2JvRY1y9",
"type": "arrow"
}
],
"updated": 1688229366346,
"link": null,
"locked": false
},
{
"type": "text",
"version": 105,
"versionNonce": 586287343,
"isDeleted": false,
"id": "7JDlJOsCac6X1GeJdx5QI",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 289.41568756103516,
"y": 148.40399932861328,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 247.5597381591797,
"height": 25,
"seed": 205561529,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214156,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "EmbeddedModelMetaclass",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "o0iF0wULG3DdAi8FGxZZf",
"originalText": "EmbeddedModelMetaclass",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "rectangle",
"version": 171,
"versionNonce": 1155523887,
"isDeleted": false,
"id": "jABrwBukq5wQ1d4yfmmyl",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -237.8904549734932,
"y": 322.25397455124636,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 205,
"height": 54,
"seed": 492005815,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "RQUSItVO1nOHRaqYqS7ZY"
},
{
"id": "sl8bKzR824MnizHKNPVaW",
"type": "arrow"
},
{
"id": "FEowXgbNW5ved7spvvO41",
"type": "arrow"
},
{
"id": "nbeiWW2Hs9fQhvAnNN90y",
"type": "arrow"
}
],
"updated": 1688305624281,
"link": null,
"locked": false
},
{
"type": "text",
"version": 160,
"versionNonce": 1700759873,
"isDeleted": false,
"id": "RQUSItVO1nOHRaqYqS7ZY",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -218.20038386753617,
"y": 336.75397455124636,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 165.61985778808594,
"height": 25,
"seed": 130512727,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305624281,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "_BaseODMModel",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "jABrwBukq5wQ1d4yfmmyl",
"originalText": "_BaseODMModel",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "rectangle",
"version": 202,
"versionNonce": 799201711,
"isDeleted": false,
"id": "W9rThSS1FY0WVyk6rE3OQ",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -235.49182619367303,
"y": 68.70132627941314,
"strokeColor": "#a61e4d",
"backgroundColor": "transparent",
"width": 202,
"height": 60,
"seed": 1054861143,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "yaA5kLOeNzHhuCdH5RbV6"
},
{
"id": "JbYMZWhuwFmFeiaAYZsZI",
"type": "arrow"
},
{
"id": "sl8bKzR824MnizHKNPVaW",
"type": "arrow"
},
{
"id": "Jbcd5LEPNRJ78cK3nbitw",
"type": "arrow"
}
],
"updated": 1688305650377,
"link": null,
"locked": false
},
{
"type": "text",
"version": 157,
"versionNonce": 1151383809,
"isDeleted": false,
"id": "yaA5kLOeNzHhuCdH5RbV6",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -186.321774618966,
"y": 73.70132627941314,
"strokeColor": "#a61e4d",
"backgroundColor": "transparent",
"width": 103.65989685058594,
"height": 50,
"seed": 1144583831,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305621386,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "\nBaseModel",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "W9rThSS1FY0WVyk6rE3OQ",
"originalText": "\nBaseModel",
"lineHeight": 1.25,
"baseline": 44
},
{
"type": "arrow",
"version": 145,
"versionNonce": 693451671,
"isDeleted": false,
"id": "UHKGbTMVCX3xV-2j2EI8K",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 262.1915314558535,
"y": -23.687198401304528,
"strokeColor": "#a61e4d",
"backgroundColor": "transparent",
"width": 1.692945786175244,
"height": 50.53572173519619,
"seed": 1001008823,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688229366347,
"link": null,
"locked": false,
"startBinding": {
"elementId": "kWjrydqFbA0BV-hc-4YBW",
"gap": 3.0452692500626695,
"focus": 0.01588143459831425
},
"endBinding": {
"elementId": "1QClEzXygZTqt76PxkWkH",
"gap": 3.4036128965770875,
"focus": -0.00687513575365181
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
1.692945786175244,
50.53572173519619
]
]
},
{
"type": "arrow",
"version": 152,
"versionNonce": 777989465,
"isDeleted": false,
"id": "lD4XdHeiTgVFnGz0vty89",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 251.36881409019264,
"y": 81.25213623046875,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 136.5801048833885,
"height": 56.546383626290464,
"seed": 1382775159,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688229366347,
"link": null,
"locked": false,
"startBinding": {
"elementId": "1QClEzXygZTqt76PxkWkH",
"gap": 1,
"focus": -0.26828215886152174
},
"endBinding": {
"elementId": "ZYQVZcX3tXLVZAnH3hWx0",
"gap": 2.3346817423618815,
"focus": -0.4111471332894141
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
-136.5801048833885,
56.546383626290464
]
]
},
{
"type": "arrow",
"version": 279,
"versionNonce": 1282912439,
"isDeleted": false,
"id": "syo0XVss1rjwOn1YisePL",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 289.1718950933908,
"y": 81.25213623046875,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 139.84882152444192,
"height": 51.55749759661953,
"seed": 991131415,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688229366347,
"link": null,
"locked": false,
"startBinding": {
"elementId": "1QClEzXygZTqt76PxkWkH",
"gap": 1,
"focus": 0.2483785002338254
},
"endBinding": {
"elementId": "o0iF0wULG3DdAi8FGxZZf",
"gap": 1.0943655015249987,
"focus": 0.4484114755079205
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
139.84882152444192,
51.55749759661953
]
]
},
{
"type": "arrow",
"version": 119,
"versionNonce": 1571033999,
"isDeleted": false,
"id": "JbYMZWhuwFmFeiaAYZsZI",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 124.82330322265682,
"y": -36.5750681559245,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 237.97867673902235,
"height": 104.27639443533764,
"seed": 1210082841,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688305621387,
"link": null,
"locked": false,
"startBinding": null,
"endBinding": {
"elementId": "W9rThSS1FY0WVyk6rE3OQ",
"gap": 1,
"focus": -0.29157207339294644
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
-237.97867673902235,
104.27639443533764
]
]
},
{
"type": "arrow",
"version": 393,
"versionNonce": 1174321441,
"isDeleted": false,
"id": "sl8bKzR824MnizHKNPVaW",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -143.30629708519024,
"y": 129.70132627941314,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 4.969637552835366,
"height": 191.55264827183322,
"seed": 1171082935,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688305624281,
"link": null,
"locked": false,
"startBinding": {
"elementId": "W9rThSS1FY0WVyk6rE3OQ",
"gap": 1,
"focus": 0.07965115605855717
},
"endBinding": {
"elementId": "jABrwBukq5wQ1d4yfmmyl",
"gap": 1,
"focus": -0.13189773975727065
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
-4.969637552835366,
191.55264827183322
]
]
},
{
"type": "arrow",
"version": 338,
"versionNonce": 1324150017,
"isDeleted": false,
"id": "FEowXgbNW5ved7spvvO41",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -31.890454973493206,
"y": 350.60417211217793,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 75.49999103848879,
"height": 1.7022986889428466,
"seed": 376203863,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688305624281,
"link": null,
"locked": false,
"startBinding": {
"elementId": "jABrwBukq5wQ1d4yfmmyl",
"gap": 1,
"focus": 0.15412681829980826
},
"endBinding": {
"elementId": "_ARtVrDLJfSitpVtrp3xr",
"gap": 4.11696539984818,
"focus": 0.2807623067766991
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
75.49999103848879,
-1.7022986889428466
]
]
},
{
"type": "arrow",
"version": 250,
"versionNonce": 1980342265,
"isDeleted": false,
"id": "yg9fb8SkwTr4SYRdt2ZhT",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 123.39449550552001,
"y": 191.66057417127817,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 3.6081141666376197,
"height": 134.99102783203116,
"seed": 1520524375,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688229366347,
"link": null,
"locked": false,
"startBinding": {
"elementId": "ZYQVZcX3tXLVZAnH3hWx0",
"gap": 2.9968855116101922,
"focus": -0.05478211135391337
},
"endBinding": {
"elementId": "_ARtVrDLJfSitpVtrp3xr",
"gap": 3.614625718858595,
"focus": -0.09640979145820627
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
-3.6081141666376197,
134.99102783203116
]
]
},
{
"type": "rectangle",
"version": 271,
"versionNonce": 14366743,
"isDeleted": false,
"id": "L26M6KL304ntTfn0ENqja",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 351.80155944824253,
"y": 431.6505215962727,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 174,
"height": 51,
"seed": 1927784151,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "ZhK_hxQ00cq9zyBrZVVvp"
},
{
"id": "nbeiWW2Hs9fQhvAnNN90y",
"type": "arrow"
},
{
"id": "ZjJ6g8_13kITs2JvRY1y9",
"type": "arrow"
}
],
"updated": 1688229366347,
"link": null,
"locked": false
},
{
"type": "text",
"version": 285,
"versionNonce": 98085217,
"isDeleted": false,
"id": "ZhK_hxQ00cq9zyBrZVVvp",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 365.65163421630893,
"y": 444.6505215962727,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 146.2998504638672,
"height": 25,
"seed": 310016823,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214157,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "EmbeddedModel",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "L26M6KL304ntTfn0ENqja",
"originalText": "EmbeddedModel",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "arrow",
"version": 278,
"versionNonce": 672588431,
"isDeleted": false,
"id": "nbeiWW2Hs9fQhvAnNN90y",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -136.22835885326396,
"y": 380.7382318405877,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 475.8425232056813,
"height": 83.21626519297138,
"seed": 1837564215,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688305634035,
"link": null,
"locked": false,
"startBinding": {
"elementId": "jABrwBukq5wQ1d4yfmmyl",
"focus": 0.21356247302120304,
"gap": 4.484257289341315
},
"endBinding": {
"elementId": "L26M6KL304ntTfn0ENqja",
"focus": -0.30169734772557727,
"gap": 12.187395095825195
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
64.00875000142305,
78.19504220145092
],
[
475.8425232056813,
83.21626519297138
]
]
},
{
"type": "arrow",
"version": 331,
"versionNonce": 296835961,
"isDeleted": false,
"id": "ZjJ6g8_13kITs2JvRY1y9",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 419.94624605978356,
"y": 190.21559320882392,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 6.909197352382307,
"height": 235.32681525751508,
"seed": 457242297,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688229366348,
"link": null,
"locked": false,
"startBinding": {
"elementId": "o0iF0wULG3DdAi8FGxZZf",
"gap": 2.311593880210631,
"focus": -0.04437565916514326
},
"endBinding": {
"elementId": "L26M6KL304ntTfn0ENqja",
"gap": 6.108113129933705,
"focus": -0.12556429943324393
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
6.909197352382307,
235.32681525751508
]
]
},
{
"type": "rectangle",
"version": 54,
"versionNonce": 557773975,
"isDeleted": false,
"id": "jWOGPtqhRJMccvPdX-VDu",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 907.7460937500006,
"y": -34.374796549479186,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 315,
"height": 45,
"seed": 138321879,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "Nl6TQd333DHEPY_TzDyPR"
},
{
"id": "36cRmVAauYIpgPT494cX6",
"type": "arrow"
},
{
"id": "ts2am8lOYt8dakA2vqYPz",
"type": "arrow"
}
],
"updated": 1688229366348,
"link": null,
"locked": false
},
{
"type": "text",
"version": 39,
"versionNonce": 1617549615,
"isDeleted": false,
"id": "Nl6TQd333DHEPY_TzDyPR",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 1011.6561508178717,
"y": -24.374796549479186,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 107.17988586425781,
"height": 25,
"seed": 803237785,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214157,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "BaseEngine",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "jWOGPtqhRJMccvPdX-VDu",
"originalText": "BaseEngine",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "rectangle",
"version": 55,
"versionNonce": 1891781047,
"isDeleted": false,
"id": "nNYXFkQd-Hh6FiZgZBZwu",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 758.7050781250003,
"y": 183.12886555989576,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 250,
"height": 40,
"seed": 1288696503,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "9OJ530C0zfIlBTgPyonk_"
},
{
"id": "36cRmVAauYIpgPT494cX6",
"type": "arrow"
}
],
"updated": 1688229366348,
"link": null,
"locked": false
},
{
"type": "text",
"version": 51,
"versionNonce": 379508033,
"isDeleted": false,
"id": "9OJ530C0zfIlBTgPyonk_",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 835.6851196289066,
"y": 190.62886555989576,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 96.0399169921875,
"height": 25,
"seed": 1127779255,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214158,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "AIOEngine",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "nNYXFkQd-Hh6FiZgZBZwu",
"originalText": "AIOEngine",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "rectangle",
"version": 96,
"versionNonce": 1532885719,
"isDeleted": false,
"id": "WGJmvq-xHSMb702lCHnMC",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 1167.1317749023438,
"y": 170.93236287434888,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 250,
"height": 50,
"seed": 1288696503,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "jJvJPzsmAWxAUGEcG9nuU"
},
{
"id": "ts2am8lOYt8dakA2vqYPz",
"type": "arrow"
}
],
"updated": 1688229366348,
"link": null,
"locked": false
},
{
"type": "text",
"version": 102,
"versionNonce": 1196743503,
"isDeleted": false,
"id": "jJvJPzsmAWxAUGEcG9nuU",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 1242.9218215942383,
"y": 183.43236287434888,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 98.41990661621094,
"height": 25,
"seed": 1127779255,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214158,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "SyncEngine",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "WGJmvq-xHSMb702lCHnMC",
"originalText": "SyncEngine",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "arrow",
"version": 124,
"versionNonce": 363235319,
"isDeleted": false,
"id": "36cRmVAauYIpgPT494cX6",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 1022.6256913695252,
"y": 13.66178581311307,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 162.944155964238,
"height": 167.3538347346252,
"seed": 918387319,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688229366349,
"link": null,
"locked": false,
"startBinding": {
"elementId": "jWOGPtqhRJMccvPdX-VDu",
"gap": 1.0365823625922548,
"focus": 0.10395625252674856
},
"endBinding": {
"elementId": "nNYXFkQd-Hh6FiZgZBZwu",
"gap": 2.1132450121574724,
"focus": -0.3153122914145206
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
-162.944155964238,
167.3538347346252
]
]
},
{
"type": "arrow",
"version": 121,
"versionNonce": 1675581177,
"isDeleted": false,
"id": "ts2am8lOYt8dakA2vqYPz",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 1115.0976562500005,
"y": 13.601826985677064,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 176.01424666596154,
"height": 152.80197552696214,
"seed": 2142648633,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688229366349,
"link": null,
"locked": false,
"startBinding": {
"elementId": "jWOGPtqhRJMccvPdX-VDu",
"focus": -0.11179254674682215,
"gap": 2.97662353515625
},
"endBinding": {
"elementId": "WGJmvq-xHSMb702lCHnMC",
"focus": 0.21453104117484706,
"gap": 4.52856036170968
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
176.01424666596154,
152.80197552696214
]
]
},
{
"type": "rectangle",
"version": 71,
"versionNonce": 2045400919,
"isDeleted": false,
"id": "NXn4aHdtl130ZdWi0rxEa",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -286.69224548339787,
"y": 854.8295796712239,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 260,
"height": 71,
"seed": 1721253785,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "-T0iQUOE9JeAMcsxBmAvp"
},
{
"id": "UAktyDZl4twNnjsg7yU4H",
"type": "arrow"
}
],
"updated": 1688229366349,
"link": null,
"locked": false
},
{
"type": "text",
"version": 75,
"versionNonce": 695502113,
"isDeleted": false,
"id": "-T0iQUOE9JeAMcsxBmAvp",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -272.7221374511713,
"y": 877.8295796712239,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 232.05978393554688,
"height": 25,
"seed": 1623944951,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214158,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "ODMBaseIndexableField",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "NXn4aHdtl130ZdWi0rxEa",
"originalText": "ODMBaseIndexableField",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "rectangle",
"version": 88,
"versionNonce": 1265849463,
"isDeleted": false,
"id": "OZ8QhmTUsd704jVLu1udO",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dotted",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -648.5759201049798,
"y": -103.5347811381022,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 142,
"height": 35,
"seed": 981057847,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "XP6YoNaNuibiKtzG1WK7k"
}
],
"updated": 1688229366349,
"link": null,
"locked": false
},
{
"type": "text",
"version": 81,
"versionNonce": 970627439,
"isDeleted": false,
"id": "XP6YoNaNuibiKtzG1WK7k",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dotted",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -628.205863952636,
"y": -98.5347811381022,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 101.2598876953125,
"height": 25,
"seed": 2053503095,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214159,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "Metaclass",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "OZ8QhmTUsd704jVLu1udO",
"originalText": "Metaclass",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "rectangle",
"version": 111,
"versionNonce": 958377367,
"isDeleted": false,
"id": "S4uDiqJBBlth1jVYRXYdQ",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -648.3649291992181,
"y": -48.75762049357098,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 183,
"height": 35,
"seed": 2109010743,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "NloV4MhszJYXPo8tE2uM3"
}
],
"updated": 1688229366349,
"link": null,
"locked": false
},
{
"type": "text",
"version": 104,
"versionNonce": 1506359553,
"isDeleted": false,
"id": "NloV4MhszJYXPo8tE2uM3",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dotted",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -632.8248443603509,
"y": -43.75762049357098,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 151.91983032226562,
"height": 25,
"seed": 1140701367,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214159,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "Abstract Class",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "S4uDiqJBBlth1jVYRXYdQ",
"originalText": "Abstract Class",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "rectangle",
"version": 79,
"versionNonce": 505668279,
"isDeleted": false,
"id": "1Ms-vOWh7lMrB2keF4ntc",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -651.2998962402337,
"y": 4.0566190083821425,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 174.24031066894526,
"height": 36.986572265625,
"seed": 1982922103,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "uHyKoVLvjUdeLLMKPUkSw"
}
],
"updated": 1688229366350,
"link": null,
"locked": false
},
{
"type": "text",
"version": 57,
"versionNonce": 1833145231,
"isDeleted": false,
"id": "uHyKoVLvjUdeLLMKPUkSw",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -590.779708862304,
"y": 10.049905141194643,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 53.19993591308594,
"height": 25,
"seed": 1718751831,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214159,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "Class",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "1Ms-vOWh7lMrB2keF4ntc",
"originalText": "Class",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "rectangle",
"version": 87,
"versionNonce": 2065986519,
"isDeleted": false,
"id": "R4MYkAZlM4JZsv6E_OTSr",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -684.7675056457513,
"y": -184.832098642985,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 266.6105995178222,
"height": 528.6784133911133,
"seed": 228259801,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [],
"updated": 1688229366350,
"link": null,
"locked": false
},
{
"type": "text",
"version": 38,
"versionNonce": 1926936948,
"isDeleted": false,
"id": "tNFFZPTSeL4oBQKV6xwlb",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -641.9628753662103,
"y": -155.60393397013345,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 72.17991638183594,
"height": 25,
"seed": 1453579833,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1702173464927,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "Caption",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Caption",
"lineHeight": 1.25,
"baseline": 17
},
{
"type": "rectangle",
"version": 14,
"versionNonce": 1733168633,
"isDeleted": false,
"id": "Q4CGBEw8PIYjICcvxNOra",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -284.2282104492182,
"y": 693.6255086263019,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 252.154296875,
"height": 72.44720458984375,
"seed": 1408485015,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "5n6xTfbQVrPsFxfM88e02"
},
{
"id": "UAktyDZl4twNnjsg7yU4H",
"type": "arrow"
},
{
"id": "mSJfnHWBSTvBtjdyV0O1u",
"type": "arrow"
}
],
"updated": 1688229366350,
"link": null,
"locked": false
},
{
"type": "text",
"version": 6,
"versionNonce": 1506298287,
"isDeleted": false,
"id": "5n6xTfbQVrPsFxfM88e02",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -227.4309997558588,
"y": 717.3491109212238,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 138.55987548828125,
"height": 25,
"seed": 2119975703,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214160,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "ODMBaseField",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "Q4CGBEw8PIYjICcvxNOra",
"originalText": "ODMBaseField",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "arrow",
"version": 23,
"versionNonce": 1647044313,
"isDeleted": false,
"id": "UAktyDZl4twNnjsg7yU4H",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -159.76171874999943,
"y": 768.2135213216143,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 0.45587158203125,
"height": 84.97824096679688,
"seed": 876963321,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688229366350,
"link": null,
"locked": false,
"startBinding": {
"elementId": "Q4CGBEw8PIYjICcvxNOra",
"focus": 0.011125618553601228,
"gap": 2.1408081054686363
},
"endBinding": {
"elementId": "NXn4aHdtl130ZdWi0rxEa",
"focus": -0.02860865318629802,
"gap": 1.6378173828127274
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
-0.45587158203125,
84.97824096679688
]
]
},
{
"type": "rectangle",
"version": 167,
"versionNonce": 336416567,
"isDeleted": false,
"id": "MmjpQNm8e4X6meZXbUBEd",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -410.88523864746037,
"y": 1014.4134267171222,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 199,
"height": 42,
"seed": 109882233,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "Q4QCGNzTUwlJqLG19obAC"
},
{
"id": "SJolbrAOGVVcoqvsFrJTh",
"type": "arrow"
},
{
"id": "2pkhcWk_2M8N6sAuVG7xG",
"type": "arrow"
},
{
"id": "RA_WqOlwJvd3VGyVXeGOC",
"type": "arrow"
}
],
"updated": 1688229366350,
"link": null,
"locked": false
},
{
"type": "text",
"version": 187,
"versionNonce": 1023257793,
"isDeleted": false,
"id": "Q4QCGNzTUwlJqLG19obAC",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -355.82520294189396,
"y": 1022.9134267171221,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 88.87992858886719,
"height": 25,
"seed": 453649367,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214160,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "ODMField",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "MmjpQNm8e4X6meZXbUBEd",
"originalText": "ODMField",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "rectangle",
"version": 166,
"versionNonce": 163628185,
"isDeleted": false,
"id": "gIeBiC2fqLAh_jzsGEknB",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 35.91697311401413,
"y": 972.5399525960286,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 199,
"height": 42,
"seed": 109882233,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "nI1YmXmWvPmgm9-oUxvxi"
},
{
"id": "mSJfnHWBSTvBtjdyV0O1u",
"type": "arrow"
},
{
"id": "rypSwXtCbrn6iaxCvcrsP",
"type": "arrow"
}
],
"updated": 1688229366350,
"link": null,
"locked": false
},
{
"type": "text",
"version": 198,
"versionNonce": 1553021903,
"isDeleted": false,
"id": "nI1YmXmWvPmgm9-oUxvxi",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 65.18703842163131,
"y": 981.0399525960286,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 140.45986938476562,
"height": 25,
"seed": 453649367,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214160,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "ODMReference",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "gIeBiC2fqLAh_jzsGEknB",
"originalText": "ODMReference",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "arrow",
"version": 69,
"versionNonce": 8991321,
"isDeleted": false,
"id": "mSJfnHWBSTvBtjdyV0O1u",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -25.968200683593295,
"y": 732.8055928548174,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 173.053466796875,
"height": 230.97659301757812,
"seed": 1892089721,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688229366350,
"link": null,
"locked": false,
"startBinding": {
"elementId": "Q4CGBEw8PIYjICcvxNOra",
"focus": -0.4216852712060308,
"gap": 6.105712890624886
},
"endBinding": {
"elementId": "gIeBiC2fqLAh_jzsGEknB",
"focus": 0.14721052685104474,
"gap": 8.75776672363304
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
151.14483642578125,
34.8729248046875
],
[
173.053466796875,
230.97659301757812
]
]
},
{
"type": "rectangle",
"version": 47,
"versionNonce": 88309561,
"isDeleted": false,
"id": "oBN5q2AVj21FpaTsmD9Up",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -575.4466247558588,
"y": 1141.5685628255205,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 232.43093872070312,
"height": 43.39898681640625,
"seed": 726003001,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "EwO5uCo0b84wFobhqhzKa"
},
{
"id": "a1ge-330toFLQcoB9JwZN",
"type": "arrow"
},
{
"id": "ZrKEOJwFCNqKazB6CJahM",
"type": "arrow"
}
],
"updated": 1688229366350,
"link": null,
"locked": false
},
{
"type": "text",
"version": 35,
"versionNonce": 1561095329,
"isDeleted": false,
"id": "EwO5uCo0b84wFobhqhzKa",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -528.111091613769,
"y": 1150.7680562337237,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 137.75987243652344,
"height": 25,
"seed": 1577305783,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214160,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "ODMEmbedded",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "oBN5q2AVj21FpaTsmD9Up",
"originalText": "ODMEmbedded",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "arrow",
"version": 49,
"versionNonce": 450965529,
"isDeleted": false,
"id": "a1ge-330toFLQcoB9JwZN",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -316.9401245117182,
"y": 1055.1209615071612,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 112.46305600605285,
"height": 83.99490356445312,
"seed": 1146642359,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688229366350,
"link": null,
"locked": false,
"startBinding": null,
"endBinding": {
"elementId": "oBN5q2AVj21FpaTsmD9Up",
"focus": -0.01727856974303873,
"gap": 2.45269775390625
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
-112.46305600605285,
83.99490356445312
]
]
},
{
"type": "rectangle",
"version": 87,
"versionNonce": 391496953,
"isDeleted": false,
"id": "C4et8o20fl-WIeItQxXWv",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -278.0358886718743,
"y": 1143.337972005208,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 259,
"height": 46,
"seed": 128160633,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "ZQrhA2Ocj9FxFiZde4ien"
},
{
"id": "SJolbrAOGVVcoqvsFrJTh",
"type": "arrow"
},
{
"id": "fcXX9mod4agtBjd_Iuyc_",
"type": "arrow"
}
],
"updated": 1688229366350,
"link": null,
"locked": false
},
{
"type": "text",
"version": 92,
"versionNonce": 431417839,
"isDeleted": false,
"id": "ZQrhA2Ocj9FxFiZde4ien",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -252.42578887939385,
"y": 1153.837972005208,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 207.77980041503906,
"height": 25,
"seed": 1469464633,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214161,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "ODMEmbeddedGeneric",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "C4et8o20fl-WIeItQxXWv",
"originalText": "ODMEmbeddedGeneric",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "arrow",
"version": 25,
"versionNonce": 1176813017,
"isDeleted": false,
"id": "SJolbrAOGVVcoqvsFrJTh",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -280.77868652343693,
"y": 1057.3061726888018,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 135.81829833984375,
"height": 81.1202392578125,
"seed": 1660302935,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688229366350,
"link": null,
"locked": false,
"startBinding": {
"elementId": "MmjpQNm8e4X6meZXbUBEd",
"focus": 0.044913994802893424,
"gap": 1
},
"endBinding": {
"elementId": "C4et8o20fl-WIeItQxXWv",
"focus": 0.29943344568626623,
"gap": 4.91156005859375
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
135.81829833984375,
81.1202392578125
]
]
},
{
"type": "arrow",
"version": 27,
"versionNonce": 703686711,
"isDeleted": false,
"id": "2pkhcWk_2M8N6sAuVG7xG",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -187.88470458984318,
"y": 924.8297627766924,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 120.21661376953125,
"height": 88.65542602539062,
"seed": 372095223,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688229366350,
"link": null,
"locked": false,
"startBinding": null,
"endBinding": {
"elementId": "MmjpQNm8e4X6meZXbUBEd",
"focus": -0.20668517357201982,
"gap": 1
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
-120.21661376953125,
88.65542602539062
]
]
},
{
"type": "rectangle",
"version": 44,
"versionNonce": 912366265,
"isDeleted": false,
"id": "xeMCiUJi6USMy-nqhFh5k",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -553.8645757039391,
"y": 1436.3778737386065,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 213.9385986328124,
"height": 60.13557434082031,
"seed": 1889425719,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "hKH0bIyPDlGaYVhvpA8MU"
},
{
"id": "SxuOejZ7cQ3UNmtI49Sb2",
"type": "arrow"
}
],
"updated": 1688229366350,
"link": null,
"locked": false
},
{
"type": "text",
"version": 37,
"versionNonce": 821571713,
"isDeleted": false,
"id": "hKH0bIyPDlGaYVhvpA8MU",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -468.6152547200524,
"y": 1453.9456609090166,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 43.43995666503906,
"height": 25,
"seed": 916853721,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214161,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "Field",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "xeMCiUJi6USMy-nqhFh5k",
"originalText": "Field",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "arrow",
"version": 197,
"versionNonce": 1219484569,
"isDeleted": false,
"id": "SxuOejZ7cQ3UNmtI49Sb2",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -336.97790273030637,
"y": 1471.9465178113544,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 311.9809341430663,
"height": 0.05721304216694989,
"seed": 872734553,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [
{
"type": "text",
"id": "S3vTGyK2dRYevYL46pK6K"
}
],
"updated": 1688229366350,
"link": null,
"locked": false,
"startBinding": {
"elementId": "xeMCiUJi6USMy-nqhFh5k",
"focus": 0.18215927101474746,
"gap": 2.9480743408203125
},
"endBinding": {
"elementId": "Vey6HwdwlxRCf1-ELdHDM",
"focus": -0.1492435186961035,
"gap": 12.560348510742188
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "dot",
"points": [
[
0,
0
],
[
311.9809341430663,
0.05721304216694989
]
]
},
{
"type": "text",
"version": 82,
"versionNonce": 507630607,
"isDeleted": false,
"id": "S3vTGyK2dRYevYL46pK6K",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -264.2173322041834,
"y": 1446.9751243324379,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 166.4597930908203,
"height": 50,
"seed": 1636140633,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214161,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "summarizes data\ninto",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "SxuOejZ7cQ3UNmtI49Sb2",
"originalText": "summarizes data\ninto",
"lineHeight": 1.25,
"baseline": 44
},
{
"type": "rectangle",
"version": 93,
"versionNonce": 2101543033,
"isDeleted": false,
"id": "Vey6HwdwlxRCf1-ELdHDM",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -12.436620076497888,
"y": 1428.6966756184893,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 282.4995422363281,
"height": 75.40855407714844,
"seed": 1731901367,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"id": "SxuOejZ7cQ3UNmtI49Sb2",
"type": "arrow"
},
{
"type": "text",
"id": "zkcJBrBFG6KjtKThh-HTM"
}
],
"updated": 1688229366350,
"link": null,
"locked": false
},
{
"type": "text",
"version": 77,
"versionNonce": 1048606817,
"isDeleted": false,
"id": "zkcJBrBFG6KjtKThh-HTM",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 63.85320536295524,
"y": 1453.9009526570635,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 129.91989135742188,
"height": 25,
"seed": 1881440503,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214162,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "ODMFieldInfo",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "Vey6HwdwlxRCf1-ELdHDM",
"originalText": "ODMFieldInfo",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "rectangle",
"version": 112,
"versionNonce": 2016154807,
"isDeleted": false,
"id": "_U4VAH79hZax3caf1zOq8",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -655.3383115132654,
"y": 94.75302632649743,
"strokeColor": "#a61e4d",
"backgroundColor": "transparent",
"width": 174.24031066894526,
"height": 36.986572265625,
"seed": 1982922103,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "cW_x4fvTzGufKvISlWlw5"
}
],
"updated": 1688229366350,
"link": null,
"locked": false
},
{
"type": "text",
"version": 111,
"versionNonce": 1110080047,
"isDeleted": false,
"id": "cW_x4fvTzGufKvISlWlw5",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -643.9980710347497,
"y": 100.74631245930993,
"strokeColor": "#a61e4d",
"backgroundColor": "transparent",
"width": 151.55982971191406,
"height": 25,
"seed": 1718751831,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214162,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "Pydantic Entity",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "_U4VAH79hZax3caf1zOq8",
"originalText": "Pydantic Entity",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "rectangle",
"version": 141,
"versionNonce": 156657111,
"isDeleted": false,
"id": "otL0ZS4UxZlv7n_BeDKXA",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -655.9780743916833,
"y": 153.90234883626306,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 174.24031066894526,
"height": 36.986572265625,
"seed": 1982922103,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "2zopLd99vM4wx7PtlkSZT"
}
],
"updated": 1688229366350,
"link": null,
"locked": false
},
{
"type": "text",
"version": 161,
"versionNonce": 661963841,
"isDeleted": false,
"id": "2zopLd99vM4wx7PtlkSZT",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -642.9578412373864,
"y": 159.89563496907556,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 148.19984436035156,
"height": 25,
"seed": 1718751831,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214162,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "Internal Entity",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "otL0ZS4UxZlv7n_BeDKXA",
"originalText": "Internal Entity",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "rectangle",
"version": 181,
"versionNonce": 1599705847,
"isDeleted": false,
"id": "Y0HURbIdJHF_oEtdDzMYw",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -655.1873448689782,
"y": 216.05154927571618,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 174.24031066894526,
"height": 36.986572265625,
"seed": 1982922103,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "FujR1BobRApolhMQALX7D"
}
],
"updated": 1688229366350,
"link": null,
"locked": false
},
{
"type": "text",
"version": 218,
"versionNonce": 1247370319,
"isDeleted": false,
"id": "FujR1BobRApolhMQALX7D",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -646.2070973714196,
"y": 222.04483540852868,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 156.27981567382812,
"height": 25,
"seed": 1718751831,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214162,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "End-User Entity",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "Y0HURbIdJHF_oEtdDzMYw",
"originalText": "End-User Entity",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "rectangle",
"version": 58,
"versionNonce": 311858393,
"isDeleted": false,
"id": "HC2a-ym2pU-oHQIc_995A",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 114.74866739908805,
"y": 1294.6465733846023,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 599.5997619628905,
"height": 90.76152801513672,
"seed": 793055897,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"id": "rypSwXtCbrn6iaxCvcrsP",
"type": "arrow"
},
{
"id": "RA_WqOlwJvd3VGyVXeGOC",
"type": "arrow"
},
{
"id": "ZrKEOJwFCNqKazB6CJahM",
"type": "arrow"
},
{
"id": "fcXX9mod4agtBjd_Iuyc_",
"type": "arrow"
},
{
"type": "text",
"id": "-tWSKWdsNztSLCWmcpzs9"
}
],
"updated": 1688229366350,
"link": null,
"locked": false
},
{
"type": "text",
"version": 32,
"versionNonce": 1828852289,
"isDeleted": false,
"id": "-tWSKWdsNztSLCWmcpzs9",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 133.74883524576768,
"y": 1315.0273373921707,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 561.5994262695312,
"height": 50,
"seed": 910294329,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305688067,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": " BaseModelMetaclass::__validate_cls_namespace__\n(infers field objects)",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "HC2a-ym2pU-oHQIc_995A",
"originalText": " BaseModelMetaclass::__validate_cls_namespace__\n(infers field objects)",
"lineHeight": 1.25,
"baseline": 44
},
{
"type": "arrow",
"version": 25,
"versionNonce": 1686147513,
"isDeleted": false,
"id": "wiD99dH8WL9B1QDTSUX1a",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 267.51554361979106,
"y": 1429.027486165364,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 68.04290771484375,
"height": 44.547119140625,
"seed": 2027938711,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688229366350,
"link": null,
"locked": false,
"startBinding": null,
"endBinding": null,
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "dot",
"points": [
[
0,
0
],
[
68.04290771484375,
-44.547119140625
]
]
},
{
"type": "arrow",
"version": 32,
"versionNonce": 127087681,
"isDeleted": false,
"id": "rypSwXtCbrn6iaxCvcrsP",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 228.8693796793615,
"y": 1293.1112721761062,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 83.17924499511719,
"height": 271.5169525146483,
"seed": 1019883257,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688305670853,
"link": null,
"locked": false,
"startBinding": {
"elementId": "HC2a-ym2pU-oHQIc_995A",
"focus": -0.5460796337634443,
"gap": 1.5353012084960938
},
"endBinding": {
"elementId": "gIeBiC2fqLAh_jzsGEknB",
"focus": -0.01584678894333607,
"gap": 7.054367065429346
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "dot",
"points": [
[
0,
0
],
[
-83.17924499511719,
-271.5169525146483
]
]
},
{
"type": "arrow",
"version": 41,
"versionNonce": 1436150863,
"isDeleted": false,
"id": "RA_WqOlwJvd3VGyVXeGOC",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 143.93819681803336,
"y": 1292.188229878743,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 352.2896575927733,
"height": 260.1011276245115,
"seed": 999753753,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688305670853,
"link": null,
"locked": false,
"startBinding": {
"elementId": "HC2a-ym2pU-oHQIc_995A",
"focus": -0.719704607309612,
"gap": 2.458343505859375
},
"endBinding": {
"elementId": "MmjpQNm8e4X6meZXbUBEd",
"focus": -0.7049819035073658,
"gap": 3.5337778727204068
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "dot",
"points": [
[
0,
0
],
[
-122.5836944580077,
-179.93125915527344
],
[
-352.2896575927733,
-260.1011276245115
]
]
},
{
"type": "arrow",
"version": 46,
"versionNonce": 1518666785,
"isDeleted": false,
"id": "ZrKEOJwFCNqKazB6CJahM",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 112.30916849772086,
"y": 1346.9785359700516,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 553.0274963378904,
"height": 152.72441864013672,
"seed": 129523831,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688305670853,
"link": null,
"locked": false,
"startBinding": {
"elementId": "HC2a-ym2pU-oHQIc_995A",
"focus": -0.49260927353775746,
"gap": 2.4394989013672443
},
"endBinding": {
"elementId": "oBN5q2AVj21FpaTsmD9Up",
"focus": 0.2775831032462999,
"gap": 9.286567687988054
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "dot",
"points": [
[
0,
0
],
[
-304.026641845703,
-30.3009033203125
],
[
-553.0274963378904,
-152.72441864013672
]
]
},
{
"type": "arrow",
"version": 26,
"versionNonce": 1062570607,
"isDeleted": false,
"id": "fcXX9mod4agtBjd_Iuyc_",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 114.43784586588492,
"y": 1317.6760152180984,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 241.41380310058582,
"height": 122.27279663085938,
"seed": 1558695511,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688305670853,
"link": null,
"locked": false,
"startBinding": {
"elementId": "HC2a-ym2pU-oHQIc_995A",
"focus": -0.6573732928210425,
"gap": 1
},
"endBinding": {
"elementId": "C4et8o20fl-WIeItQxXWv",
"focus": 0.20482491354805857,
"gap": 6.065246582031023
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "dot",
"points": [
[
0,
0
],
[
-241.41380310058582,
-122.27279663085938
]
]
},
{
"type": "text",
"version": 58,
"versionNonce": 1214171084,
"isDeleted": false,
"id": "pHfVl0dYIJKDYcrEOMmK9",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -485.4066416422527,
"y": 860.0546188354491,
"strokeColor": "#c92a2a",
"backgroundColor": "transparent",
"width": 206.75975036621094,
"height": 25,
"seed": 1730994265,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1702173464927,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "This could be a mixin",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "This could be a mixin",
"lineHeight": 1.25,
"baseline": 17
},
{
"type": "rectangle",
"version": 40,
"versionNonce": 1638334889,
"isDeleted": false,
"id": "3p0_zCTdqx9_AMcvHeSUP",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -519.8740582531865,
"y": 411.80322863749427,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 258.247779712862,
"height": 50.58210925754861,
"seed": 12607113,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "Womk_dRN2udgdNiALINZ2"
},
{
"id": "Jbcd5LEPNRJ78cK3nbitw",
"type": "arrow"
}
],
"updated": 1688231916155,
"link": null,
"locked": false
},
{
"type": "text",
"version": 15,
"versionNonce": 1395615745,
"isDeleted": false,
"id": "Womk_dRN2udgdNiALINZ2",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -469.6300969856227,
"y": 424.5942832662686,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 157.75985717773438,
"height": 25,
"seed": 570760999,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305214163,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "BaseBSONModel",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "3p0_zCTdqx9_AMcvHeSUP",
"originalText": "BaseBSONModel",
"lineHeight": 1.25,
"baseline": 19
},
{
"type": "arrow",
"version": 113,
"versionNonce": 261414049,
"isDeleted": false,
"id": "Jbcd5LEPNRJ78cK3nbitw",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": -249.49425392563194,
"y": 106.93597491259342,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 136.5045325568691,
"height": 296.55682973278925,
"seed": 174030313,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688305650906,
"link": null,
"locked": false,
"startBinding": {
"elementId": "W9rThSS1FY0WVyk6rE3OQ",
"focus": 0.737708089535725,
"gap": 14.002427731958903
},
"endBinding": {
"elementId": "3p0_zCTdqx9_AMcvHeSUP",
"focus": 0.010760624764986902,
"gap": 8.310423992111623
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
-115.68117810338481,
86.74790906124068
],
[
-136.5045325568691,
296.55682973278925
]
]
},
{
"type": "ellipse",
"version": 95,
"versionNonce": 1689658415,
"isDeleted": false,
"id": "xEhH0_wN5jyLmPzyWIudj",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 1518.3688078791301,
"y": 646.3968910086662,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 287,
"height": 146,
"seed": 103681761,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [
{
"type": "text",
"id": "rYeofwk_nNG7xyGB5eiHG"
},
{
"id": "ffnkcB8EFZuallwlVdtdd",
"type": "arrow"
}
],
"updated": 1688305468405,
"link": null,
"locked": false
},
{
"type": "text",
"version": 78,
"versionNonce": 72399425,
"isDeleted": false,
"id": "rYeofwk_nNG7xyGB5eiHG",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 1584.4449518809113,
"y": 674.2780959820483,
"strokeColor": "#5c940d",
"backgroundColor": "transparent",
"width": 154.90806579589844,
"height": 90,
"seed": 1167370401,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305468406,
"link": null,
"locked": false,
"fontSize": 36,
"fontFamily": 1,
"text": "Model\nInstance",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "xEhH0_wN5jyLmPzyWIudj",
"originalText": "Model\nInstance",
"lineHeight": 1.25,
"baseline": 79
},
{
"type": "rectangle",
"version": 118,
"versionNonce": 695283727,
"isDeleted": false,
"id": "nMQA0fDC9bnsU9zPCt8-T",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 2765.250420021056,
"y": 560.7097979097082,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 306.9653320312502,
"height": 124.60088094075513,
"seed": 846558113,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "jbVsjGGw1mU8bnb6LUnSc"
},
{
"id": "AxaE7cUZ-BSE0VL7MjRoi",
"type": "arrow"
}
],
"updated": 1688305439128,
"link": null,
"locked": false
},
{
"type": "text",
"version": 112,
"versionNonce": 150318849,
"isDeleted": false,
"id": "jbVsjGGw1mU8bnb6LUnSc",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 2826.8970569839466,
"y": 578.0102383800858,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 183.67205810546875,
"height": 90,
"seed": 1658134689,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305455527,
"link": null,
"locked": false,
"fontSize": 36,
"fontFamily": 1,
"text": "BSON\n(MongoDB)",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "nMQA0fDC9bnsU9zPCt8-T",
"originalText": "BSON\n(MongoDB)",
"lineHeight": 1.25,
"baseline": 79
},
{
"type": "rectangle",
"version": 111,
"versionNonce": 1288279297,
"isDeleted": false,
"id": "Q3dEKBCmujX_Ay8m3DKMn",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 2777.9644499689725,
"y": 791.0046740083404,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 306.9653320312502,
"height": 124.60088094075513,
"seed": 846558113,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "vi3-gvUrw5do2_kJvWlA-"
},
{
"id": "fwvLwAUKgx8-QOkA0R5n2",
"type": "arrow"
}
],
"updated": 1688305442543,
"link": null,
"locked": false
},
{
"type": "text",
"version": 96,
"versionNonce": 1845950351,
"isDeleted": false,
"id": "vi3-gvUrw5do2_kJvWlA-",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 2884.431094256082,
"y": 830.805114478718,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 94.03204345703125,
"height": 45,
"seed": 1658134689,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305442543,
"link": null,
"locked": false,
"fontSize": 36,
"fontFamily": 1,
"text": "JSON",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "Q3dEKBCmujX_Ay8m3DKMn",
"originalText": "JSON",
"lineHeight": 1.25,
"baseline": 34
},
{
"type": "rectangle",
"version": 130,
"versionNonce": 77186127,
"isDeleted": false,
"id": "McMQd13P3SUAEnbjRKP8H",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 2771.457695411682,
"y": 1028.883397152872,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 306.9653320312502,
"height": 124.60088094075513,
"seed": 846558113,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "1YVzBghuiPwjZE1f6Kf6H"
},
{
"id": "aWk9xp3PGOP38CdG5mCOC",
"type": "arrow"
}
],
"updated": 1688305444498,
"link": null,
"locked": false
},
{
"type": "text",
"version": 122,
"versionNonce": 2114890785,
"isDeleted": false,
"id": "1YVzBghuiPwjZE1f6Kf6H",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 2867.1603397598265,
"y": 1068.6838376232495,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 115.56004333496094,
"height": 45,
"seed": 1658134689,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305444498,
"link": null,
"locked": false,
"fontSize": 36,
"fontFamily": 1,
"text": "Python",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "McMQd13P3SUAEnbjRKP8H",
"originalText": "Python",
"lineHeight": 1.25,
"baseline": 34
},
{
"type": "diamond",
"version": 76,
"versionNonce": 1443225089,
"isDeleted": false,
"id": "_UD33YwPqEc_7U1DlmWQQ",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 2012.7095264663694,
"y": 519.9193926362709,
"strokeColor": "#a61e4d",
"backgroundColor": "transparent",
"width": 436,
"height": 380,
"seed": 515878159,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [
{
"type": "text",
"id": "5d1hshx3Va0B-EZbB8HMy"
},
{
"id": "aWk9xp3PGOP38CdG5mCOC",
"type": "arrow"
},
{
"id": "fwvLwAUKgx8-QOkA0R5n2",
"type": "arrow"
},
{
"id": "vghGIdWJkjMlizQZuVSW3",
"type": "arrow"
},
{
"id": "ffnkcB8EFZuallwlVdtdd",
"type": "arrow"
}
],
"updated": 1688305472693,
"link": null,
"locked": false
},
{
"type": "text",
"version": 84,
"versionNonce": 1548729999,
"isDeleted": false,
"id": "5d1hshx3Va0B-EZbB8HMy",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 2145.9834980239866,
"y": 664.9193926362709,
"strokeColor": "#a61e4d",
"backgroundColor": "transparent",
"width": 169.45205688476562,
"height": 90,
"seed": 474636737,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305472693,
"link": null,
"locked": false,
"fontSize": 36,
"fontFamily": 1,
"text": "Pydantic\nValidation",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "_UD33YwPqEc_7U1DlmWQQ",
"originalText": "Pydantic\nValidation",
"lineHeight": 1.25,
"baseline": 79
},
{
"type": "arrow",
"version": 68,
"versionNonce": 109477889,
"isDeleted": false,
"id": "aWk9xp3PGOP38CdG5mCOC",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 2764.1829151382435,
"y": 1116.1441208622223,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 409.3854466414123,
"height": 313.4186826535429,
"seed": 1716494895,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688305444498,
"link": null,
"locked": false,
"startBinding": {
"elementId": "McMQd13P3SUAEnbjRKP8H",
"focus": -0.8242044715896969,
"gap": 7.274780273438182
},
"endBinding": {
"elementId": "_UD33YwPqEc_7U1DlmWQQ",
"focus": -0.011545353035105272,
"gap": 8.259361764991667
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
-409.3854466414123,
-313.4186826535429
]
]
},
{
"type": "arrow",
"version": 79,
"versionNonce": 880749999,
"isDeleted": false,
"id": "fwvLwAUKgx8-QOkA0R5n2",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 2770.499850359599,
"y": 879.076811385773,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 324.8976003957414,
"height": 133.87041983658412,
"seed": 1831724079,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688305442543,
"link": null,
"locked": false,
"startBinding": {
"elementId": "Q3dEKBCmujX_Ay8m3DKMn",
"focus": -0.733284930728151,
"gap": 7.4645996093740905
},
"endBinding": {
"elementId": "_UD33YwPqEc_7U1DlmWQQ",
"focus": -0.28030075065247295,
"gap": 24.55988922760585
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
-324.8976003957414,
-133.87041983658412
]
]
},
{
"type": "diamond",
"version": 111,
"versionNonce": 1792673185,
"isDeleted": false,
"id": "YbNRk88JD0E1IexXU5XNj",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 1843.6774423192337,
"y": 202.8566586681718,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 779,
"height": 132,
"seed": 173672705,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [
{
"type": "text",
"id": "lpwsZx0siAulVGMklvNWZ"
},
{
"id": "AxaE7cUZ-BSE0VL7MjRoi",
"type": "arrow"
},
{
"id": "vghGIdWJkjMlizQZuVSW3",
"type": "arrow"
}
],
"updated": 1688305429987,
"link": null,
"locked": false
},
{
"type": "text",
"version": 77,
"versionNonce": 1273716975,
"isDeleted": false,
"id": "lpwsZx0siAulVGMklvNWZ",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 2051.3034188817337,
"y": 246.3566586681718,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 364.248046875,
"height": 45,
"seed": 243339599,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1688305429987,
"link": null,
"locked": false,
"fontSize": 36,
"fontFamily": 1,
"text": "_parse_doc_to_obj",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "YbNRk88JD0E1IexXU5XNj",
"originalText": "_parse_doc_to_obj",
"lineHeight": 1.25,
"baseline": 34
},
{
"type": "arrow",
"version": 209,
"versionNonce": 407676993,
"isDeleted": false,
"id": "AxaE7cUZ-BSE0VL7MjRoi",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 2874.983367637454,
"y": 538.8150632091881,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 262.0004136275752,
"height": 263.3008043478154,
"seed": 1922409967,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688305439129,
"link": null,
"locked": false,
"startBinding": {
"elementId": "nMQA0fDC9bnsU9zPCt8-T",
"focus": 0.18565410250667447,
"gap": 21.894734700520075
},
"endBinding": {
"elementId": "YbNRk88JD0E1IexXU5XNj",
"focus": -0.9581021619285586,
"gap": 4.9444073183017
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
-262.0004136275752,
-263.3008043478154
]
]
},
{
"type": "arrow",
"version": 87,
"versionNonce": 509528865,
"isDeleted": false,
"id": "vghGIdWJkjMlizQZuVSW3",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 2238.1248068919213,
"y": 344.9067463195938,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 9.174741786770483,
"height": 173.8716567373507,
"seed": 1000023841,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688305435428,
"link": null,
"locked": false,
"startBinding": {
"elementId": "YbNRk88JD0E1IexXU5XNj",
"focus": -0.0230046831190309,
"gap": 10.735378960051221
},
"endBinding": {
"elementId": "_UD33YwPqEc_7U1DlmWQQ",
"focus": -0.054336970658854254,
"gap": 2.016173751099984
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
-9.174741786770483,
173.8716567373507
]
]
},
{
"type": "arrow",
"version": 23,
"versionNonce": 1428368993,
"isDeleted": false,
"id": "ffnkcB8EFZuallwlVdtdd",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 2009.864534944123,
"y": 710.0742224254997,
"strokeColor": "#000000",
"backgroundColor": "transparent",
"width": 190.68023950455336,
"height": 7.194336649818297,
"seed": 1128239631,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1688305466084,
"link": null,
"locked": false,
"startBinding": {
"elementId": "_UD33YwPqEc_7U1DlmWQQ",
"focus": 0.04637739376511212,
"gap": 2.8492014715156415
},
"endBinding": {
"elementId": "xEhH0_wN5jyLmPzyWIudj",
"focus": 0.052009991817207474,
"gap": 13.862731797900523
},
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"points": [
[
0,
0
],
[
-190.68023950455336,
7.194336649818297
]
]
},
{
"type": "text",
"version": 102,
"versionNonce": 128674548,
"isDeleted": false,
"id": "7XJlibFb5OHbfd8pUVf83",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 1951.1055225601194,
"y": 154.03484063431756,
"strokeColor": "#364fc7",
"backgroundColor": "transparent",
"width": 968.7963256835938,
"height": 45,
"seed": 873796033,
"groupIds": [],
"frameId": null,
"roundness": null,
"boundElements": [],
"updated": 1702173464928,
"link": null,
"locked": false,
"fontSize": 36,
"fontFamily": 1,
"text": "validate ODM schema (references, embedded models,...)",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "validate ODM schema (references, embedded models,...)",
"lineHeight": 1.25,
"baseline": 31
}
],
"appState": {
"gridSize": null,
"viewBackgroundColor": "#ffffff"
},
"files": {}
}
python-odmantic-1.0.2/docs/img/usage_fastapi_swagger.png 0000664 0000000 0000000 00000000202 14613034133 0023363 0 ustar 00root root 0000000 0000000 version https://git-lfs.github.com/spec/v1
oid sha256:8b4ce248fa26e05f17accc3bbadf995f5afb973fd0e6b50c2140fbcd49fec1d6
size 75423
python-odmantic-1.0.2/docs/index.md 0000777 0000000 0000000 00000000000 14613034133 0020673 2../README.md ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/js/ 0000775 0000000 0000000 00000000000 14613034133 0016171 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/docs/js/gitter.js 0000664 0000000 0000000 00000000116 14613034133 0020023 0 ustar 00root root 0000000 0000000 ((window.gitter = {}).chat = {}).options = {
room: "odmantic/community",
};
python-odmantic-1.0.2/docs/main.py 0000664 0000000 0000000 00000000747 14613034133 0017063 0 ustar 00root root 0000000 0000000 def define_env(env):
@env.macro
def async_sync_snippet(folder: str, filename: str, hl_lines=None, linenums=True):
return f"""
=== "Async"
```python {'linenums="1"' if linenums else ''} {'hl_lines="'+hl_lines+'"' if hl_lines is not None else ''}"
--8<-- "{folder}/async/{filename}"
```
=== "Sync"
```python {'linenums="1"' if linenums else ''} {'hl_lines="'+hl_lines+'"' if hl_lines is not None else ''}"
--8<-- "{folder}/sync/{filename}"
```
"""
python-odmantic-1.0.2/docs/migration_guide.md 0000664 0000000 0000000 00000010611 14613034133 0021244 0 ustar 00root root 0000000 0000000 # Migration Guide
## Migrating to v1
Before migrating ODMantic, have a look at the [Pydantic v2 migration guide](https://docs.pydantic.dev/dev/migration/).
### Upgrading to ODMantic v1
```bash
pip install -U pydantic
```
### Handling `Optional` with non-implicit default `None` values
Since this new version, the default value of an `Optional` field is not implicit anymore.
Thus, if you want to keep the same behavior, you have to add the `default` parameter to your `Optional` fields.
**Before:**
```python hl_lines="2"
class MyModel(Model):
my_field: Optional[str]
assert MyModel().my_field is None
```
**Now:**
```python hl_lines="2"
class MyModel(Model):
my_field: Optional[str] = None
assert MyModel().my_field is None
```
### Upgrading models configuration
Instead of the old `Config` class, you have to use the new `model_config` typed dict.
**Before:**
```python
class Event(Model):
date: datetime
class Config:
collection = "event_collection"
parse_doc_with_default_factories = True
indexes = [
Index(Event.date, unique=True),
pymongo.IndexModel([("date", pymongo.DESCENDING)]),
]
```
**Now:**
```python
class Event(Model):
date: datetime
model_config = {
"collection": "event_collection",
"parse_doc_with_default_factories": True,
"indexes": lambda: [
Index(Event.date, unique=True),
pymongo.IndexModel([("date", pymongo.DESCENDING)]),
],
}
```
### Defining custom BSON serializers
Instead of using the `__bson__` class method, you have to use the new [WithBsonSerializer][odmantic.bson.WithBsonSerializer] annotation.
!!! note
We will probably bring back the `__bson__` class method in a future version but
using the new annotation is the recommended way to define custom BSON serializers.
Here is an example of serializing an integer as a string in BSON:
**Before:**
```python
class IntBSONStr(int):
@classmethod
def __bson__(cls, v) -> str:
return str(v)
```
**Now:**
```python
from typing import Annotated
from odmantic import WithBsonSerializer
IntBSONStr = Annotated[int, WithBsonSerializer(lambda v: str(v))]
```
### Building a Pydantic model from an ODMantic model
If you want to build a Pydantic model from an ODMantic model, you now have to enable the
[`from_attributes`](https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.from_attributes){target="_blank"} configuration option.
For example, with a `UserModel` that is used internally and a `ResponseSchema` that
could be exposed through an API:
```python hl_lines="14"
from pydantic import BaseModel, EmailStr
from odmantic import Model
class UserModel(Model):
email: EmailStr
password_hash: str
class UserSchema(BaseModel):
email: EmailStr
class ResponseSchema(BaseModel):
user: UserSchema
model_config = {"from_attributes": True}
user = UserModel(email="john@doe.com", password_hash="...")
response = ResponseSchema(user=user)
```
### Replacing the `Model` and `EmbeddedModel` deprecated methods
- Replace `Model.dict` with the new `Model.model_dump` method
- Replace `Model.doc` with the new `Model.model_dump_doc` method
- Replace `Model.parse_doc` with the new `Model.model_validate_doc` method
- Replace `Model.update` with the new `Model.model_update` method
- Replace `Model.copy` with the new `Model.model_copy` method
### Custom JSON encoders on `odmantic.bson` types
Custom JSON encoders (defined with the `json_encoders` config option) are no longer
effective on `odmantic.bson` types since the builtin encoders cannot be overridden in
that way anymore.
The solution is to use the PlainSerializer annotation provided by Pydantic. For example,
if we want to serialize ObjectId as a `id_` prefixed string:
```python hl_lines="5 12"
from typing import Annotated
from pydantic import BaseModel, PlainSerializer
from odmantic import ObjectId
MyObjectId = Annotated[ObjectId, PlainSerializer(lambda v: "id_" + str(v))]
class MyModel(BaseModel):
id: MyObjectId
instance = MyModel(id=ObjectId("ffffffffffffffffffffffff"))
print(instance.model_dump_json())
#> {"id": "id_ffffffffffffffffffffffff"}
```
---
And ... that's it, congrats! 🚀⚒️
If you have any questions or if you need help to migrate something that is not covered
by this guide, feel free to open an issue on [GitHub](https://github.com/art049/odmantic/issues){target="_blank"}.
python-odmantic-1.0.2/docs/modeling.md 0000664 0000000 0000000 00000045042 14613034133 0017702 0 ustar 00root root 0000000 0000000 # Modeling
## Models
To create a Model, simply inherit from the [Model][odmantic.model.Model] class and then
specify the field types and eventually their descriptors.
### Collection
Each Model will be linked to its own collection. By default, the collection name will be
created from the chosen class name and converted to
[snake_case](https://en.wikipedia.org/wiki/Snake_case). For example a model class named
`CapitalCity` will be stored in the collection named `capital_city`.
If the class name ends with `Model`, ODMantic will remove it to create the collection
name. For example, a model class named `PersonModel` will belong in the `person`
collection.
It's possible to customize the collection name of a model by specifying the `collection`
option in the `model_config` class attribute.
!!! example "Custom collection name example"
```python hl_lines="7-8"
from odmantic import Model
class CapitalCity(Model):
name: str
population: int
model_config = {
"collection": "city"
}
```
Now, when `CapitalCity` instances will be persisted to the database, they will
belong in the `city` collection instead of `capital_city`.
!!! warning
Models and Embedded models inheritance is not supported yet.
### Indexes
#### Index definition
There are two ways to create indexes on a model in ODMantic. The first one is to use the
Field descriptors as explained in [Indexed fields](fields.md#indexed-fields) or
[Unique fields](fields.md#unique-fields). However, this way doesn't allow a great
flexibility on index definition.
That's why you can also use the `model_config.indexes` generator to specify advanced indexes
(compound indexes, custom names). This static function defined in the `model_config` class
attribute should yield [odmantic.Index][odmantic.index.Index].
For example:
```python hl_lines="5 8 11-19" linenums="1"
--8<-- "modeling/compound_index.py"
```
This snippet creates 4 indexes on the `Product` model:
- An index on the `name` field defined with
[the field descriptor](fields.md#indexed-fields), improving lookup performance by
product name.
- A unique index on the `sku` field defined with
[the field descriptor](fields.md#unique-fields), enforcing uniqueness of the `sku`
field.
- A compound index on the `name` and `stock` fields, making sure the following query
will be efficient (i.e. avoid a full collection scan):
```python
engine.find(Product, Product.name == "banana", Product.stock > 5)
```
- A unique index on the `name` and `category` fields, making sure each category has
unique product name.
!!! Tip "Sort orders with index definition"
You can also specify the sort order of the fields in the index definition using
[query.asc][odmantic.query.asc] and [query.desc][odmantic.query.desc] as presented
in the [Sorting](querying.md#sorting) section.
For example defining the following index on the `Event` model:
```python linenums="1" hl_lines="11-14"
--8<-- "modeling/compound_index_sort_order.py"
```
Will greatly improve the performance of the query:
```python
engine.find(Event, sort=(asc(Event.name), desc(Event.date))
```
#### Index creation
In order to create and enable the indexes in the database, you need to call the
`engine.configure_database` method
(either [AIOEngine.configure_database][odmantic.engine.AIOEngine.configure_database] or
[SyncEngine.configure_database][odmantic.engine.SyncEngine.configure_database]).
{{ async_sync_snippet("modeling", "index_creation.py", hl_lines="6") }}
This method can also take a `#!python update_existing_indexes=True` parameter to update existing
indexes when the index definition changes. If not enabled, an exception will be thrown
when a conflicting index update happens.
#### Advanced indexes
In some cases, you might need a greater flexibility on the index definition (Geo2D,
Hashed, Text indexes for example), the
`Config.indexes` generator can also yield [pymongo.IndexModel](https://pymongo.readthedocs.io/en/stable/api/pymongo/operations.html?highlight=indexmodel#pymongo.operations.IndexModel){:target=blank_}
objects.
For example, defining a [text index](https://www.mongodb.com/docs/manual/core/index-text/){:target=blank_} :
```python hl_lines="11-15" linenums="1"
--8<-- "modeling/custom_text_index.py"
```
### Custom model validators
Exactly as done with pydantic, it's possible to define custom model validators as
described in the [pydantic: Root
Validators](https://docs.pydantic.dev/latest/usage/validators/){:target=blank_}
documentation (this apply as well to Embedded Models).
In the following example, we will define a rectangle class and add two validators: The
first one will check that the height is greater than the width. The second one will
ensure that the area of the rectangle is less or equal to 9.
```python hl_lines="9 14-15 18-19 22-23 26-27 35 40-41 45 50-51 55 60-61" linenums="1"
--8<-- "modeling/custom_validators.py"
```
!!! tip
You can define class variables in the Models using the `typing.ClassVar` type
construct, as done in this example with `MAX_AREA`. Those class variables will be
completely ignored by ODMantic while persisting instances in the database.
### Advanced Configuration
The model configuration is done in the same way as with Pydantic models: using a
[ConfigDict](https://docs.pydantic.dev/latest/usage/model_config/){:target=blank_} `model_config` defined in the model body.
Here is an example of a model configuration:
```python
class Event(Model):
date: datetime
model_config = {
"collection": "event_collection",
"parse_doc_with_default_factories": True,
"indexes": lambda: [
Index(Event.date, unique=True),
pymongo.IndexModel([("date", pymongo.DESCENDING)]),
],
}
```
#### Available options
`#!python collection: str`
: Customize the collection name associated to the model. See [this
section](modeling.md#collection) for more details about default collection naming.
`#!python parse_doc_with_default_factories: bool`
: Wether to allow populating field values with default factories while parsing
documents from the database. See
[Advanced parsing behavior](raw_query_usage.md#advanced-parsing-behavior) for more
details.
Default: `#!python False`
`#!python indexes: Callable[[],Iterable[Union[Index, pymongo.IndexModel]]]`
: Define additional indexes for the model. See [Indexes](modeling.md#indexes) for
more details.
Default: `#!python lambda: []`
`#!python title: str` *(inherited from Pydantic)*
: Title inferred in the JSON schema.
Default: name of the model class
`#!python schema_extra: dict` *(inherited from Pydantic)*
: A dict used to extend/update the generated JSON Schema, or a callable to
post-process it. See [Pydantic: Schema customization](https://docs.pydantic.dev/latest/usage/json_schema/#schema-customization){:target=_blank} for more details.
Default: `#!python {}`
`#!python anystr_strip_whitespace: bool` *(inherited from Pydantic)*
: Whether to strip leading and trailing whitespaces for str & byte types.
Default: `#!python False`
`#!python json_encoders: dict` *(inherited from Pydantic)*
: Customize the way types used in the model are encoded to JSON.
??? example "`json_encoders` example"
For example, in order to serialize `datetime` fields as timestamp values:
```python
class Event(Model):
date: datetime
model_config = {
"json_encoders": {
datetime: lambda v: v.timestamp()
}
}
```
`#!python extra: pydantic.Extra` *(inherited from Pydantic)*
: Whether to ignore, allow, or forbid extra attributes during model initialization. Accepts the string values of 'ignore', 'allow', or 'forbid', or values of the Extra enum. 'forbid' will cause validation to fail if extra attributes are included, 'ignore' will silently ignore any extra attributes, and 'allow' will assign the attributes to the model, reflecting them in the saved database documents and fetched instances.
Default: `#!python Extra.ignore`
For more details and examples about the options inherited from Pydantic, you can have a
look at [Pydantic: Model
Config](https://docs.pydantic.dev/latest/usage/model_config/){:target=blank_}
!!! warning
Only the options described above are supported and other options from Pydantic can't
be used with ODMantic.
If you feel the need to have an additional option inherited from Pydantic, you can
[open an issue](https://github.com/art049/odmantic/issues/new){:target=blank}.
## Embedded Models
Using an embedded model will store it directly in the root model it's integrated
in. On the MongoDB side, the collection will contain the root documents and in inside
each of them, the embedded models will be directly stored.
Embedded models are especially useful while building
[one-to-one](https://en.wikipedia.org/wiki/One-to-one_(data_model)){:target=_blank} or
[one-to-many](https://en.wikipedia.org/wiki/One-to-many_(data_model)){:target=_blank}
relationships.
!!! note
Since Embedded Models are directly embedded in the MongoDB collection of the root
model, it will not be possible to query on them directly without specifying a root
document.
The creation of an Embedded model is done by inheriting the
[EmbeddedModel][odmantic.model.EmbeddedModel] class. You can then define fields exactly
as for the regular Models.
### One to One
In this example, we will model the relation between a country and its capital city.
Since one capital city can belong to one and only one country, we can model this
relation as a One-to-One relationship. We will use an Embedded Model in this case.
```python hl_lines="4 12 19 24" linenums="1"
--8<-- "modeling/one_to_one.py"
```
Defining this relation is done in the same way as defining a new field. Here, the
`CapitalCity` class will be considered as a field type during the model definition.
The [Field][odmantic.field.Field] descriptor can be used as well for Embedded
Models in order to bring more flexibility (default values, Mongo key name, ...).
???+abstract "Content of the `country` collection after execution"
```json hl_lines="5-8 14-17"
{
"_id": ObjectId("5f79d7e8b305f24ca43593e2"),
"name": "Sweden",
"currency": "Swedish krona",
"capital_city": {
"name": "Stockholm",
"population": 975904
}
}
{
"_id": ObjectId("5f79d7e8b305f24ca43593e1"),
"name": "Switzerland",
"currency": "Swiss franc",
"capital_city": {
"name": "Bern",
"population": 1035000
}
}
```
!!! tip
It is possible as well to define query filters based on embedded documents content.
```python hl_lines="2"
--8<-- "modeling/one_to_one_1.py"
```
For more details, see the [Querying](querying.md) section.
### One to Many
Here, we will model the relation between a customer of an online shop and his shipping
addresses. A single customer can have multiple addresses but these addresses belong only
to the customer's account. He should be allowed to modify them without modifying others
addresses (for example if two family members use the same address, their addresses
should not be linked together).
```python hl_lines="6 15 20-33" linenums="1"
--8<-- "modeling/one_to_many.py"
```
As done previously for the One to One relation, defining a One to Many relationship with
Embedded Models is done exactly as defining a field with its type being a sequence of
`Address` objects.
???+abstract "Content of the `customer` collection after execution"
```json hl_lines="4-17"
{
"_id": ObjectId("5f79eb116371e09b16e4fae4"),
"name":"John Doe",
"addresses":[
{
"street":"1757 Birch Street",
"city":"Greenwood",
"state":"Indiana",
"zipcode":"46142"
},
{
"street":"262 Barnes Avenue",
"city":"Cincinnati",
"state":"Ohio",
"zipcode":"45216"
}
]
}
```
!!! tip
To add conditions on the number of embedded elements, it's possible to use the
`min_items` and `max_items` arguments of the [Field][odmantic.field.Field]
descriptor. Another possibility is to use the `typing.Tuple` type.
!!! note
Building query filters based on the content of a sequence of embedded documents is
not supported yet (but this feature is planned for an upcoming release :fire:).
Anyway, it's still possible to perform the filtering operation manually using Mongo
[Array Operators](https://docs.mongodb.com/manual/reference/operator/query-array/){:target=_blank}
(`$all`, `$elemMatch`, `$size`). See the [Raw query usage](raw_query_usage.md) section for
more details.
### Customization
Since the Embedded Models are considered as types by ODMantic, most of the complex type
constructs that could be imagined should be supported.
Some ideas which could be useful:
- Combine two different embedded models in a single field using `typing.Tuple`.
- Allow multiple Embedded model types using a `typing.Union` type.
- Make an Embedded model not required using `typing.Optional`.
- Embed the documents in a dictionary (using the `typing.Dict` type) to provide an
additional key-value mapping to the embedded documents.
- Nest embedded documents
## Referenced models
Embedded models are really simple to use but sometimes it is needed as well to
have **many-to-one** (i.e. multiple entities referring to another single one) or
[many-to-many](https://en.wikipedia.org/wiki/Many-to-many_(data_model)){:target=_blank}
relationships. This is not really possible to model those using embedded documents and
in this case, references will come handy.
Another use case where references are useful is for one-to-one/one-to-many relations but
when the referenced model has to exist in its own collection, in order to be accessed on
its own without any parent model specified.
### Many to One (Mapped)
In this part, we will model the relation between books and publishers. Let's consider that each
book has a single publisher. In this case, multiple books could be published by the same
publisher. We can thus model this relation as a many-to-one relationship.
```python hl_lines="4 10 13 19-23" linenums="1"
--8<-- "modeling/many_to_one.py"
```
The definition of a reference field **requires** the presence of the [Reference()][odmantic.reference.Reference]
descriptor. Once the models are defined, linking two instances is done simply by
assigning the reference field of the referencing instance to the referenced instance.
??? question "Why is it required to include the Reference descriptor ?"
The main goal behind enforcing the presence of the descriptor is to have a clear
distinction between Embedded Models and References.
In the future, a generic `Reference[T]` type will probably be included to make this
distinction since it would make more sense than having to set a descriptor for each
reference.
???+abstract "Content of the `publisher` collection after execution"
```json hl_lines="2 8"
{
"_id": ObjectId("5f7a0dc48a73b20f16e2a364"),
"founded": 1826,
"location": "FR",
"name": "Hachette Livre"
}
{
"_id": ObjectId("5f7a0dc48a73b20f16e2a365"),
"founded": 1989,
"location": "US",
"name": "HarperCollins"
}
```
We can see that the publishers have been persisted to their collection even if no
explicit save has been performed.
When calling the [engine.save][odmantic.engine.AIOEngine.save] method, the engine
will persist automatically the referenced documents.
While fetching instances, the engine will as well resolve **every** reference.
???+abstract "Content of the `book` collection after execution"
```json hl_lines="4 10 16"
{
"_id": ObjectId("5f7a0dc48a73b20f16e2a366"),
"pages": 304,
"publisher": ObjectId("5f7a0dc48a73b20f16e2a364"),
"title": "They Didn't See Us Coming"
}
{
"_id": ObjectId("5f7a0dc48a73b20f16e2a367"),
"pages": 256,
"publisher": ObjectId("5f7a0dc48a73b20f16e2a364"),
"title": "This Isn't Happening"
}
{
"_id": ObjectId("5f7a0dc48a73b20f16e2a368"),
"pages": 464,
"publisher": ObjectId("5f7a0dc48a73b20f16e2a365"),
"title": "Prodigal Summer"
}
```
The resulting books in the collection contain the publisher reference directly as a
document attribute (using the reference name as the document's key).
!!! tip
It's possible to customize the foreign key storage key using the `key_name` argument
while building the [Reference][odmantic.reference.Reference] descriptor.
### Many to Many (Manual)
Here, we will model the relation between books and their authors. Since a book can have
multiple authors and an author can be authoring multiple books, we will model this
relation as a many-to-many relationship.
!!! note
Currently, ODMantic does not support mapped multi-references yet. But we will still
define the relationship in a manual way.
```python hl_lines="15 18-19 22 25 29" linenums="1"
--8<-- "modeling/many_to_many.py"
```
We defined an `author_ids` field which holds the list of unique ids of the authors (This
`id` field in the `Author` model is generated implicitly by default).
Since this multi-reference is not mapped by the ODM, we have to persist the authors
manually.
???+abstract "Content of the `author` collection after execution"
```json hl_lines="2 6"
{
"_id": ObjectId("5f7a37dc7311be1362e1da4e"),
"name": "David Beazley"
}
{
"_id": ObjectId("5f7a37dc7311be1362e1da4f"),
"name": "Brian K. Jones"
}
```
???+abstract "Content of the `book` collection after execution"
```json hl_lines="5-8 14-16"
{
"_id": ObjectId("5f7a37dc7311be1362e1da50"),
"title":"Python Cookbook"
"pages":706,
"author_ids":[
ObjectId("5f7a37dc7311be1362e1da4e"),
ObjectId("5f7a37dc7311be1362e1da4f")
],
}
{
"_id": ObjectId("5f7a37dc7311be1362e1da51"),
"title":"Python Essential Reference"
"pages":717,
"author_ids":[
ObjectId("5f7a37dc7311be1362e1da4f")
],
}
```
!!! example "Retrieving the authors of the Python Cookbook"
First, it's required to fetch the ids of the authors. Then we can use the
[in_][odmantic.query.in_] filter to select only the authors with the desired ids.
```python hl_lines="2" linenums="1"
--8<-- "modeling/many_to_many_1.py"
```
python-odmantic-1.0.2/docs/querying.md 0000664 0000000 0000000 00000020317 14613034133 0017745 0 ustar 00root root 0000000 0000000 # Querying
## Filtering
ODMantic uses [QueryExpression][odmantic.query.QueryExpression] objects to handle filter
expressions. These expressions can be built from the comparison operators. It's then
possible to combine multiple expressions using the logical operators. To support the
wide variety of operators provided by MongoDB, it's possible as well to define the
filter 'manually'.
### Comparison operators
There are multiple ways of building [QueryExpression][odmantic.query.QueryExpression]
objects with comparisons operators:
1. Using python comparison operators between the field of the model and the desired value
: `==`, `!=`, `<=`, `<`, `>=`, `>`
2. Using the functions provided by the `odmantic.query` module
- [query.eq][odmantic.query.eq]
- [query.ne][odmantic.query.ne]
- [query.gt][odmantic.query.gt]
- [query.gte][odmantic.query.gte]
- [query.lt][odmantic.query.lt]
- [query.lte][odmantic.query.lte]
- [query.in_][odmantic.query.in_]
- [query.not_in][odmantic.query.not_in]
3. Using methods of the model's field and the desired value
- `field.eq`
- `field.ne`
- `field.gte`
- `field.gt`
- `field.lte`
- `field.lte`
- `field.in_`
- `field.not_in`
!!! note "Type checkers"
Since there is currently not any type checker plugin, the third usage might create
some errors with type checkers.
#### Equal
Filter the trees named "Spruce":
```python linenums="1" hl_lines="9 11 13"
--8<-- "querying/equal.py"
```
Equivalent raw MongoDB filter:
```json
{"name": "Spruce"}
```
!!! tip "Using equality operators with Enum fields"
Building filters using `Enum` fields is possible as well.
???+example "Example of filter built on an Enum field"
Filter the 'small' trees:
```python linenums="1" hl_lines="6-8 14 17 19 21"
--8<-- "querying/enum.py"
```
Equivalent raw MongoDB filter:
```json
{'kind': 'small'}
```
[More details](fields.md#enum-fields) about Enum fields.
#### Not Equal
Filter the trees that are **not** named "Spruce":
```python linenums="1" hl_lines="9 11 13"
--8<-- "querying/not_equal.py"
```
Equivalent raw MongoDB filter:
```json
{"name": {"$ne": "Spruce"}}
```
#### Less than (or equal to)
Filter the trees that have a size that is less than (or equal to) 2:
```python linenums="1" hl_lines="9 11 13 16 18 20"
--8<-- "querying/lt_e.py"
```
Equivalent raw MongoDB filter (less than):
```json
{"average_size": {"$lt": 2}}
```
Equivalent raw MongoDB filter (less than or equal to):
```json
{"average_size": {"$lte": 2}}
```
#### Greater than (or equal to)
Filter the trees having a size that is greater than (or equal to) 2:
```python linenums="1" hl_lines="9 11 13 16 18 20"
--8<-- "querying/gt_e.py"
```
Equivalent raw MongoDB filter (greater than):
```json
{"average_size": {"$gt": 2}}
```
Equivalent raw MongoDB filter (greater than or equal to):
```json
{"average_size": {"$gte": 2}}
```
#### Included in
Filter the trees named either "Spruce" or "Pine":
```python linenums="1" hl_lines="9 11"
--8<-- "querying/in.py"
```
Equivalent raw MongoDB filter:
```json
{"name": {"$in": ["Spruce", "Pine"]}}
```
#### Not included in
Filter the trees neither named "Spruce" nor "Pine":
```python linenums="1" hl_lines="9 11"
--8<-- "querying/not_in.py"
```
Equivalent raw MongoDB filter:
```json
{"name": {"$nin": ["Spruce", "Pine"]}}
```
### Evaluation operators
#### Match (Regex)
Filter the trees with a name starting with 'Spruce':
```python linenums="1" hl_lines="8 10"
--8<-- "querying/match.py"
```
Equivalent raw MongoDB filter:
```json
{"name": {"$regex": "^Spruce"}}
```
### Logical operators
There are two ways of combining [QueryExpression][odmantic.query.QueryExpression]
objects with logical operators:
1. Using python 'bitwise' operators between the field of the model and the desired value
: `&`, `|`
!!! warning
When using those operators make sure to correctly bracket the expressions
to avoid python operator precedence issues.
2. Using the functions provided by the `odmantic.query` module
- [query.and_][odmantic.query.and_]
- [query.or_][odmantic.query.or_]
- [query.nor_][odmantic.query.nor_]
#### And
Filter the trees named Spruce (**AND**) with a size less than 2:
```python linenums="1" hl_lines="9 18"
--8<-- "querying/and.py"
```
Equivalent raw MongoDB filter:
```json
{"name": "Spruce", "size": {"$lte": 2}}}
```
!!! tip "Implicit AND"
When using [find][odmantic.engine.AIOEngine.find],
[find_one][odmantic.engine.AIOEngine.find_one] or
[count][odmantic.engine.AIOEngine.count], you can specify multiple queries as
positional arguments and those will be implicitly combined with the `AND` operator.
#### Or
Filter the trees named Spruce **OR** the trees with a size greater than 2:
```python linenums="1" hl_lines="9 18"
--8<-- "querying/or.py"
```
Equivalent raw MongoDB filter:
```json
{
"$or":[
{"name":"Spruce"},
{"size":{"$gt":2}}
]
}
```
#### Nor
Filter the trees neither named Spruce **NOR** bigger than 2 (size):
```python linenums="1" hl_lines="9"
--8<-- "querying/nor.py"
```
Equivalent raw MongoDB filter:
```json
{
"$nor":[
{"name":"Spruce"},
{"size":{"$gt":2}}
]
}
```
!!! tip "NOR Equivalence"
The following logical expressions are equivalent:
- A NOR B NOR C
- NOT(A OR B OR C)
- NOT(A) AND NOT(B) AND NOT(C)
!!! info "`query.nor_` operator naming"
[query.and_][odmantic.query.and_] and [query.or_][odmantic.query.or_] require to add
an extra underscore to avoid overlapping with the python keywords.
While it could've been possible to name the NOR operator query.nor, the extra underscore has been kept for consistency in the naming of the logical operators.
### Embedded documents filters
It's possible to build filter based on the content of embedded documents:
```python linenums="1" hl_lines="4 9 12 15 17"
--8<-- "querying/embedded.py"
```
Equivalent raw MongoDB filters:
```json
{"capital_city.name": {"$eq": "Paris"}}
```
```json
{"capital_city.population": {"$gt": 1000000}}
```
!!! warning "Filtering across References"
Currently, it is not possible to build filter based on referenced objects.
### Raw MongoDB filters
Any [QueryExpression][odmantic.query.QueryExpression] can be replaced with raw MongoDB filters.
Thus, it's completely possible to use traditional filters with the
[find][odmantic.engine.AIOEngine.find], [find_one][odmantic.engine.AIOEngine.find_one]
or [count][odmantic.engine.AIOEngine.count] methods.
You can find more details about building raw query filters using the Model in the [Raw
query usage](raw_query_usage.md#using-raw-mongodb-filters) section.
## Sorting
ODMantic uses [SortExpression][odmantic.query.SortExpression] objects to handle sort
expressions.
There are multiple ways of building [SortExpression][odmantic.query.SortExpression]
objects:
1. Using implicit `Model` fields:
!!! example "Ascending sort"
To sort `Publisher` instances by **ascending** `Publisher.founded`:
```python
await engine.find(Publisher, sort=Publisher.founded)
```
This example refers to the code showcased in the [Overview](index.md#define-your-first-model).
2. Using the functions provided by the `odmantic.query` module
- [query.asc][odmantic.query.asc]
- [query.desc][odmantic.query.desc]
3. Using methods of the model's field and the desired value
- `field.asc`
- `field.desc`
!!! note "Type checkers"
Since there is currently not any type checker plugin, the third usage might create
some errors with type checkers.
### Ascending
```python linenums="1" hl_lines="14-16"
--8<-- "querying/asc.py"
```
### Descending
```python linenums="1" hl_lines="14-15"
--8<-- "querying/desc.py"
```
### Sort on multiple fields
We can pass a `tuple` to the `sort` kwarg, this will enable us to make a more complex sort query:
```python linenums="1" hl_lines="14"
--8<-- "querying/multiple_sort.py"
```
### Embedded model field as a sort key
We can sort instances based on the content of their embedded models.
!!! example "Sorting by an embedded model field"
We can sort the countries by descending order of the population of their capital
city:
```python linenums="1" hl_lines="5 13 17"
--8<-- "querying/embedded_sort.py"
```
python-odmantic-1.0.2/docs/raw_query_usage.md 0000664 0000000 0000000 00000010467 14613034133 0021311 0 ustar 00root root 0000000 0000000 # Raw query usage
As ODMantic doesn't completely wrap the MongoDB API, some helpers are provided to be
enhance the usability while building raw queries and interacting with raw documents.
## Raw query helpers
### Collection name
You can get the collection name associated to a model by using the unary `+` operator on
the model class.
```python linenums="1"
--8<-- "raw_query_usage/collection_name.py"
```
### Motor collection
The [AIOEngine][odmantic.engine.AIOEngine] object can provide you directly the motor
collection
([AsyncIOMotorCollection](https://motor.readthedocs.io/en/stable/api-asyncio/asyncio_motor_collection.html){:target=blank_})
linked to the motor client used by the engine. To achieve this, you can use the
[AIOEngine.get_collection][odmantic.engine.AIOEngine.get_collection] method.
```python linenums="1" hl_lines="9"
--8<-- "raw_query_usage/motor_collection.py"
```
### PyMongo collection
The [SyncEngine][odmantic.engine.SyncEngine] object can provide you directly the PyMongo
collection
([pymongo.collection.Collection](https://pymongo.readthedocs.io/en/stable/api/pymongo/collection.html){:target=blank_})
linked to the PyMongo client used by the engine. To achieve this, you can use the
[SyncEngine.get_collection][odmantic.engine.SyncEngine.get_collection] method.
```python linenums="1" hl_lines="9"
--8<-- "raw_query_usage/pymongo_collection.py"
```
### Key name of a field
Since some field might have some [customized key names](fields.md#document-structure),
you can get the key name associated to a field by using the unary `+` operator on the
model class. As well, to ease the use of aggregation pipelines where you might need to
reference your field (`$field`), you can double the operator (i.e use `++`) to get the
field reference name.
```python linenums="1"
--8<-- "raw_query_usage/field_key_name.py"
```
## Using raw MongoDB filters
Any [QueryExpression][odmantic.query.QueryExpression] can be replaced by its raw filter
equivalent.
For example, with a Tree model:
```python linenums="1"
--8<-- "raw_query_usage/raw_query_filters.py"
```
All the following find queries would give exactly the same results:
```python
--8<-- "raw_query_usage/raw_query_filters_1.py"
```
## Raw MongoDB documents
### Parsing documents
You can parse MongoDB document to instances using the
[model_validate_doc][odmantic.model._BaseODMModel.model_validate_doc] method.
!!! tip
If the provided documents contain extra fields, ODMantic will ignore them. This can
be especially useful in aggregation pipelines.
```python linenums="1" hl_lines="20 27 38-39 44"
--8<-- "raw_query_usage/create_from_raw.py"
```
### Dumping documents
You can generate a document from instances using the
[model_dump_doc][odmantic.model._BaseODMModel.model_dump_doc] method.
```python linenums="1" hl_lines="20 27 38-39 44"
--8<-- "raw_query_usage/extract_from_existing.py"
```
### Advanced parsing behavior
#### Default values
While parsing documents, ODMantic will use the default values provided in the Models to populate the missing fields from the documents:
```python linenums="1" hl_lines="8 11 18"
--8<-- "raw_query_usage/parse_with_unset_default.py"
```
#### Default factories
For the field with default factories provided through the Field descriptor though, by
default they wont be populated.
```python linenums="1" hl_lines="12 15 21-24"
--8<-- "raw_query_usage/parse_with_unset_default_factory.py"
```
In the previous example, using the default factories could create data inconsistencies
and in this case, it would probably be more suitable to perform a manual migration to
provide the correct values.
Still, the `parse_doc_with_default_factories`
[Config](modeling.md#advanced-configuration) option can be used to allow the use of the
default factories while parsing documents:
```python linenums="1" hl_lines="12 15 18 25"
--8<-- "raw_query_usage/parse_with_unset_default_factory_enabled.py"
```
## Aggregation example
In the following example, we will demonstrate the use of the previous helpers to build
an aggregation pipeline. We will first consider a `Rectangle` model with two float
fields (`height` and `length`). We will then fetch the rectangles with an area that is
less than 10. To finish, we will reconstruct `Rectangle` instances from this query.
```python linenums="1" hl_lines="20 27 38-39 44"
--8<-- "raw_query_usage/aggregation_example.py"
```
python-odmantic-1.0.2/docs/sitemap.xml 0000664 0000000 0000000 00000001074 14613034133 0017743 0 ustar 00root root 0000000 0000000
{%- for file in pages -%}
{% if not file.page.is_link %}
{% if file.page.canonical_url %}{{ file.page.canonical_url|e }}{% else %}{{ file.page.abs_url|e }}{% endif %}
{% if file.page.update_date %}{{file.page.update_date}}{% endif %}
daily
{% if not file.page.url %}1.0{% else %}0.5{% endif %}
{%- endif -%}
{% endfor %}
python-odmantic-1.0.2/docs/usage_fastapi.md 0000664 0000000 0000000 00000062654 14613034133 0020727 0 ustar 00root root 0000000 0000000 # Usage with FastAPI
## Example
In this example, we create a minimalist REST API describing trees by their name, average size and discovery year.
!!! info "Requirements"
To run the following example, you'll need to install FastAPI and Uvicorn.
```shell
pip install fastapi uvicorn
```
```python linenums="1"
--8<-- "usage_fastapi/base_example.py"
```
You can then start the application. For example if you saved the file above in a file
named `tree_api.py`:
```python
uvicorn tree_api:app
```
Uvicorn should start serving the API locally:
```
INFO: Started server process [21429]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://localhost:8080 (Press CTRL+C to quit)
```
To try it out, you can simply access the interactive documentation generated by FastAPI
at [http://localhost:8080/docs](http://localhost:8080/docs){:target=blank_}.
{: align=left }
We'll now dive in the details of this example.
### Defining the model
First, we create our `Tree` model.
```python
class Tree(Model):
name: str
average_size: float
discovery_year: int
```
This describes our `Tree` instances structure both for JSON serialization and for the
storage in the MongoDB collection.
### Building the engine
After having defined the model, we create the [AIOEngine][odmantic.engine.AIOEngine]
object. This object will be responsible for performing database operations.
```python
engine = AIOEngine()
```
It's possible as well to build the engine with custom parameters (mongo URI,
database name). See [this section](engine.md#creating-the-engine) for more details.
!!! tip "Running the python file directly"
If you need to execute the python file directly with the interpreter (to use a
debugger for example), some extra steps will be required.
Run `uvicorn` using the default event loop (if the file is called directly):
```python
if __name__ == "__main__":
import asyncio
import uvicorn
loop = asyncio.get_event_loop()
config = uvicorn.Config(app=app, port=8080, loop=loop)
server = uvicorn.Server(config)
loop.run_until_complete(server.serve())
```
??? info "`uvicorn.run` behavior with event loops (Advanced)"
The usual entrypoint `uvicorn.run(app)` for ASGI apps doesn't work because when
called `uvicorn` will create and run a brand **new** event loop.
Thus, the engine object will be bound to a different event loop that will not be
running. In this case, you'll witness ` attached to a different
loop` errors because the app itself will be running in a different event loop
than the engine's driver.
Anyway, when running directly the app through the `uvicorn` CLI, the default
event loop will be the one that will be running later, so no modifications are
required.
!!! warning "AIOEngineDependency deprecation (from v0.2.0)"
The `AIOEngineDependency` that was used to inject the engine in the API routes is
now deprecated (it will be kept for few versions though).
Using a global engine object should be preferred as it will dramatically reduce the
required verbosity to use the engine in an endpoint.
If you need to run your `app` directly from a python file, see the above **Running the
python file directly** section.
### Creating a tree
The next step is to define a route enabling us to create a new tree. To that end, we
create a `PUT` route with the path `/trees/`. This endpoint will receive a tree,
persist it to the database and return the created object.
```python
@app.put("/trees/", response_model=Tree)
async def create_tree(tree: Tree):
await engine.save(tree)
return tree
```
First, the request body will be parsed to a `Tree` object (this is done by
specifying the argument `tree: Tree`). This mean that the model validation will be
performed. Once the model is parsed to a Tree instance, we persist it to the
database and we return it.
???+tip "Command line tool for interacting with JSON based HTTP APIs"
To interact with the API from the command line, we recommend to use the
[HTTPie](https://httpie.org/){:target=blank_} CLI.
The next examples are still provided with the `curl` syntax since the Swagger
documentation generated by FastAPI will give you curl examples directly.
!!! example "Creating a Tree from the command line"
=== "HTTPie"
Send the request:
```shell
http PUT localhost:8080/trees/ name="Spruce" discovery_year=1995 average_size=2
```
Output:
```hl_lines="10"
HTTP/1.1 200 OK
content-length: 90
content-type: application/json
date: Sun, 18 Oct 2020 18:40:30 GMT
server: uvicorn
{
"average_size": 2.0,
"discovery_year": 1995,
"id": "5f8c8c1ff1d33aa1012f3086",
"name": "Spruce"
}
```
=== "curl"
Send the request:
```shell
curl -X PUT "http://localhost:8080/trees/" \
-H "Content-Type: application/json" \
-d '{"name":"Spruce", "discovery_year":1995, "average_size":2}'
```
Output:
```
{"name":"Spruce","average_size":2.0,"discovery_year":1995,"id":"5f8c8c1ff1d33aa1012f3086"}
```
You can notice that the `id` field has been added automatically by ODMantic.
This `id` field is actually not required since it's defined automatically by ODMantic
with a default factory method ([more details](fields.md#the-id-field)).
You can still specify this field in the request body to predefine the `id` of the
created instance or to overwrite an existing instance.
!!! question "Why `PUT` instead of `POST` ?"
Since the `engine.save` behave as an upsert operation ([more
details](engine.md#update)), you can overwrite instances stored in the database
by creating a new instance with the same id and calling the `engine.save` method.
??? example "Modifying the Tree from the command line"
To overwrite the tree with `id=5f8c8c1ff1d33aa1012f3086`:
=== "HTTPie"
Send the request:
```shell
http PUT localhost:8080/trees/ \
name="Norway Spruce" discovery_year=1795 \
average_size=200 id="5f8c8c1ff1d33aa1012f3086"
```
Output:
```hl_lines="11"
HTTP/1.1 200 OK
content-length: 90
content-type: application/json
date: Sun, 18 Oct 2020 18:40:30 GMT
server: uvicorn
{
"average_size": 200.0,
"discovery_year": 1795,
"id": "5f8c8c1ff1d33aa1012f3086",
"name": "Norway Spruce"
}
```
=== "curl"
Send the request:
```shell
curl -X PUT "http://localhost:8080/trees/" \
-H "Content-Type: application/json" \
-d '{"name":"Norway Spruce", "discovery_year":1795,
"average_size":200, "id":"5f8c8c1ff1d33aa1012f3086"}'
```
Output:
```
{"name":"Norway Spruce","average_size":200.0,"discovery_year":1795,"id":"5f8c8c1ff1d33aa1012f3086"}
```
Since we can modify an existing instance, it makes more sense to define the
operation as a `PUT` instead of a `POST` that should create a new resource on each
call.
If the request body doesn't match our model schema, a `422
Unprocessable Entity` error will be returned by the API, containing the details about
the error.
!!! example "Invalid data while creating the Tree from the command line"
You can try by omitting the `average_size` field:
=== "HTTPie"
Send the request:
```shell
http PUT localhost:8080/trees/ name="Spruce" discovery_year=1995
```
Output:
```hl_lines="1 12 14"
HTTP/1.1 422 Unprocessable Entity
content-length: 96
content-type: application/json
date: Sun, 18 Oct 2020 16:42:18 GMT
server: uvicorn
{
"detail": [
{
"loc": [
"body",
"average_size"
],
"msg": "field required",
"type": "value_error.missing"
}
]
}
```
=== "curl"
Send the request:
```shell
curl -v -X PUT "http://localhost:8080/trees/" \
-H "Content-Type: application/json" \
-d '{"name":"Spruce", "discovery_year":1995}'
```
Output:
```hl_lines="12 19"
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> PUT /trees/ HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.58.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 40
>
* upload completely sent off: 40 out of 40 bytes
< HTTP/1.1 422 Unprocessable Entity
< date: Sun, 18 Oct 2020 18:51:33 GMT
< server: uvicorn
< content-length: 96
< content-type: application/json
<
* Connection #0 to host localhost left intact
{"detail":[{"loc":["body","average_size"],"msg":"field required","type":"value_error.missing"}]}%
```
The validation error structure is the one that is defined by the [Pydantic:
ValidationError](https://docs.pydantic.dev/latest/usage/models/#error-handling){:target=blank_}
exception.
Finally, specifying the `response_model` in the `app.put` decorator is not
mandatory but it is strongly advised as it helps FastAPI to generate the documentation.
### Getting all the trees
To get the trees stored in the database, we use the
[AIOEngine.find][odmantic.engine.AIOEngine.find] method in its `awaitable` form ([more
details](engine.md#fetch-multiple-instances)), this gives us directly the list of Tree
instances that we can return directly:
```python
@app.get("/trees/", response_model=List[Tree])
async def get_trees():
trees = await engine.find(Tree)
return trees
```
!!! example "Creating and getting the trees from the command line"
=== "HTTPie"
Create some trees:
```shell
http PUT localhost:8080/trees/ name="Spruce" discovery_year=1995 average_size=10.2
http PUT localhost:8080/trees/ name="Pine" discovery_year=1850 average_size=5
```
Get the trees:
```shell
http localhost:8080/trees/
```
Output:
```
HTTP/1.1 200 OK
content-length: 270
content-type: application/json
date: Sun, 18 Oct 2020 17:59:10 GMT
server: uvicorn
[
{
"average_size": 10.2,
"discovery_year": 1995,
"id": "5f8c8266f1d33aa1012f3082",
"name": "Spruce"
},
{
"average_size": 5.0,
"discovery_year": 1850,
"id": "5f8c8266f1d33aa1012f3083",
"name": "Pine"
}
]
```
=== "curl"
Create some trees:
```shell
curl -v -X PUT "http://localhost:8080/trees/" \
-H "Content-Type: application/json" \
-d '{"name":"Spruce", "discovery_year":1995, "average_size":10.2}'
curl -v -X PUT "http://localhost:8080/trees/" \
-H "Content-Type: application/json" \
-d '{"name":"Pine", "discovery_year":1850, "average_size":5}'
```
Get the trees:
```shell
curl http://localhost:8080/trees/ | python -mjson.tool
```
Output:
```
[
{
"name": "Spruce",
"average_size": 10.2,
"discovery_year": 1995,
"id": "5f8c8266f1d33aa1012f3082"
},
{
"name": "Pine",
"average_size": 5.0,
"discovery_year": 1850,
"id": "5f8c8266f1d33aa1012f3083"
}
]
```
!!! tip "Pagination"
You can add pagination to this `GET` request by using the `skip` and `limit`
arguments while calling the [AIOEngine.find][odmantic.engine.AIOEngine.find] method.
### Counting the trees
To get the number of trees stored in the database, we use the
[AIOEngine.count][odmantic.engine.AIOEngine.count] method without specifying any query
parameters (to directly get the total count of instances).
```python
@app.get("/trees/count", response_model=int)
async def count_trees():
count = await engine.count(Tree)
return count
```
!!! example "Getting the tree count from the command line"
=== "HTTPie"
Get the count:
```shell
http localhost:8080/trees/count
```
Output:
```hl_lines="7"
HTTP/1.1 200 OK
content-length: 1
content-type: application/json
date: Sun, 18 Oct 2020 20:16:50 GMT
server: uvicorn
2
```
=== "curl"
Get the count:
```shell
curl http://localhost:8080/trees/count
```
Output:
```
2
```
### Getting a tree by `id`
```python
@app.get("/trees/{id}", response_model=Tree)
async def get_tree_by_id(id: ObjectId, ):
tree = await engine.find_one(Tree, Tree.id == id)
if tree is None:
raise HTTPException(404)
return tree
```
To return a tree from its `id` we add a path parameter `id: ObjectId`. Once this
endpoint is called, FastAPI will try to validate this query parameter, thus inferring an
`ObjectId` object.
!!! warning "Using BSON objects as parameters"
While you can define ODMantic models directly using `bson` fields ([more
details](fields.md#bson-types-integration)), it's not possible to use those types
directly with FastAPI, you'll need to get the equivalent objects from the
[odmantic.bson](api_reference/bson.md) module. Those equivalent types implement the
additional validation logic enabling FastAPI to work with them.
```python
from odmantic.bson import ObjectId
```
For convenience reasons, the `ObjectId` type including the validation logic is as
well available directly from the `odmantic` module.
```python
from odmantic import ObjectId
```
With this `ObjectId`, we build a query that will filter only the instances having this
exactly same `id`:
```python
Tree.id == id
```
Then, we pass this query to the [AIOEngine.find_one][odmantic.engine.AIOEngine.find_one] method that will try to return an instance, otherwise `None` will be returned:
```python
tree = await engine.find_one(Tree, Tree.id == id)
```
Now, if our tree object is None (i.e the instance has not been found), we need to return
a `404 Not Found` error:
```python
if tree is None:
raise HTTPException(404)
```
Otherwise, we found the requested instance. We can return it !
```python
return tree
```
!!! example "Getting a tree from the command line"
=== "HTTPie"
Get the tree `5f8c8266f1d33aa1012f3082`:
```shell
http localhost:8080/trees/5f8c8266f1d33aa1012f3082
```
Output:
```hl_lines="10"
HTTP/1.1 200 OK
content-length: 91
content-type: application/json
date: Sun, 18 Oct 2020 21:08:07 GMT
server: uvicorn
{
"average_size": 10.2,
"discovery_year": 1995,
"id": "5f8c8266f1d33aa1012f3082",
"name": "Spruce"
}
```
=== "curl"
Get the tree `5f8c8266f1d33aa1012f3082`:
```shell
curl http://localhost:8080/trees/5f8c8266f1d33aa1012f3082
```
Output:
```
{"name":"Spruce","average_size":10.2,"discovery_year":1995,"id":"5f8c8266f1d33aa1012f3082"}
```
!!! example "Trying to get a tree not in the database from the command line"
=== "HTTPie"
Try to get the tree `f0f0f0f0f0f0f0f0f0f0f0f0` (it has not been created):
```shell
http localhost:8080/trees/f0f0f0f0f0f0f0f0f0f0f0f0
```
Output:
```hl_lines="1 8"
HTTP/1.1 404 Not Found
content-length: 22
content-type: application/json
date: Sun, 18 Oct 2020 21:11:48 GMT
server: uvicorn
{
"detail": "Not Found"
}
```
=== "curl"
Try to get the tree `f0f0f0f0f0f0f0f0f0f0f0f0` (it has not been created):
```shell
curl http://localhost:8080/trees/f0f0f0f0f0f0f0f0f0f0f0f0
```
Output:
```
{"detail":"Not Found"}
```
This `id` path parameter should be a 16 characters hexadecimal string (see [MongoDB:
ObjectId](https://docs.mongodb.com/manual/reference/method/ObjectId/){:target=blank_}
for more details). If the `id` specified in the path does not match this criteria, a `422
Unprocessable Entity` error will be returned:
!!! example "Trying to get a tree with an invalid `id` from the command line"
=== "HTTPie"
Get the tree identified by `invalid_object_id`:
```shell
http localhost:8080/trees/invalid_object_id
```
Output:
```hl_lines="1 11-12 14"
HTTP/1.1 422 Unprocessable Entity
content-length: 89
content-type: application/json
date: Sun, 18 Oct 2020 20:50:25 GMT
server: uvicorn
{
"detail": [
{
"loc": [
"path",
"id"
],
"msg": "invalid ObjectId specified",
"type": "type_error"
}
]
}
```
=== "curl"
Get the tree identified by `invalid_object_id`:
```shell
curl http://localhost:8080/trees/invalid_object_id
```
Output:
```
{"detail":[{"loc":["path","id"],"msg":"invalid ObjectId specified","type":"type_error"}]}
```
## Extending the example
### Deleting a tree
```python linenums="1" hl_lines="26-32"
--8<-- "usage_fastapi/example_delete.py"
```
This new `DELETE` route is strongly inspired from the one used to [get a tree from its
`id`](#getting-a-tree-by-id).
Currently, ODMantic can only delete an instance and it's not possible to perform a
delete operation from a query filter. Thus, we first need to get the associated
instance. Once we have the instance, we call the
[AIOEngine.delete][odmantic.engine.AIOEngine.delete] method to perform the deletion.
!!! example "Deleting a tree from the command line"
=== "HTTPie"
Delete the tree identified by `5f8c8266f1d33aa1012f3082`:
```shell
http DELETE localhost:8080/trees/5f8c8266f1d33aa1012f3082
```
Output:
```hl_lines="1"
HTTP/1.1 200 OK
content-length: 91
content-type: application/json
date: Sun, 18 Oct 2020 21:35:22 GMT
server: uvicorn
{
"average_size": 10.2,
"discovery_year": 1995,
"id": "5f8c8266f1d33aa1012f3082",
"name": "Spruce"
}
```
Check that the tree is not stored anymore:
```shell
http localhost:8080/trees/5f8c8266f1d33aa1012f3082
```
Output:
```hl_lines="1 8"
HTTP/1.1 404 Not Found
content-length: 22
content-type: application/json
date: Sun, 18 Oct 2020 21:36:45 GMT
server: uvicorn
{
"detail": "Not Found"
}
```
=== "curl"
Delete the tree identified by `5f8c8266f1d33aa1012f3082`:
```shell
curl -X DELETE http://localhost:8080/trees/5f8c8266f1d33aa1012f3082
```
Output:
```
{"name":"Spruce","average_size":10.2,"discovery_year":1995,"id":"5f8c8266f1d33aa1012f3082"}
```
Check that the tree is not stored anymore:
```shell
curl http://localhost:8080/trees/5f8c8266f1d33aa1012f3082
```
Output:
```
{"detail":"Not Found"}
```
The tree has been removed successfully !
### Updating a tree
We already defined a `PUT` route that enables us to modify (replace) a tree instance.
However, with this previous implementation, it's not possible to specify only the
fields that we want to change as the whole Tree instance is rebuilt from the request's
body.
In this example, we will define a `PATCH` method that will allow us to modify only some
specific fields of a Tree instance:
```python linenums="1" hl_lines="26-29 32-39"
--8<-- "usage_fastapi/example_update.py"
```
First, we define the `TreePatchSchema` this Pydantic model will contain the
modifications that we need to apply on the instance. Since we want to be able to update
each field independently, we give each of them the `None` default value.
Then, we configure a new `PATCH` endpoint by setting the `id` of the model to update
as a path parameter and the `TreePatchSchema` as the request body parameter.
After all the parameters have been validated properly and the appropriate instance have
been gathered, we can apply the modifications to the local instance using the
[Model.model_update][odmantic.model._BaseODMModel.model_update] method. By default, the update
method will replace each field values in the instance with the ones explicitely set in
the patch object. Thus, the fields containing the None default values are not gonna be
changed in the instance.
We can then finish by saving and returning the updated tree.
??? tip "Optional, defaults, non-required and required pydantic fields (advanced)"
```python
from pydantic import BaseModel
class M(BaseModel):
a: Optional[int]
b: Optional[int] = None
c: int = None
d: int
```
In this example, fields have a different behavior:
`#!python a: Optional[int]`
: this field is **not required**, `None` is its default value, it can be given
`None` or any `int` values
`#!python b: Optional[int] = None`
: same behavior as `a` since `None` is set automatically as the default value for
`typing.Optional` fields
`#!python c: int = None`
: this field is **not required**, if not explicitely provided it will take the
`None` value, **only** an `int` can be given as an explicit value
`#!python d: int`
: this field is **required** and an `int` value **must** be provided
(More details: [pydantic #1223](https://github.com/samuelcolvin/pydantic/issues/1223#issuecomment-594632324){:target=blank_},
[pydantic: Required fields](https://docs.pydantic.dev/latest/usage/models/#required-fields){:target=blank_})
By default [Model.model_update][odmantic.model._BaseODMModel.model_update], will not apply
values from unset (not explicitely populated) fields. Since we don't want to allow
explicitely set `None` values in the example, we used fields defined as
`#!python c: int = None`.
!!! example "Updating a tree from the command line"
=== "HTTPie"
Update the tree identified by `5f8c8266f1d33aa1012f3083`:
```shell
http PATCH localhost:8080/trees/5f8c8266f1d33aa1012f3083 \
discovery_year=1825 name="Stone Pine"
```
Output:
```hl_lines="1"
HTTP/1.1 200 OK
content-length: 94
content-type: application/json
date: Sun, 18 Oct 2020 22:02:44 GMT
server: uvicorn
{
"average_size": 5.0,
"discovery_year": 1825,
"id": "5f8c8266f1d33aa1012f3083",
"name": "Stone Pine"
}
```
Check that the tree has been updated properly:
```shell
http localhost:8080/trees/5f8c8266f1d33aa1012f3083
```
Output:
```hl_lines="9 11"
HTTP/1.1 200 OK
content-length: 94
content-type: application/json
date: Sun, 18 Oct 2020 22:06:52 GMT
server: uvicorn
{
"average_size": 5.0,
"discovery_year": 1825,
"id": "5f8c8266f1d33aa1012f3083",
"name": "Stone Pine"
}
```
=== "curl"
Update the tree identified by `5f8c8266f1d33aa1012f3083`:
```shell
curl -X PATCH "http://localhost:8080/trees/5f8c8266f1d33aa1012f3083" \
-H "Content-Type: application/json" \
-d '{"name":"Stone Pine", "discovery_year":1825}'
```
Output:
```
{"name":"Stone Pine","average_size":5.0,"discovery_year":1825,"id":"5f8c8266f1d33aa1012f3083"}
```
Check that the tree has been updated properly:
```shell
curl http://localhost:8080/trees/5f8c8266f1d33aa1012f3083
```
Output:
```
{"name":"Stone Pine","average_size":5.0,"discovery_year":1825,"id":"5f8c8266f1d33aa1012f3083"}
```
The tree has been updated successfully !
## Upcoming features
A lot of feature could still improve the ODMantic + FastAPI experience.
Some ideas that should arrive soon:
- Add a `not_found_exception` argument to the AIOEngine.find_one method. Thus, if the
document is not found an exception will be raised directly.
- Implement the equivalent of MongoDB insert method to be able to create document
without overwriting existing ones.
- Implement a Model.model_update method to update the model fields from a dictionnary or from
a Pydantic schema.
- Automatically generate CRUD endpoints directly from an ODMantic Model.
python-odmantic-1.0.2/docs/usage_pydantic.md 0000664 0000000 0000000 00000002527 14613034133 0021104 0 ustar 00root root 0000000 0000000 # Usage with Pydantic
## Defining models with BSON Fields
You might need to define pure Pydantic models which include `BSON` fields. To that end,
you can use the [BaseBSONModel][odmantic.bson.BaseBSONModel] as the base class of your
Pydantic models. This class adds the JSON encoders required to handle the `BSON` fields.
Also, you will have to use the `bson` equivalent types defined in the
[odmantic.bson](api_reference/bson.md) module. Those types, add a validation logic to
the native types from the `bson` module.
!!! note "Custom `json_encoders` with `BaseBSONModel`"
If you want to specify additional json encoders, with a Pydantic model containing
`BSON` fields, you will need to pass as well the ODMantic encoders
([BSON_TYPES_ENCODERS][odmantic.bson.BSON_TYPES_ENCODERS]).
??? example "Custom encoders example"
```python linenums="1" hl_lines="11-14 18"
--8<-- "usage_pydantic/custom_encoders.py"
```
An issue that would simplify this behavior has been opened: [pydantic#2024](https://github.com/samuelcolvin/pydantic/issues/2024){:target=blank_}
## Accessing the underlying pydantic model
Each ODMantic Model contain a pure version of the pydantic model used to build the
ODMantic Model. This Pydantic model can be accessed in the `__pydantic_model__` class
attribute of the ODMantic Model/EmbeddedModel.
python-odmantic-1.0.2/mkdocs.yml 0000664 0000000 0000000 00000005607 14613034133 0016640 0 ustar 00root root 0000000 0000000 site_name: ODMantic
site_description: AsyncIO MongoDB ODM (Object Document Mapper) using python type hinting
repo_name: art049/odmantic
repo_url: https://github.com/art049/odmantic
site_url: https://art049.github.io/odmantic/
docs_dir: ./docs
site_dir: ./site
theme:
name: material
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
primary: green
accent: green
toggle:
icon: material/weather-night
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: green
accent: green
toggle:
icon: material/weather-sunny
name: Switch to light mode
icon:
logo: material/spa
repo: fontawesome/brands/github
features:
- instant
- content.tabs.link
extra_css:
- css/extra.css
extra_javascript:
- js/gitter.js
- https://sidecar.gitter.im/dist/sidecar.v1.js
extra_templates:
- sitemap.xml
markdown_extensions:
- admonition
- attr_list
- def_list
- toc:
permalink: true
toc_depth: 4
- codehilite:
linenums: true
guess_lang: false
- pymdownx.snippets:
base_path: docs/examples_src
check_paths: true # Fail when the document to include is not found
- pymdownx.tabbed:
alternate_style: true
- pymdownx.superfences
- pymdownx.details
- pymdownx.inlinehilite
- pymdownx.magiclink:
user: art049
repo: odmantic
repo_url_shorthand: true
- pymdownx.emoji:
emoji_index: !!python/name:pymdownx.emoji.twemoji
plugins:
- search
- macros:
module_name: docs/main
- mkdocstrings:
custom_templates: docs/api_reference/templates
default_handler: python
handlers:
python:
rendering:
show_source: true
show_root_heading: true
watch:
- odmantic/
- docs/
nav:
- Getting Started: index.md
- fields.md
- modeling.md
- engine.md
- querying.md
- raw_query_usage.md
- usage_fastapi.md
- usage_pydantic.md
- API Reference:
- odmantic.model: ./api_reference/model.md
- odmantic.engine: ./api_reference/engine.md
- odmantic.session: ./api_reference/session.md
- odmantic.query: ./api_reference/query.md
- odmantic.field: ./api_reference/field.md
- odmantic.reference: ./api_reference/reference.md
- odmantic.index: ./api_reference/index.md
- odmantic.bson: ./api_reference/bson.md
- odmantic.exceptions: ./api_reference/exceptions.md
- odmantic.config: ./api_reference/config.md
- contributing.md
- changelog.md
- migration_guide.md
extra:
social:
- icon: fontawesome/brands/github
link: https://github.com/art049
- icon: fontawesome/brands/twitter
link: https://twitter.com/art049
- icon: fontawesome/brands/linkedin
link: https://www.linkedin.com/in/arthur-pastel-a08579112
analytics:
provider: google
property: UA-180814888-1
python-odmantic-1.0.2/mypy.ini 0000664 0000000 0000000 00000001515 14613034133 0016326 0 ustar 00root root 0000000 0000000 [mypy]
python_version=3.9
pretty=True
follow_imports=normal
ignore_missing_imports=True
namespace_packages=True
warn_redundant_casts=True
warn_unused_ignores=True
warn_unreachable=True
# Strict
disallow_untyped_calls=True
disallow_untyped_defs=True
disallow_incomplete_defs=True
check_untyped_defs=True
disallow_untyped_decorators=True
no_implicit_optional=True
strict_optional=True
warn_no_return=True
warn_return_any=True
disable_error_code = unreachable
[mypy-tests.*]
disallow_untyped_calls=False
disallow_untyped_defs=False
disallow_incomplete_defs=False
disallow_untyped_decorators=False
disable_error_code = arg-type, call-arg, typeddict-unknown-key
[mypy-docs.*]
disallow_untyped_calls=False
disallow_untyped_defs=False
disallow_incomplete_defs=False
no_implicit_optional=False
strict_optional=False
disallow_untyped_decorators=False
python-odmantic-1.0.2/odmantic/ 0000775 0000000 0000000 00000000000 14613034133 0016423 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/odmantic/__init__.py 0000664 0000000 0000000 00000000715 14613034133 0020537 0 ustar 00root root 0000000 0000000 import importlib.metadata
from .bson import ObjectId, WithBsonSerializer
from .engine import AIOEngine, SyncEngine
from .field import Field
from .index import Index
from .model import EmbeddedModel, Model
from .reference import Reference
__all__ = [
"AIOEngine",
"Model",
"EmbeddedModel",
"Field",
"Reference",
"Index",
"ObjectId",
"SyncEngine",
"WithBsonSerializer",
]
__version__ = importlib.metadata.version(__name__)
python-odmantic-1.0.2/odmantic/bson.py 0000664 0000000 0000000 00000033401 14613034133 0017737 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import decimal
import re
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Callable, Dict, Pattern, Sequence, Tuple, Type, Union
import bson
import bson.binary
import bson.decimal128
import bson.errors
import bson.int64
import bson.regex
from pydantic import GetJsonSchemaHandler
from pydantic.json_schema import JsonSchemaValue
from pydantic.main import BaseModel
from pydantic_core import core_schema
from odmantic.typing import Annotated, get_args, get_origin
@dataclass(frozen=True)
class WithBsonSerializer:
"""Adds a BSON serializer to use on a field when it will be saved to the database"""
bson_serializer: Callable[[Any], Any]
def _get_bson_serializer(type_: Type[Any]) -> Callable[[Any], Any] | None:
origin = get_origin(type_)
if origin is not None and origin == Annotated:
args = get_args(type_)
for arg in args:
if isinstance(arg, WithBsonSerializer):
return arg.bson_serializer
return None
class ObjectId(bson.ObjectId):
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: Any,
_handler: Callable[[Any], core_schema.CoreSchema],
) -> core_schema.CoreSchema:
def validate_from_string_or_bytes(value: Union[str, bytes]) -> bson.ObjectId:
try:
return bson.ObjectId(value)
except bson.errors.InvalidId:
raise ValueError("Invalid ObjectId")
from_string_or_bytes_schema = core_schema.chain_schema(
[
core_schema.union_schema(
[
core_schema.str_schema(),
core_schema.bytes_schema(),
]
),
core_schema.no_info_plain_validator_function(
validate_from_string_or_bytes
),
]
)
return core_schema.json_or_python_schema(
json_schema=from_string_or_bytes_schema,
python_schema=core_schema.union_schema(
[
core_schema.is_instance_schema(bson.ObjectId),
from_string_or_bytes_schema,
],
),
serialization=core_schema.plain_serializer_function_ser_schema(
str, when_used="json"
),
)
@classmethod
def __get_pydantic_json_schema__(
cls, _schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> JsonSchemaValue:
json_schema = handler(core_schema.str_schema())
json_schema.update(
examples=["5f85f36d6dfecacc68428a46", "ffffffffffffffffffffffff"],
example="5f85f36d6dfecacc68428a46",
)
return json_schema
class Int64(bson.Int64):
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: Any,
_handler: Callable[[Any], core_schema.CoreSchema],
) -> core_schema.CoreSchema:
def validate_from_int(value: int) -> bson.int64.Int64:
return bson.int64.Int64(value)
from_int_schema = core_schema.chain_schema(
[
core_schema.int_schema(),
core_schema.no_info_plain_validator_function(validate_from_int),
]
)
return core_schema.json_or_python_schema(
json_schema=from_int_schema,
python_schema=core_schema.union_schema(
[
core_schema.is_instance_schema(bson.int64.Int64),
from_int_schema,
]
),
)
@classmethod
def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> JsonSchemaValue:
# Use the same schema that would be used for `int`
return handler(core_schema.int_schema())
Long = Int64
class Decimal128(bson.decimal128.Decimal128):
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: Any,
_handler: Callable[[Any], core_schema.CoreSchema],
) -> core_schema.CoreSchema:
def validate_from_decimal_repr(
value: Union[decimal.Decimal, float, str, Tuple[int, Sequence[int], int]],
) -> bson.decimal128.Decimal128:
try:
return bson.decimal128.Decimal128(value)
except Exception:
raise ValueError("Invalid Decimal128 value")
from_decimal_repr_schema = core_schema.no_info_plain_validator_function(
validate_from_decimal_repr
)
return core_schema.json_or_python_schema(
json_schema=from_decimal_repr_schema,
python_schema=core_schema.union_schema(
[
core_schema.is_instance_schema(bson.decimal128.Decimal128),
from_decimal_repr_schema,
]
),
serialization=core_schema.plain_serializer_function_ser_schema(
lambda v: v.to_decimal(), when_used="json"
),
)
@classmethod
def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> JsonSchemaValue:
return handler(core_schema.float_schema())
class Binary(bson.binary.Binary):
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: Any,
_handler: Callable[[Any], core_schema.CoreSchema],
) -> core_schema.CoreSchema:
def validate_from_bytes(
value: bytes,
) -> bson.binary.Binary:
return bson.binary.Binary(value)
from_bytes_schema = core_schema.chain_schema(
[
core_schema.bytes_schema(),
core_schema.no_info_plain_validator_function(validate_from_bytes),
]
)
return core_schema.json_or_python_schema(
json_schema=from_bytes_schema,
python_schema=core_schema.union_schema(
[
core_schema.is_instance_schema(bson.binary.Binary),
from_bytes_schema,
]
),
)
@classmethod
def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> JsonSchemaValue:
return handler(core_schema.bytes_schema())
def validate_pattern_from_str(
value: str,
) -> Pattern:
try:
return re.compile(value)
except Exception:
raise ValueError("Invalid Pattern value")
def validate_regex_from_pattern(
value: Pattern,
) -> bson.regex.Regex:
try:
return bson.regex.Regex(value.pattern, flags=value.flags)
except Exception:
raise ValueError("Invalid Regex value")
def validate_pattern_from_regex(
value: bson.regex.Regex,
) -> Pattern:
try:
return re.compile(value.pattern, flags=value.flags)
except Exception:
raise ValueError("Invalid Pattern value")
class Regex(bson.regex.Regex):
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: Any,
_handler: Callable[[Any], core_schema.CoreSchema],
) -> core_schema.CoreSchema:
from_str_schema = core_schema.chain_schema(
[
core_schema.str_schema(),
core_schema.no_info_plain_validator_function(validate_pattern_from_str),
core_schema.no_info_plain_validator_function(
validate_regex_from_pattern
),
]
)
from_pattern_schema = core_schema.chain_schema(
[
core_schema.is_instance_schema(Pattern),
core_schema.no_info_plain_validator_function(
validate_regex_from_pattern
),
]
)
return core_schema.json_or_python_schema(
json_schema=from_str_schema,
python_schema=core_schema.union_schema(
[
core_schema.is_instance_schema(bson.regex.Regex),
from_pattern_schema,
from_str_schema,
]
),
serialization=core_schema.plain_serializer_function_ser_schema(
lambda v: v.pattern, when_used="json"
),
)
@classmethod
def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> JsonSchemaValue:
schema = handler(core_schema.str_schema())
schema.update(
examples=[r"^Foo"], example=r"^Foo", type="string", format="binary"
)
return schema
class __PatternPydanticAnnotation: # cannot subclass Pattern since it's final
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: Any,
_handler: Callable[[Any], core_schema.CoreSchema],
) -> core_schema.CoreSchema:
from_regex_schema = core_schema.chain_schema(
[
core_schema.is_instance_schema(bson.regex.Regex),
core_schema.no_info_plain_validator_function(
validate_pattern_from_regex
),
]
)
from_str_schema = core_schema.chain_schema(
[
core_schema.str_schema(),
core_schema.no_info_plain_validator_function(validate_pattern_from_str),
]
)
return core_schema.json_or_python_schema(
json_schema=from_str_schema,
python_schema=core_schema.union_schema(
[
core_schema.is_instance_schema(Pattern),
from_regex_schema,
from_str_schema,
]
),
)
_Pattern = Annotated[Pattern, __PatternPydanticAnnotation]
class _datetime(datetime):
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: Any,
_handler: Callable[[Any], core_schema.CoreSchema],
) -> core_schema.CoreSchema:
def validate_mongo_datetime(
d: datetime,
) -> datetime:
# MongoDB does not store timezone info
# https://docs.python.org/3/library/datetime.html#determining-if-an-object-is-aware-or-naive
if d.tzinfo is not None and d.tzinfo.utcoffset(d) != timedelta(0):
raise ValueError("datetime objects must be naive (no timezone info)")
# Truncate microseconds to milliseconds to comply with Mongo behavior
microsecs = d.microsecond - d.microsecond % 1000
return d.replace(microsecond=microsecs)
mongo_datetime_schema = core_schema.chain_schema(
[
core_schema.datetime_schema(),
core_schema.no_info_plain_validator_function(validate_mongo_datetime),
]
)
return core_schema.json_or_python_schema(
json_schema=mongo_datetime_schema,
python_schema=mongo_datetime_schema,
)
@classmethod
def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> JsonSchemaValue:
return handler(core_schema.datetime_schema())
class _decimalDecimalPydanticAnnotation:
"""This specific BSON substitution field helps to handle the support of standard
python Decimal objects
https://api.mongodb.com/python/current/faq.html?highlight=decimal#how-can-i-store-decimal-decimal-instances
"""
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: Any,
_handler: Callable[[Any], core_schema.CoreSchema],
) -> core_schema.CoreSchema:
def validate_from_decimal128(
value: bson.decimal128.Decimal128,
) -> decimal.Decimal:
return value.to_decimal()
decimal128_schema = core_schema.chain_schema(
[
core_schema.is_instance_schema(bson.decimal128.Decimal128),
core_schema.no_info_plain_validator_function(validate_from_decimal128),
]
)
def validate_from_str(
value: str,
) -> decimal.Decimal:
try:
return decimal.Decimal(value)
except decimal.InvalidOperation:
raise ValueError("Invalid decimal string")
str_schema = core_schema.chain_schema(
[
core_schema.str_schema(),
core_schema.no_info_plain_validator_function(validate_from_str),
]
)
return core_schema.json_or_python_schema(
json_schema=str_schema,
python_schema=core_schema.union_schema(
[
core_schema.is_instance_schema(decimal.Decimal),
decimal128_schema,
str_schema,
]
),
)
_decimalDecimal = Annotated[
decimal.Decimal,
_decimalDecimalPydanticAnnotation,
WithBsonSerializer(lambda v: bson.decimal128.Decimal128(v)),
]
BSON_TYPES_ENCODERS: Dict[Type, Callable] = {
bson.ObjectId: str,
bson.decimal128.Decimal128: lambda x: x.to_decimal(), # Convert to regular decimal
bson.regex.Regex: lambda x: x.pattern, # TODO: document no serialization of flags
}
class BaseBSONModel(BaseModel):
"""Equivalent of `pydantic.BaseModel` supporting BSON types serialization.
If you want to apply other custom JSON encoders, you'll need to use
[BSON_TYPES_ENCODERS][odmantic.bson.BSON_TYPES_ENCODERS] directly.
"""
model_config = {"json_encoders": BSON_TYPES_ENCODERS}
_BSON_SUBSTITUTED_FIELDS = {
bson.ObjectId: ObjectId,
bson.int64.Int64: Int64,
bson.decimal128.Decimal128: Decimal128,
bson.binary.Binary: Binary,
bson.regex.Regex: Regex,
Pattern: _Pattern,
decimal.Decimal: _decimalDecimal,
datetime: _datetime,
}
python-odmantic-1.0.2/odmantic/config.py 0000664 0000000 0000000 00000005073 14613034133 0020247 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, cast
import pymongo
from pydantic.config import ConfigDict
if TYPE_CHECKING:
import odmantic.index as ODMIndex
class ODMConfigDict(ConfigDict, total=False):
collection: str | None
"""Customize the collection name associated to the model"""
parse_doc_with_default_factories: bool
"""Wether to allow populating field values with default factories while parsing
documents from the database"""
indexes: Callable[[], Iterable[ODMIndex.Index | pymongo.IndexModel]] | None
"""Define additional indexes for the model"""
PYDANTIC_CONFIG_OPTIONS = set(ConfigDict.__annotations__.keys())
PYDANTIC_ALLOWED_CONFIG_OPTIONS = {
"title",
"json_schema_extra",
"str_strip_whitespace",
"arbitrary_types_allowed",
"extra",
"json_encoders",
}
PYDANTIC_FORBIDDEN_CONFIG_OPTIONS = (
PYDANTIC_CONFIG_OPTIONS - PYDANTIC_ALLOWED_CONFIG_OPTIONS
)
ODM_CONFIG_OPTIONS = set(ODMConfigDict.__annotations__.keys()) - PYDANTIC_CONFIG_OPTIONS
ODM_CONFIG_ALLOWED_CONFIG_OPTIONS = ODM_CONFIG_OPTIONS | PYDANTIC_ALLOWED_CONFIG_OPTIONS
ENFORCED_PYDANTIC_CONFIG = ConfigDict(validate_default=True, validate_assignment=True)
def validate_config(config: ODMConfigDict, cls_name: str) -> ODMConfigDict:
"""Validate the model configuration, enforcing some Pydantic options"""
out_config: Dict[str, Any] = {
"title": None,
"json_schema_extra": None,
"str_strip_whitespace": False,
"arbitrary_types_allowed": False,
"extra": None,
**ENFORCED_PYDANTIC_CONFIG,
"collection": None,
"parse_doc_with_default_factories": False,
"indexes": None,
}
for config_key, value in config.items():
if config_key in ENFORCED_PYDANTIC_CONFIG:
raise ValueError(
f"'{cls_name}': configuration attribute '{config_key}' is "
f"enforced to {ENFORCED_PYDANTIC_CONFIG.get(config_key,'unknown')} "
"by ODMantic and cannot be changed"
)
elif config_key in PYDANTIC_FORBIDDEN_CONFIG_OPTIONS:
raise ValueError(
f"'{cls_name}': configuration attribute '{config_key}'"
" from Pydantic is not supported"
)
elif config_key in ODM_CONFIG_ALLOWED_CONFIG_OPTIONS:
out_config[config_key] = value
else:
raise ValueError(
f"'{cls_name}': unknown configuration attribute '{config_key}'"
)
return cast(ODMConfigDict, out_config)
python-odmantic-1.0.2/odmantic/engine.py 0000664 0000000 0000000 00000111222 14613034133 0020241 0 ustar 00root root 0000000 0000000 from typing import (
Any,
AsyncGenerator,
AsyncIterable,
Awaitable,
Dict,
Generator,
Generic,
Iterable,
Iterator,
List,
Optional,
Sequence,
Tuple,
Type,
TypeVar,
Union,
cast,
)
import pymongo
from pymongo import MongoClient
from pymongo.client_session import ClientSession
from pymongo.collection import Collection
from pymongo.command_cursor import CommandCursor
from pymongo.database import Database
from odmantic.exceptions import DocumentNotFoundError, DuplicateKeyError
from odmantic.field import FieldProxy, ODMReference
from odmantic.index import ODMBaseIndex
from odmantic.model import Model
from odmantic.query import QueryExpression, SortExpression, and_
from odmantic.session import (
AIOSession,
AIOSessionBase,
AIOTransaction,
SyncSession,
SyncSessionBase,
SyncTransaction,
)
from odmantic.typing import lenient_issubclass
try:
import motor
from motor.motor_asyncio import (
AsyncIOMotorClient,
AsyncIOMotorClientSession,
AsyncIOMotorCollection,
AsyncIOMotorCursor,
AsyncIOMotorDatabase,
)
except ImportError: # pragma: no cover
motor = None
ModelType = TypeVar("ModelType", bound=Model)
SortExpressionType = Optional[Union[FieldProxy, Tuple[FieldProxy]]]
AIOSessionType = Union[AsyncIOMotorClientSession, AIOSession, AIOTransaction, None]
SyncSessionType = Union[ClientSession, SyncSession, SyncTransaction, None]
class BaseCursor(Generic[ModelType]):
"""This object has to be built from the [odmantic.engine.AIOEngine.find][] method.
An AIOCursor object support multiple async operations:
- **async for**: asynchronously iterate over the query results
- **await** : when awaited it will return a list of the fetched models
"""
def __init__(
self,
model: Type[ModelType],
cursor: Union["AsyncIOMotorCursor", "CommandCursor"],
):
self._model = model
self._cursor = cursor
self._results: Optional[List[ModelType]] = None
def _parse_document(self, raw_doc: Dict) -> ModelType:
instance = self._model.model_validate_doc(raw_doc)
object.__setattr__(instance, "__fields_modified__", set())
return instance
class AIOCursor(
BaseCursor[ModelType], AsyncIterable[ModelType], Awaitable[List[ModelType]]
):
"""This object has to be built from the [odmantic.engine.AIOEngine.find][] method.
An AIOCursor object support multiple async operations:
- **async for**: asynchronously iterate over the query results
- **await** : when awaited it will return a list of the fetched models
"""
_cursor: "AsyncIOMotorCursor"
def __init__(self, model: Type[ModelType], cursor: "AsyncIOMotorCursor"):
super().__init__(model=model, cursor=cursor)
def __await__(self) -> Generator[None, None, List[ModelType]]:
if self._results is not None:
return self._results
raw_docs = yield from self._cursor.to_list(length=None).__await__()
instances = []
for raw_doc in raw_docs:
instances.append(self._parse_document(raw_doc))
yield
self._results = instances
return instances
async def __aiter__(self) -> AsyncGenerator[ModelType, None]:
if self._results is not None:
for res in self._results:
yield res
return
results = []
async for raw_doc in self._cursor:
instance = self._parse_document(raw_doc)
results.append(instance)
yield instance
self._results = results
class SyncCursor(BaseCursor[ModelType], Iterable[ModelType]):
"""This object has to be built from the [odmantic.engine.SyncEngine.find][] method.
A SyncCursor object supports iterating over the query results using **`for`**.
To get a list of all the results you can wrap it with `list`, as in `list(cursor)`.
"""
_cursor: "CommandCursor"
def __init__(self, model: Type[ModelType], cursor: "CommandCursor"):
super().__init__(model=model, cursor=cursor)
def __iter__(self) -> Iterator[ModelType]:
if self._results is not None:
for res in self._results:
yield res
return
results = []
for raw_doc in self._cursor:
instance = self._parse_document(raw_doc)
results.append(instance)
yield instance
self._results = results
_FORBIDDEN_DATABASE_CHARACTERS = set(("/", "\\", ".", '"', "$"))
class BaseEngine:
"""The BaseEngine is the base class for the async and sync engines. It holds the
common functionality, like generating the MongoDB queries, that is then used by the
two engines.
"""
def __init__(
self,
client: Union["AsyncIOMotorClient", "MongoClient"],
database: str = "test",
):
# https://docs.mongodb.com/manual/reference/limits/#naming-restrictions
forbidden_characters = _FORBIDDEN_DATABASE_CHARACTERS.intersection(
set(database)
)
if len(forbidden_characters) > 0:
raise ValueError(
f"database name cannot contain: {' '.join(forbidden_characters)}"
)
self.client = client
self.database_name = database
self.database = client[self.database_name]
@staticmethod
def _build_query(*queries: Union[QueryExpression, Dict, bool]) -> QueryExpression:
if len(queries) == 0:
return QueryExpression()
for query in queries:
if isinstance(query, bool):
raise TypeError("cannot build query using booleans")
queries = cast(Tuple[Union[QueryExpression, Dict], ...], queries)
if len(queries) == 1:
return QueryExpression(queries[0])
return and_(*queries)
@staticmethod
def _cascade_find_pipeline(
model: Type[ModelType], doc_namespace: str = ""
) -> List[Dict]:
"""Recursively build the find pipeline for model."""
pipeline: List[Dict] = []
for ref_field_name in model.__references__:
odm_reference = cast(ODMReference, model.__odm_fields__[ref_field_name])
pipeline.extend(
[
{
"$lookup": {
"from": odm_reference.model.__collection__,
"let": {"foreign_id": f"${odm_reference.key_name}"},
"pipeline": [
{
"$match": {
"$expr": {"$eq": ["$_id", "$$foreign_id"]}
}
},
*BaseEngine._cascade_find_pipeline(
odm_reference.model,
doc_namespace=f"{doc_namespace}{ref_field_name}.",
),
],
"as": odm_reference.key_name,
# FIXME if ref field name is an existing key_name ?
}
},
{ # Preserves document with unbound references
"$unwind": {
"path": f"${odm_reference.key_name}",
"preserveNullAndEmptyArrays": True,
}
},
]
)
return pipeline
@staticmethod
def _build_sort_expression(
sort_field: Union[FieldProxy, SortExpression],
) -> SortExpression:
return (
SortExpression({+sort_field: 1})
if not isinstance(sort_field, SortExpression)
else sort_field
)
@classmethod
def _validate_sort_argument(cls, sort: Any) -> Optional[SortExpression]:
if sort is None:
return None
if isinstance(sort, tuple):
for sorted_field in sort:
if not isinstance(sorted_field, (FieldProxy, SortExpression)):
raise TypeError(
"sort elements have to be Model fields or asc, desc descriptors"
)
sort_expression: Dict = {}
for sort_field in sort:
sort_expression.update(cls._build_sort_expression(sort_field))
return SortExpression(sort_expression)
if not isinstance(sort, (FieldProxy, SortExpression)):
raise TypeError(
"sort has to be a Model field or "
"asc, desc descriptors or a tuple of these"
)
return cls._build_sort_expression(sort)
def _prepare_find_pipeline(
self,
model: Type[ModelType],
*queries: Union[
QueryExpression, Dict, bool
], # bool: allow using binary operators with mypy
sort: Optional[Any] = None,
skip: int = 0,
limit: Optional[int] = None,
) -> List[Dict[str, Any]]:
if not lenient_issubclass(model, Model):
raise TypeError("Can only call find with a Model class")
sort_expression = self._validate_sort_argument(sort)
if limit is not None and limit <= 0:
raise ValueError("limit has to be a strict positive value or None")
if skip < 0:
raise ValueError("skip has to be a positive integer")
query = BaseEngine._build_query(*queries)
pipeline: List[Dict[str, Any]] = [{"$match": query}]
if sort_expression is not None:
pipeline.append({"$sort": sort_expression})
if skip > 0:
pipeline.append({"$skip": skip})
if limit is not None and limit > 0:
pipeline.append({"$limit": limit})
pipeline.extend(BaseEngine._cascade_find_pipeline(model))
return pipeline
class AIOEngine(BaseEngine):
"""The AIOEngine object is responsible for handling database operations with MongoDB
in an asynchronous way using motor.
"""
client: "AsyncIOMotorClient"
database: "AsyncIOMotorDatabase"
def __init__(
self,
client: Union["AsyncIOMotorClient", None] = None,
database: str = "test",
):
"""Engine constructor.
Args:
client: instance of an AsyncIO motor client. If None, a default one
will be created
database: name of the database to use
"""
if not motor:
raise RuntimeError(
"motor is required to use AIOEngine, install it with:\n\n"
+ 'pip install "odmantic[motor]"'
)
if client is None:
client = AsyncIOMotorClient()
super().__init__(client=client, database=database)
def get_collection(self, model: Type[ModelType]) -> "AsyncIOMotorCollection":
"""Get the motor collection associated to a Model.
Args:
model: model class
Returns:
the AsyncIO motor collection object
"""
return self.database[model.__collection__]
@staticmethod
def _get_session(
session: Union[AIOSessionType, AIOSessionBase],
) -> Optional[AsyncIOMotorClientSession]:
if isinstance(session, (AIOSession, AIOTransaction)):
return session.get_driver_session()
assert not isinstance(session, AIOSessionBase) # Abstract class
return session
async def configure_database(
self,
models: Sequence[Type[ModelType]],
*,
update_existing_indexes: bool = False,
session: SyncSessionType = None,
) -> None:
"""Apply model constraints to the database.
Args:
models: list of models to initialize the database with
update_existing_indexes: conflicting indexes will be dropped before creation
session: an optional session to use for the operation
"""
driver_session = self._get_session(session)
for model in models:
collection = self.get_collection(model)
for index in model.__indexes__():
pymongo_index = (
index.get_pymongo_index()
if isinstance(index, ODMBaseIndex)
else index
)
try:
await collection.create_indexes(
[pymongo_index], session=driver_session
)
except pymongo.errors.OperationFailure as exc:
if update_existing_indexes and getattr(exc, "code", None) in (
85, # aka IndexOptionsConflict
86, # aka IndexKeySpecsConflict for MongoDB > 5
):
await collection.drop_index(
pymongo_index.document["name"], session=driver_session
)
await collection.create_indexes(
[pymongo_index], session=driver_session
)
else:
raise
def session(self) -> AIOSession:
"""Get a new session for the engine to allow ordering sequential operations.
Returns:
a new session object
Example usage:
```python
engine = AIOEngine(...)
async with engine.session() as session:
john = await session.find(User, User.name == "John")
john.name = "Doe"
await session.save(john)
```
"""
return AIOSession(self)
def transaction(self) -> AIOTransaction:
"""Get a new transaction for the engine to aggregate sequential operations.
Returns:
a new transaction object
Example usage:
```python
engine = AIOEngine(...)
async with engine.transaction() as transaction:
john = transaction.find(User, User.name == "John")
john.name = "Doe"
await transaction.save(john)
await transaction.commit()
```
Warning:
MongoDB transaction are only supported on replicated clusters: either
directly a replicaSet or a sharded cluster with replication enabled.
"""
return AIOTransaction(self)
def find(
self,
model: Type[ModelType],
*queries: Union[
QueryExpression, Dict, bool
], # bool: allow using binary operators with mypy
sort: Optional[Any] = None,
skip: int = 0,
limit: Optional[int] = None,
session: AIOSessionType = None,
) -> AIOCursor[ModelType]:
"""Search for Model instances matching the query filter provided
Args:
model: model to perform the operation on
*queries: query filter to apply
sort: sort expression
skip: number of document to skip
limit: maximum number of instance fetched
session: an optional session to use for the operation
Raises:
DocumentParsingError: unable to parse one of the resulting documents
Returns:
[odmantic.engine.AIOCursor][] of the query
"""
pipeline = self._prepare_find_pipeline(
model,
*queries,
sort=sort,
skip=skip,
limit=limit,
)
collection = self.get_collection(model)
motor_cursor = collection.aggregate(
pipeline, session=self._get_session(session)
)
return AIOCursor(model, motor_cursor)
async def find_one(
self,
model: Type[ModelType],
*queries: Union[
QueryExpression, Dict, bool
], # bool: allow using binary operators w/o plugin
sort: Optional[Any] = None,
session: AIOSessionType = None,
) -> Optional[ModelType]:
"""Search for a Model instance matching the query filter provided
Args:
model: model to perform the operation on
*queries: query filter to apply
sort: sort expression
session: an optional session to use for the operation
Raises:
DocumentParsingError: unable to parse the resulting document
Returns:
the fetched instance if found otherwise None
"""
if not lenient_issubclass(model, Model):
raise TypeError("Can only call find_one with a Model class")
results = await self.find(model, *queries, sort=sort, limit=1, session=session)
if len(results) == 0:
return None
return results[0]
async def _save(
self, instance: ModelType, session: "AsyncIOMotorClientSession"
) -> ModelType:
"""Perform an atomic save operation in the specified session"""
for ref_field_name in instance.__references__:
sub_instance = cast(Model, getattr(instance, ref_field_name))
await self._save(sub_instance, session)
fields_to_update = instance.__fields_modified__ | instance.__mutable_fields__
if len(fields_to_update) > 0:
doc = instance.model_dump_doc(include=fields_to_update)
collection = self.get_collection(type(instance))
try:
await collection.update_one(
instance.model_dump_doc(include={instance.__primary_field__}),
{"$set": doc},
upsert=True,
session=session,
)
except pymongo.errors.DuplicateKeyError as e:
raise DuplicateKeyError(instance, e)
object.__setattr__(instance, "__fields_modified__", set())
return instance
async def save(
self,
instance: ModelType,
*,
session: AIOSessionType = None,
) -> ModelType:
"""Persist an instance to the database
This method behaves as an 'upsert' operation. If a document already exists
with the same primary key, it will be overwritten.
All the other models referenced by this instance will be saved as well.
Args:
instance: instance to persist
session: An optional session to use for the operation. If not provided, an
internal session will be used to persist the instance and
sub-instances.
Returns:
the saved instance
Raises:
DuplicateKeyError: the instance is duplicated according to a unique index.
NOTE:
The save operation actually modify the instance argument in place. However,
the instance is still returned for convenience.
"""
if not isinstance(instance, Model):
raise TypeError("Can only call find_one with a Model class")
if session:
await self._save(instance, self._get_session(session))
else:
async with await self.client.start_session() as local_session:
await self._save(instance, local_session)
return instance
async def save_all(
self,
instances: Sequence[ModelType],
*,
session: AIOSessionType = None,
) -> List[ModelType]:
"""Persist instances to the database
This method behaves as multiple 'upsert' operations. If one of the document
already exists with the same primary key, it will be overwritten.
All the other models referenced by this instance will be recursively saved as
well.
Args:
instances: instances to persist
session: An optional session to use for the operation. If not provided, an
internal session will be used to persist the instances.
Returns:
the saved instances
Raises:
DuplicateKeyError: an instance is duplicated according to a unique index.
NOTE:
The save_all operation actually modify the arguments in place. However, the
instances are still returned for convenience.
"""
if session:
added_instances = [
await self._save(instance, self._get_session(session))
for instance in instances
]
else:
async with await self.client.start_session() as local_session:
added_instances = [
await self._save(instance, local_session) for instance in instances
]
return added_instances
async def delete(
self,
instance: ModelType,
*,
session: AIOSessionType = None,
) -> None:
"""Delete an instance from the database
Args:
instance: the instance to delete
session: an optional session to use for the operation
Raises:
DocumentNotFoundError: the instance has not been persisted to the database
"""
# TODO handle cascade deletion
collection = self.database[instance.__collection__]
pk_name = instance.__primary_field__
result = await collection.delete_many(
{"_id": getattr(instance, pk_name)}, session=self._get_session(session)
)
count = int(result.deleted_count)
if count == 0:
raise DocumentNotFoundError(instance)
async def remove(
self,
model: Type[ModelType],
*queries: Union[QueryExpression, Dict, bool],
just_one: bool = False,
session: AIOSessionType = None,
) -> int:
"""Delete Model instances matching the query filter provided
Args:
model: model to perform the operation on
*queries: query filter to apply
just_one: limit the deletion to just one document
session: an optional session to use for the operation
Returns:
the number of instances deleted from the database.
"""
query = AIOEngine._build_query(*queries)
collection = self.get_collection(model)
if just_one:
result = await collection.delete_one(
query, session=self._get_session(session)
)
else:
result = await collection.delete_many(
query, session=self._get_session(session)
)
return cast(int, result.deleted_count)
async def count(
self,
model: Type[ModelType],
*queries: Union[QueryExpression, Dict, bool],
session: AIOSessionType = None,
) -> int:
"""Get the count of documents matching a query
Args:
model: model to perform the operation on
*queries: query filters to apply
session: an optional session to use for the operation
Returns:
number of document matching the query
"""
if not lenient_issubclass(model, Model):
raise TypeError("Can only call count with a Model class")
query = BaseEngine._build_query(*queries)
collection = self.database[model.__collection__]
count = await collection.count_documents(
query, session=self._get_session(session)
)
return int(count)
class SyncEngine(BaseEngine):
"""The SyncEngine object is responsible for handling database operations with
MongoDB in an synchronous way using pymongo.
"""
client: "MongoClient"
database: "Database"
def __init__(
self,
client: "Union[MongoClient, None]" = None,
database: str = "test",
):
"""Engine constructor.
Args:
client: instance of a PyMongo client. If None, a default one
will be created
database: name of the database to use
"""
if client is None:
client = MongoClient()
super().__init__(client=client, database=database)
def get_collection(self, model: Type[ModelType]) -> "Collection":
"""Get the pymongo collection associated to a Model.
Args:
model: model class
Returns:
the pymongo collection object
"""
collection = self.database[model.__collection__]
return collection
@staticmethod
def _get_session(
session: Union[SyncSessionType, SyncSessionBase],
) -> Optional[ClientSession]:
if isinstance(session, (SyncSession, SyncTransaction)):
return session.get_driver_session()
assert not isinstance(session, SyncSessionBase) # Abstract class
return session
def configure_database(
self,
models: Sequence[Type[ModelType]],
*,
update_existing_indexes: bool = False,
session: SyncSessionType = None,
) -> None:
"""Apply model constraints to the database.
Args:
models: list of models to initialize the database with
update_existing_indexes: conflicting indexes will be dropped before creation
session: an optional session to use for the operation
"""
driver_session = self._get_session(session)
for model in models:
collection = self.get_collection(model)
for index in model.__indexes__():
pymongo_index = (
index.get_pymongo_index()
if isinstance(index, ODMBaseIndex)
else index
)
try:
collection.create_indexes([pymongo_index], session=driver_session)
except pymongo.errors.OperationFailure as exc:
if update_existing_indexes and getattr(exc, "code", None) in (
85, # aka IndexOptionsConflict
86, # aka IndexKeySpecsConflict for MongoDB > 5
):
collection.drop_index(
pymongo_index.document["name"], session=driver_session
)
collection.create_indexes(
[pymongo_index], session=driver_session
)
else:
raise
def session(self) -> SyncSession:
"""Get a new session for the engine to allow ordering sequential operations.
Returns:
a new session object
Example usage:
```python
engine = SyncEngine(...)
with engine.session() as session:
john = session.find(User, User.name == "John")
john.name = "Doe"
session.save(john)
```
"""
return SyncSession(self)
def transaction(self) -> SyncTransaction:
"""Get a new transaction for the engine to aggregate sequential operations.
Returns:
a new transaction object
Example usage:
```python
engine = SyncEngine(...)
with engine.transaction() as transaction:
john = transaction.find(User, User.name == "John")
john.name = "Doe"
transaction.save(john)
transaction.commit()
```
Warning:
MongoDB transaction are only supported on replicated clusters: either
directly a replicaSet or a sharded cluster with replication enabled.
"""
return SyncTransaction(self)
def find(
self,
model: Type[ModelType],
*queries: Union[
QueryExpression, Dict, bool
], # bool: allow using binary operators with mypy
sort: Optional[Any] = None,
skip: int = 0,
limit: Optional[int] = None,
session: SyncSessionType = None,
) -> SyncCursor[ModelType]:
"""Search for Model instances matching the query filter provided
Args:
model: model to perform the operation on
*queries: query filter to apply
sort: sort expression
skip: number of document to skip
limit: maximum number of instance fetched
session: an optional session to use for the operation
Raises:
DocumentParsingError: unable to parse one of the resulting documents
Returns:
[odmantic.engine.SyncCursor][] of the query
"""
pipeline = self._prepare_find_pipeline(
model,
*queries,
sort=sort,
skip=skip,
limit=limit,
)
collection = self.get_collection(model)
cursor = collection.aggregate(pipeline, session=self._get_session(session))
return SyncCursor(model, cursor)
def find_one(
self,
model: Type[ModelType],
*queries: Union[
QueryExpression, Dict, bool
], # bool: allow using binary operators w/o plugin
sort: Optional[Any] = None,
session: SyncSessionType = None,
) -> Optional[ModelType]:
"""Search for a Model instance matching the query filter provided
Args:
model: model to perform the operation on
*queries: query filter to apply
sort: sort expression
session: an optional session to use for the operation
Raises:
DocumentParsingError: unable to parse the resulting document
Returns:
the fetched instance if found otherwise None
"""
if not lenient_issubclass(model, Model):
raise TypeError("Can only call find_one with a Model class")
results = list(self.find(model, *queries, sort=sort, limit=1, session=session))
if len(results) == 0:
return None
return results[0]
def _save(self, instance: ModelType, session: "ClientSession") -> ModelType:
"""Perform an atomic save operation in the specified session"""
for ref_field_name in instance.__references__:
sub_instance = cast(Model, getattr(instance, ref_field_name))
self._save(sub_instance, session)
fields_to_update = instance.__fields_modified__ | instance.__mutable_fields__
if len(fields_to_update) > 0:
doc = instance.model_dump_doc(include=fields_to_update)
collection = self.get_collection(type(instance))
try:
collection.update_one(
instance.model_dump_doc(include={instance.__primary_field__}),
{"$set": doc},
upsert=True,
session=session,
)
except pymongo.errors.DuplicateKeyError as e:
raise DuplicateKeyError(instance, e)
object.__setattr__(instance, "__fields_modified__", set())
return instance
def save(
self,
instance: ModelType,
*,
session: SyncSessionType = None,
) -> ModelType:
"""Persist an instance to the database
This method behaves as an 'upsert' operation. If a document already exists
with the same primary key, it will be overwritten.
All the other models referenced by this instance will be saved as well.
Args:
instance: instance to persist
session: An optional session to use for the operation. If not provided, an
internal session will be used to persist the instance and
sub-instances.
Returns:
the saved instance
Raises:
DuplicateKeyError: the instance is duplicated according to a unique index.
NOTE:
The save operation actually modify the instance argument in place. However,
the instance is still returned for convenience.
"""
if not isinstance(instance, Model):
raise TypeError("Can only call find_one with a Model class")
if session:
self._save(instance, self._get_session(session)) # type: ignore
else:
with self.client.start_session() as local_session:
self._save(instance, local_session)
return instance
def save_all(
self,
instances: Sequence[ModelType],
*,
session: SyncSessionType = None,
) -> List[ModelType]:
"""Persist instances to the database
This method behaves as multiple 'upsert' operations. If one of the document
already exists with the same primary key, it will be overwritten.
All the other models referenced by this instance will be recursively saved as
well.
Args:
instances: instances to persist
session: An optional session to use for the operation. If not provided an
internal session will be used to persist the instances.
Returns:
the saved instances
Raises:
DuplicateKeyError: an instance is duplicated according to a unique index.
NOTE:
The save_all operation actually modify the arguments in place. However, the
instances are still returned for convenience.
"""
if session:
added_instances = [
self._save(instance, self._get_session(session)) # type: ignore
for instance in instances
]
else:
with self.client.start_session() as local_session:
added_instances = [
self._save(instance, local_session) for instance in instances
]
return added_instances
def delete(
self,
instance: ModelType,
session: SyncSessionType = None,
) -> None:
"""Delete an instance from the database
Args:
instance: the instance to delete
session: an optional session to use for the operation
Raises:
DocumentNotFoundError: the instance has not been persisted to the database
"""
# TODO handle cascade deletion
collection = self.database[instance.__collection__]
pk_name = instance.__primary_field__
result = collection.delete_many(
{"_id": getattr(instance, pk_name)}, session=self._get_session(session)
)
count = result.deleted_count
if count == 0:
raise DocumentNotFoundError(instance)
def remove(
self,
model: Type[ModelType],
*queries: Union[QueryExpression, Dict, bool],
just_one: bool = False,
session: SyncSessionType = None,
) -> int:
"""Delete Model instances matching the query filter provided
Args:
model: model to perform the operation on
*queries: query filter to apply
just_one: limit the deletion to just one document
session: an optional session to use for the operation
Returns:
the number of instances deleted from the database.
"""
query = SyncEngine._build_query(*queries)
collection = self.get_collection(model)
if just_one:
result = collection.delete_one(query, session=self._get_session(session))
else:
result = collection.delete_many(query, session=self._get_session(session))
return result.deleted_count
def count(
self,
model: Type[ModelType],
*queries: Union[QueryExpression, Dict, bool],
session: SyncSessionType = None,
) -> int:
"""Get the count of documents matching a query
Args:
model: model to perform the operation on
*queries: query filters to apply
session: an optional session to use for the operation
Returns:
number of document matching the query
"""
if not lenient_issubclass(model, Model):
raise TypeError("Can only call count with a Model class")
query = BaseEngine._build_query(*queries)
collection = self.database[model.__collection__]
count = collection.count_documents(query, session=self._get_session(session))
return int(count)
python-odmantic-1.0.2/odmantic/exceptions.py 0000664 0000000 0000000 00000006611 14613034133 0021162 0 ustar 00root root 0000000 0000000 from abc import ABCMeta
from typing import TYPE_CHECKING, Any, Dict, List, Type, TypeVar, Union
import pymongo
from pydantic import ValidationError
from pydantic_core import InitErrorDetails, PydanticCustomError
if TYPE_CHECKING:
from odmantic.model import Model, _BaseODMModel
ModelType = TypeVar("ModelType")
class BaseEngineException(Exception, metaclass=ABCMeta):
"""Base Exception raised by the engine while operating with the database."""
def __init__(self, message: str, model: Type["Model"]):
self.model: Type["Model"] = model
super().__init__(message)
class DocumentNotFoundError(BaseEngineException):
"""The targetted document has not been found by the engine.
Attributes:
instance: the instance that has not been found
"""
def __init__(self, instance: "Model"):
self.instance: "Model" = instance
super().__init__(
f"Document not found for : {type(instance).__name__}. "
f"Instance: {self.instance}",
type(instance),
)
class DuplicateKeyError(BaseEngineException):
"""The targetted document is duplicated according to a unique index.
Attributes:
instance: the instance that has not been found
driver_error: the original driver error
"""
def __init__(
self, instance: "Model", driver_error: pymongo.errors.DuplicateKeyError
):
self.instance: "Model" = instance
self.driver_error = driver_error
super().__init__(
f"Duplicate key error for: {type(instance).__name__}. "
f"Instance: {self.instance} "
f"Driver error: {self.driver_error}",
type(instance),
)
ErrorList = List[InitErrorDetails]
def ODManticCustomError(
error_type: str,
message_template: str,
context: Union[Dict[str, Any], None] = None,
) -> PydanticCustomError:
odm_error_type = f"odmantic::{error_type}"
return PydanticCustomError(odm_error_type, message_template, context)
def KeyNotFoundInDocumentError(key_name: str) -> PydanticCustomError:
return ODManticCustomError(
"key_not_found_in_document",
"Key '{key_name}' not found in document",
{"key_name": key_name},
)
def ReferencedDocumentNotFoundError(foreign_key_name: str) -> PydanticCustomError:
return ODManticCustomError(
"referenced_document_not_found",
"Referenced document not found for foreign key '{foreign_key_name}'",
{"foreign_key_name": foreign_key_name},
)
def IncorrectGenericEmbeddedModelValue(value: Any) -> PydanticCustomError:
return ODManticCustomError(
"incorrect_generic_embedded_model_value",
"Incorrect generic embedded model value '{value}'",
{"value": value},
)
class DocumentParsingError(ValueError):
"""Unable to parse the document into an instance.
Inherits from the `ValidationError` defined by Pydantic.
Attributes:
model (Union[Type[Model],Type[EmbeddedModel]]): model which could not be
instanciated
"""
def __init__(
self,
errors: ErrorList,
model: Type["_BaseODMModel"],
):
self.model = model
self.inner = ValidationError.from_exception_data(
title=self.model.__name__,
line_errors=errors,
)
def __str__(self) -> str:
return str(self.inner)
def __repr__(self) -> str:
return repr(self.inner)
python-odmantic-1.0.2/odmantic/field.py 0000664 0000000 0000000 00000032661 14613034133 0020070 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import abc
from typing import (
TYPE_CHECKING,
Any,
Callable,
Iterable,
Optional,
Pattern,
Set,
Type,
Union,
cast,
)
from pydantic.config import JsonDict
from pydantic.fields import Field as PDField
from pydantic.fields import FieldInfo, PydanticUndefined
from odmantic.config import ODMConfigDict
from odmantic.query import (
QueryExpression,
SortExpression,
asc,
desc,
eq,
gt,
gte,
in_,
lt,
lte,
match,
ne,
not_in,
)
if TYPE_CHECKING:
from odmantic.model import EmbeddedModel, Model # noqa: F401
from .typing import NoArgAnyCallable
def Field(
default: Any = PydanticUndefined,
*,
key_name: Optional[str] = None,
primary_field: bool = False,
index: bool = False,
unique: bool = False,
default_factory: Optional["NoArgAnyCallable"] = None,
# alias: str = None, # FIXME not supported yet
title: Optional[str] = None,
description: Optional[str] = None,
json_schema_extra: JsonDict | Callable[[JsonDict], None] | None = None,
const: Optional[bool] = None,
gt: Optional[float] = None,
ge: Optional[float] = None,
lt: Optional[float] = None,
le: Optional[float] = None,
multiple_of: Optional[float] = None,
min_items: Optional[int] = None,
max_items: Optional[int] = None,
min_length: Optional[int] = None,
max_length: Optional[int] = None,
regex: Optional[str] = None,
examples: list[Any] | None = None,
) -> Any:
"""Used to provide extra information about a field, either for the model schema or
complex validation. Some arguments apply only to number fields (``int``, ``float``,
``Decimal``) and some apply only to ``str``.
Tip:
The main additions of ODMantic to the regular pydantic `Field` are the
`key_name`, `index`, `unique` and the `primary_field` options.
Warning:
If both `default` and `default_factory` are set, an error is raised.
Warning:
`primary_field` can't be used along with `key_name` since the key_name will be
set to `_id`.
Args:
default: since this is replacing the field’s default, its first argument is
used to set the default, use ellipsis (``...``) to indicate the field has no
default value
key_name: the name to use in the the mongo document structure
primary_field: this field should be considered as a primary key.
index: this field should be considered as an index
unique: this field should be considered as a unique index
default_factory: callable that will be called when a default value is needed
for this field.
title: can be any string, used in the schema
description: can be any string, used in the schema
examples: can be any list, used in the schema
json_schema_extra: Any additional JSON schema data for the schema property.
const: this field is required and *must* take it's default value
gt: only applies to numbers, requires the field to be "greater than". The
schema will have an ``exclusiveMinimum`` validation keyword
ge: only applies to numbers, requires the field to be "greater than or equal
to". The schema will have a ``minimum`` validation keyword
lt: only applies to numbers, requires the field to be "less than". The schema
will have an ``exclusiveMaximum`` validation keyword
le: only applies to numbers, requires the field to be "less than or equal to"
. The schema will have a ``maximum`` validation keyword
multiple_of: only applies to numbers, requires the field to be "a multiple of
". The schema will have a ``multipleOf`` validation keyword
min_items: only applies to sequences, requires the field to have a minimum
item count.
max_items: only applies to sequences, requires the field to have a maximum
item count.
min_length: only applies to strings, requires the field to have a minimum
length. The schema will have a ``maximum`` validation keyword
max_length: only applies to strings, requires the field to have a maximum
length. The schema will have a ``maxLength`` validation keyword
regex: only applies to strings, requires the field match agains a regular
expression pattern string. The schema will have a ``pattern`` validation
keyword
"""
# Perform casts on optional fields to avoid incompatibility due to the strict
# optional mypy setting
# TODO: add remaining validation fields from pydantic
pydantic_field = PDField(
default,
default_factory=default_factory,
# alias=alias, # FIXME check aliases compatibility
title=cast(str, title),
description=cast(str, description),
examples=examples,
json_schema_extra=json_schema_extra,
const=cast(bool, const),
gt=cast(float, gt),
ge=cast(float, ge),
lt=cast(float, lt),
le=cast(float, le),
multiple_of=cast(float, multiple_of),
min_items=cast(int, min_items),
max_items=cast(int, max_items),
min_length=cast(int, min_length),
max_length=cast(int, max_length),
regex=cast(str, regex),
)
if primary_field:
if key_name is not None and key_name != "_id":
raise ValueError(
"cannot specify a primary field with a custom key_name,"
"key_name='_id' enforced"
)
else:
key_name = "_id"
elif key_name == "_id":
raise ValueError(
"cannot specify key_name='_id' without defining the field as primary"
)
return ODMFieldInfo(
pydantic_field_info=pydantic_field,
primary_field=primary_field,
key_name=key_name,
index=index,
unique=unique,
)
class ODMFieldInfo:
"""Extra data for an ODM field."""
__slots__ = ("pydantic_field_info", "primary_field", "key_name", "index", "unique")
def __init__(
self,
*,
pydantic_field_info: FieldInfo,
primary_field: bool,
key_name: Optional[str],
index: bool,
unique: bool,
):
self.pydantic_field_info = pydantic_field_info
self.primary_field = primary_field
self.key_name = key_name
self.index = index
self.unique = unique
class ODMBaseField(metaclass=abc.ABCMeta):
__slots__ = ("key_name", "model_config", "pydantic_field")
__allowed_operators__: Set[str]
def __init__(self, key_name: str, model_config: ODMConfigDict):
self.key_name = key_name
self.model_config = model_config
def bind_pydantic_field(self, field: FieldInfo) -> None:
self.pydantic_field = field
def is_required_in_doc(self) -> bool:
if self.model_config["parse_doc_with_default_factories"]:
return self.pydantic_field.is_required()
else:
return (
self.pydantic_field.default_factory is not None
or self.pydantic_field.is_required()
)
class ODMBaseIndexableField(ODMBaseField, metaclass=abc.ABCMeta):
__slots__ = ("index", "unique")
def __init__(
self,
key_name: str,
model_config: ODMConfigDict,
index: bool,
unique: bool,
):
super().__init__(key_name, model_config)
self.index = index
self.unique = unique
class ODMField(ODMBaseIndexableField):
"""Used to interact with the ODM model class."""
__slots__ = ("primary_field",)
__allowed_operators__ = set(
("eq", "ne", "in_", "not_in", "lt", "lte", "gt", "gte", "match", "asc", "desc")
)
def __init__(
self,
*,
key_name: str,
model_config: ODMConfigDict,
primary_field: bool,
index: bool = False,
unique: bool = False,
):
super().__init__(key_name, model_config, index, unique)
self.primary_field = primary_field
def get_default_importing_value(self) -> Any:
# The default importing value doesn't consider the default_factory setting by
# default as it could result in inconsistent behaviors for datetime.now
# factories for example
return self.pydantic_field.get_default(
call_default_factory=self.model_config["parse_doc_with_default_factories"]
)
class ODMReference(ODMBaseField):
"""Field pointing on a referenced model."""
__slots__ = ("model",)
__allowed_operators__ = set(("eq", "ne", "in_", "not_in"))
def __init__(
self, key_name: str, model_config: ODMConfigDict, model: Type["Model"]
):
super().__init__(key_name, model_config)
self.model = model
class ODMEmbedded(ODMField):
__slots__ = "model"
__allowed_operators__ = set(("eq", "ne", "in_", "not_in"))
def __init__(
self,
primary_field: bool,
key_name: str,
model_config: ODMConfigDict,
model: Type["EmbeddedModel"],
index: bool = False,
unique: bool = False,
):
super().__init__(
primary_field=primary_field,
key_name=key_name,
model_config=model_config,
index=index,
unique=unique,
)
self.model = model
class ODMEmbeddedGeneric(ODMField):
# Only dict,set and list are "officially" supported for now
__slots__ = ("model", "generic_origin")
__allowed_operators__ = set(("eq", "ne"))
def __init__(
self,
key_name: str,
model_config: ODMConfigDict,
model: Type["EmbeddedModel"],
generic_origin: Any,
index: bool = False,
unique: bool = False,
):
super().__init__(
primary_field=False,
key_name=key_name,
model_config=model_config,
index=index,
unique=unique,
)
self.model = model
self.generic_origin = generic_origin
class KeyNameProxy(str):
"""Used to provide the `++` operator enabling reference key name creation"""
def __pos__(self) -> str:
return f"${self}"
class FieldProxy:
__slots__ = ("parent", "field")
def __init__(self, parent: Optional["FieldProxy"], field: ODMBaseField) -> None:
self.parent = parent
self.field = field
def _get_key_name(self) -> str:
parent: Optional[FieldProxy] = object.__getattribute__(self, "parent")
field: ODMBaseField = object.__getattribute__(self, "field")
if parent is None:
return field.key_name
parent_name: str = object.__getattribute__(parent, "_get_key_name")()
return f"{parent_name}.{field.key_name}"
def __getattribute__(self, name: str) -> Any:
if name == "__class__": # support `isinstance` for python < 3.7
return super().__getattribute__(name)
field: ODMBaseField = object.__getattribute__(self, "field")
if isinstance(field, ODMReference):
if name in field.model.__odm_fields__:
raise NotImplementedError(
"filtering across references is not supported"
)
elif isinstance(field, ODMEmbedded):
child_field = field.model.__odm_fields__.get(name)
if child_field is None:
try:
return super().__getattribute__(name)
except AttributeError:
raise AttributeError(
f"attribute {name} not found in {field.model.__name__}"
)
return FieldProxy(parent=self, field=child_field)
if name not in field.__allowed_operators__:
raise AttributeError(
f"operator {name} not allowed for {type(field).__name__} fields"
)
return super().__getattribute__(name)
def __pos__(self) -> KeyNameProxy:
return KeyNameProxy(object.__getattribute__(self, "_get_key_name")())
def __gt__(self, value: Any) -> QueryExpression:
return self.gt(value)
def gt(self, value: Any) -> QueryExpression:
return gt(self, value)
def gte(self, value: Any) -> QueryExpression:
return gte(self, value)
def __ge__(self, value: Any) -> QueryExpression:
return self.gte(value)
def lt(self, value: Any) -> QueryExpression:
return lt(self, value)
def __lt__(self, value: Any) -> QueryExpression:
return self.lt(value)
def lte(self, value: Any) -> QueryExpression:
return lte(self, value)
def __le__(self, value: Any) -> QueryExpression:
return self.lte(value)
def eq(self, value: Any) -> QueryExpression:
return eq(self, value)
def __eq__(self, value: Any) -> QueryExpression: # type: ignore
return self.eq(value)
def ne(self, value: Any) -> QueryExpression:
return ne(self, value)
def __ne__(self, value: Any) -> QueryExpression: # type: ignore
return self.ne(value)
def in_(self, value: Iterable) -> QueryExpression:
return in_(self, value)
def not_in(self, value: Iterable) -> QueryExpression:
return not_in(self, value)
def match(self, pattern: Union[Pattern, str]) -> QueryExpression:
return match(self, pattern)
def asc(self) -> SortExpression:
return asc(self)
def desc(self) -> SortExpression:
return desc(self)
python-odmantic-1.0.2/odmantic/index.py 0000664 0000000 0000000 00000006311 14613034133 0020105 0 ustar 00root root 0000000 0000000 from abc import ABCMeta, abstractmethod
from typing import Any, Dict, Optional, Sequence, Tuple, Union, cast
import pymongo
from odmantic.field import FieldProxy
from odmantic.query import FieldProxyAny, SortExpression, asc
class ODMBaseIndex(metaclass=ABCMeta):
def __init__(self, unique: bool, index_name: Optional[str]) -> None:
self.unique = unique
self.index_name = index_name
@abstractmethod
def get_index_specifier(self) -> Sequence[Tuple[str, int]]: ...
def get_pymongo_index(self) -> pymongo.IndexModel:
kwargs: Dict[str, Any] = {"keys": self.get_index_specifier()}
if self.index_name is not None:
kwargs["name"] = self.index_name
if self.unique:
kwargs["unique"] = True
return pymongo.IndexModel(**kwargs)
class ODMSingleFieldIndex(ODMBaseIndex):
def __init__(self, key_name: str, unique: bool, index_name: Optional[str] = None):
super().__init__(unique, index_name)
self.key_name = key_name
def get_index_specifier(self) -> Sequence[Tuple[str, int]]:
return [
(self.key_name, pymongo.ASCENDING),
]
class ODMCompoundIndex(ODMBaseIndex):
def __init__(
self,
fields: Tuple[SortExpression, ...],
unique: bool,
index_name: Optional[str],
):
super().__init__(unique, index_name)
self.fields = fields
def get_index_specifier(self) -> Sequence[Tuple[str, int]]:
return [
(
list(f.keys())[0],
pymongo.ASCENDING if list(f.values())[0] == 1 else pymongo.DESCENDING,
)
for f in self.fields
]
class Index:
def __init__(
self,
*fields: Union[FieldProxyAny, SortExpression],
unique: bool = False,
name: Optional[str] = None,
) -> None:
"""Declare an ODM index in the Model.Config.indexes generator.
Example usage:
```python
from odmantic import Model, Index
from odmantic.query import desc
class Player(Model):
name: str
score: int
model_config = {
"indexes": lambda: [Index(Player.name, desc(Player.score))],
}
```
Args:
*fields (Any | SortExpression | str): fields to build the index with
unique: build a unique index
name: specify an optional custom index name
"""
self.fields = cast(Tuple[Union[SortExpression, FieldProxy], ...], fields)
self.unique = unique
self.name = name
def to_odm_index(self) -> "ODMBaseIndex":
if len(self.fields) == 1:
field = self.fields[0]
if isinstance(field, SortExpression):
key_name = list(field.keys())[0]
else:
key_name = object.__getattribute__(field, "_get_key_name")()
return ODMSingleFieldIndex(
key_name, unique=self.unique, index_name=self.name
)
else:
fields = tuple(
(f if isinstance(f, SortExpression) else asc(f) for f in self.fields)
)
return ODMCompoundIndex(fields, unique=self.unique, index_name=self.name)
python-odmantic-1.0.2/odmantic/model.py 0000664 0000000 0000000 00000114021 14613034133 0020074 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import datetime
import decimal
import enum
import pathlib
import uuid
from abc import ABCMeta
from collections.abc import Callable as abcCallable
from types import FunctionType
from typing import (
TYPE_CHECKING,
Any,
Callable,
ClassVar,
Dict,
FrozenSet,
Iterable,
List,
Optional,
Set,
Tuple,
Type,
TypeVar,
Union,
cast,
no_type_check,
)
import bson
import pydantic
import pymongo
from pydantic import TypeAdapter, ValidationError
from pydantic._internal._decorators import PydanticDescriptorProxy
from pydantic.fields import Field as PDField
from pydantic.fields import FieldInfo as PDFieldInfo
from pydantic.main import BaseModel
from pydantic_core import InitErrorDetails
from typing_extensions import Literal, deprecated
from odmantic.bson import (
_BSON_SUBSTITUTED_FIELDS,
BaseBSONModel,
ObjectId,
_get_bson_serializer,
)
from odmantic.config import ODMConfigDict, validate_config
from odmantic.exceptions import (
DocumentParsingError,
IncorrectGenericEmbeddedModelValue,
KeyNotFoundInDocumentError,
ReferencedDocumentNotFoundError,
)
from odmantic.field import (
Field,
FieldProxy,
ODMBaseField,
ODMBaseIndexableField,
ODMEmbedded,
ODMEmbeddedGeneric,
ODMField,
ODMFieldInfo,
ODMReference,
)
from odmantic.index import Index, ODMBaseIndex, ODMSingleFieldIndex
from odmantic.reference import ODMReferenceInfo
from odmantic.typing import (
GenericAlias,
dataclass_transform,
get_args,
get_first_type_argument_subclassing,
get_origin,
is_classvar,
is_type_argument_subclass,
lenient_issubclass,
resolve_annotations,
)
from odmantic.utils import (
Undefined,
is_dunder,
raise_on_invalid_collection_name,
raise_on_invalid_key_name,
to_snake_case,
)
if TYPE_CHECKING:
from odmantic.typing import (
AbstractSetIntStr,
DictStrAny,
IncEx,
MappingIntStrAny,
ReprArgs,
)
UNTOUCHED_TYPES = (
FunctionType,
property,
classmethod,
staticmethod,
PydanticDescriptorProxy,
)
def should_touch_field(value: Any = None, type_: Optional[Type] = None) -> bool:
return not (
lenient_issubclass(type_, UNTOUCHED_TYPES)
or isinstance(value, UNTOUCHED_TYPES)
or (type_ is not None and is_classvar(type_))
)
def find_duplicate_key(fields: Iterable[ODMBaseField]) -> Optional[str]:
seen: Set[str] = set()
for f in fields:
if f.key_name in seen:
return f.key_name
seen.add(f.key_name)
return None
_IMMUTABLE_TYPES = (
type(None),
bool,
int,
float,
str,
bytes,
tuple,
frozenset,
datetime.date,
datetime.time,
datetime.datetime,
datetime.timedelta,
enum.Enum,
decimal.Decimal,
pathlib.Path,
uuid.UUID,
pydantic.BaseModel,
bson.ObjectId,
bson.Decimal128,
decimal.Decimal,
)
def is_type_mutable(type_: Type) -> bool:
type_origin: Optional[Type] = getattr(type_, "__origin__", None)
if type_origin is not None:
type_args: Tuple[Type, ...] = getattr(type_, "__args__", ())
for type_arg in type_args:
if type_arg is ...: # Handle tuple definition
continue
if lenient_issubclass(type_origin, Iterable) and lenient_issubclass(
type_arg, EmbeddedModel
): # Handle nested embedded models
return True
if is_type_mutable(type_arg):
return True
if type_origin is Union:
return False
return not lenient_issubclass(type_origin, _IMMUTABLE_TYPES)
else:
is_immutable = type_ is None or (
lenient_issubclass(type_, _IMMUTABLE_TYPES)
and not lenient_issubclass(type_, EmbeddedModel)
)
return not is_immutable
def is_type_forbidden(t: Type) -> bool:
if t is Callable or t is abcCallable:
# Callable type require a special treatment since typing.Callable is not a class
return True
return False
def validate_type(type_: Type) -> Type:
if not should_touch_field(type_=type_) or lenient_issubclass(
type_, (Model, EmbeddedModel)
):
return type_
if is_type_forbidden(type_):
raise TypeError(f"{type_} fields are not supported")
subst_type = _BSON_SUBSTITUTED_FIELDS.get(type_)
if subst_type is not None:
return subst_type
type_origin: Optional[Type] = get_origin(type_)
if type_origin is not None and type_origin is not Literal:
type_args: Tuple[Type, ...] = get_args(type_)
new_arg_types = tuple(validate_type(subtype) for subtype in type_args)
# FIXME: remove this hack when a better solution to handle dynamic
# generics is found
# https://github.com/pydantic/pydantic/issues/8354
if type_origin is Union:
# as new_arg_types is a tuple, we can directly create a matching Union
# instance, instead of hacking our way around it:
# https://stackoverflow.com/a/72884529/3784643
type_ = Union[new_arg_types] # type: ignore
else:
type_ = GenericAlias(type_origin, new_arg_types) # type: ignore
return type_
class BaseModelMetaclass(pydantic._internal._model_construction.ModelMetaclass):
@staticmethod
def __validate_cls_namespace__( # noqa C901
name: str, namespace: Dict[str, Any]
) -> None:
"""Validate the class name space in place"""
annotations = resolve_annotations(
namespace.get("__annotations__", {}), namespace.get("__module__")
)
config = validate_config(namespace.get("model_config", ODMConfigDict()), name)
odm_fields: Dict[str, ODMBaseField] = {}
references: List[str] = []
bson_serializers: Dict[str, Callable[[Any], Any]] = {}
mutable_fields: Set[str] = set()
# Make sure all fields are defined with type annotation
for field_name, value in namespace.items():
if (
should_touch_field(value=value)
and not is_dunder(field_name)
and field_name not in annotations
and not field_name.startswith("model_")
):
raise TypeError(
f"field {field_name} is defined without type annotation"
)
# Validate fields types and substitute bson fields
for field_name, field_type in annotations.items():
if not is_dunder(field_name) and should_touch_field(type_=field_type):
substituted_type = validate_type(field_type)
annotations[field_name] = substituted_type
# Handle BSON serialized fields after substitution to allow some
# builtin substitutions
bson_serializer = _get_bson_serializer(substituted_type)
if bson_serializer is not None:
bson_serializers[field_name] = bson_serializer
# Validate fields
for field_name, field_type in annotations.items():
value = namespace.get(field_name, Undefined)
if is_dunder(field_name) or not should_touch_field(value, field_type):
continue # pragma: no cover
# https://github.com/nedbat/coveragepy/issues/198
if isinstance(value, PDFieldInfo):
raise TypeError("please use odmantic.Field instead of pydantic.Field")
if is_type_mutable(field_type):
mutable_fields.add(field_name)
if lenient_issubclass(field_type, EmbeddedModel):
if isinstance(value, ODMFieldInfo):
namespace[field_name] = value.pydantic_field_info
key_name = (
value.key_name if value.key_name is not None else field_name
)
primary_field = value.primary_field
index = value.index
unique = value.unique
else:
key_name = field_name
primary_field = False
index = False
unique = False
odm_fields[field_name] = ODMEmbedded(
primary_field=primary_field,
model=field_type,
key_name=key_name,
model_config=config,
index=index,
unique=unique,
)
elif is_type_argument_subclass(field_type, EmbeddedModel):
if isinstance(value, ODMFieldInfo):
if value.primary_field:
raise TypeError(
"Declaring a generic type of embedded models as a primary "
f"field is not allowed: {field_name} in {name}"
)
namespace[field_name] = value.pydantic_field_info
key_name = (
value.key_name if value.key_name is not None else field_name
)
index = value.index
unique = value.unique
else:
key_name = field_name
index = False
unique = False
model = get_first_type_argument_subclassing(field_type, EmbeddedModel)
assert model is not None
if len(model.__references__) > 0:
raise TypeError(
"Declaring a generic type of embedded models containing "
f"references is not allowed: {field_name} in {name}"
)
generic_origin = get_origin(field_type)
assert generic_origin is not None
odm_fields[field_name] = ODMEmbeddedGeneric(
model=model,
generic_origin=generic_origin,
key_name=key_name,
model_config=config,
index=index,
unique=unique,
)
elif lenient_issubclass(field_type, Model):
if not isinstance(value, ODMReferenceInfo):
raise TypeError(
f"cannot define a reference {field_name} (in {name}) without"
" a Reference assigned to it"
)
key_name = value.key_name if value.key_name is not None else field_name
raise_on_invalid_key_name(key_name)
odm_fields[field_name] = ODMReference(
model=field_type, key_name=key_name, model_config=config
)
references.append(field_name)
del namespace[field_name] # Remove default ODMReferenceInfo value
else:
if isinstance(value, ODMFieldInfo):
key_name = (
value.key_name if value.key_name is not None else field_name
)
raise_on_invalid_key_name(key_name)
odm_fields[field_name] = ODMField(
primary_field=value.primary_field,
key_name=key_name,
model_config=config,
index=value.index,
unique=value.unique,
)
namespace[field_name] = value.pydantic_field_info
elif value is Undefined:
odm_fields[field_name] = ODMField(
primary_field=False, key_name=field_name, model_config=config
)
else:
try:
TypeAdapter(field_type).validate_python(value)
except ValidationError:
raise TypeError(
f"Unhandled field definition {name}: {repr(field_type)}"
f" = {repr(value)}"
)
odm_fields[field_name] = ODMField(
primary_field=False, key_name=field_name, model_config=config
)
# NOTE: Duplicate key detection make sur that at most one primary key is
# defined
duplicate_key = find_duplicate_key(odm_fields.values())
if duplicate_key is not None:
raise TypeError(f"Duplicated key_name: {duplicate_key} in {name}")
namespace["__annotations__"] = annotations
namespace["__odm_fields__"] = odm_fields
namespace["__references__"] = tuple(references)
namespace["__bson_serializers__"] = bson_serializers
namespace["__mutable_fields__"] = frozenset(mutable_fields)
namespace["model_config"] = config
@no_type_check
def __new__(
mcs,
name: str,
bases: Tuple[type, ...],
namespace: Dict[str, Any],
**kwargs: Any,
):
is_custom_cls = namespace.get(
"__module__"
) != "odmantic.model" and namespace.get("__qualname__") not in (
"_BaseODMModel",
"Model",
"EmbeddedModel",
)
if is_custom_cls:
# Handle calls from pydantic.main.create_model (used internally by FastAPI)
patched_bases = []
for b in bases:
if hasattr(b, "__pydantic_model__"):
patched_bases.append(b.__pydantic_model__)
else:
patched_bases.append(b)
bases = tuple(patched_bases)
# Nullify unset docstring (to avoid getting the docstrings from the parent
# classes)
if namespace.get("__doc__", None) is None:
namespace["__doc__"] = ""
cls = super().__new__(mcs, name, bases, namespace, **kwargs)
if is_custom_cls:
config: ODMConfigDict = namespace["model_config"]
# Patch Model related fields to build a "pure" pydantic model
odm_fields: Dict[str, ODMBaseField] = namespace["__odm_fields__"]
for field_name, field in odm_fields.items():
if isinstance(field, (ODMReference, ODMEmbedded)):
namespace["__annotations__"][field_name] = (
field.model.__pydantic_model__
)
# Build the pydantic model
pydantic_cls = (
pydantic._internal._model_construction.ModelMetaclass.__new__(
mcs,
f"{name}.__pydantic_model__",
(BaseBSONModel,),
namespace,
**kwargs,
)
)
# Change the title to generate clean JSON schemas from this "pure" model
if config["title"] is None:
pydantic_cls.model_config["title"] = name
cls.__pydantic_model__ = pydantic_cls
for name, field in cls.__odm_fields__.items():
field.bind_pydantic_field(cls.model_fields[name])
setattr(cls, name, FieldProxy(parent=None, field=field))
return cls
@dataclass_transform(kw_only_default=True, field_specifiers=(Field, ODMFieldInfo))
class ModelMetaclass(BaseModelMetaclass):
@no_type_check
def __new__( # noqa C901
mcs,
name: str,
bases: Tuple[type, ...],
namespace: Dict[str, Any],
**kwargs: Any,
):
if namespace.get("__module__") != "odmantic.model" and namespace.get(
"__qualname__"
) not in ("_BaseODMModel", "Model"):
mcs.__validate_cls_namespace__(name, namespace)
config: ODMConfigDict = namespace["model_config"]
primary_field: Optional[str] = None
odm_fields: Dict[str, ODMBaseField] = namespace["__odm_fields__"]
for field_name, field in odm_fields.items():
if isinstance(field, ODMField) and field.primary_field:
primary_field = field_name
break
if primary_field is None:
if "id" in odm_fields:
raise TypeError(
"can't automatically generate a primary field since an 'id' "
"field already exists"
)
primary_field = "id"
odm_fields["id"] = ODMField(
primary_field=True, key_name="_id", model_config=config
)
namespace["id"] = PDField(default_factory=ObjectId)
namespace["__annotations__"]["id"] = ObjectId
namespace["__primary_field__"] = primary_field
if config["collection"] is not None:
collection_name = config["collection"]
else:
cls_name = name
if cls_name.endswith("Model"):
# TODO document this
cls_name = cls_name[:-5] # Strip Model in the class name
collection_name = to_snake_case(cls_name)
raise_on_invalid_collection_name(collection_name, cls_name=name)
namespace["__collection__"] = collection_name
return super().__new__(mcs, name, bases, namespace, **kwargs)
def __pos__(cls) -> str:
return cast(str, getattr(cls, "__collection__"))
@dataclass_transform(kw_only_default=True, field_specifiers=(Field, ODMFieldInfo))
class EmbeddedModelMetaclass(BaseModelMetaclass):
@no_type_check
def __new__(
mcs,
name: str,
bases: Tuple[type, ...],
namespace: Dict[str, Any],
**kwargs: Any,
):
if namespace.get("__module__") != "odmantic.model" and namespace.get(
"__qualname__"
) not in ("_BaseODMModel", "EmbeddedModel"):
mcs.__validate_cls_namespace__(name, namespace)
odm_fields: Dict[str, ODMBaseField] = namespace["__odm_fields__"]
for field in odm_fields.values():
if isinstance(field, ODMField) and field.primary_field:
raise TypeError(
f"cannot define a primary field in {name} embedded document"
)
return super().__new__(mcs, name, bases, namespace, **kwargs)
BaseT = TypeVar("BaseT", bound="_BaseODMModel")
class _BaseODMModel(pydantic.BaseModel, metaclass=ABCMeta):
"""Base class for [Model][odmantic.model.Model] and
[EmbeddedModel][odmantic.model.EmbeddedModel].
!!! warning
This internal class should never be instanciated directly.
"""
if TYPE_CHECKING:
__odm_fields__: ClassVar[Dict[str, ODMBaseField]] = {}
__bson_serializers__: ClassVar[Dict[str, Callable[[Any], Any]]] = {}
__mutable_fields__: ClassVar[FrozenSet[str]] = frozenset()
__references__: ClassVar[Tuple[str, ...]] = ()
__pydantic_model__: ClassVar[Type[BaseBSONModel]]
# __fields_modified__ is not a ClassVar but this allows to hide this field from
# the dataclass transform generated constructor
__fields_modified__: ClassVar[Set[str]] = set()
model_config: ClassVar[ODMConfigDict]
__slots__ = ("__fields_modified__",)
def __init__(self, **data: Any):
super().__init__(**data)
object.__setattr__(self, "__fields_modified__", set(self.__odm_fields__.keys()))
@classmethod
# TODO: rename to model_validate
def validate(cls: Type[BaseT], value: Any) -> BaseT:
if isinstance(value, cls):
# Do not copy the object as done in pydantic
# This enable to keep the same python object
return value
return super().model_validate(value)
def __repr_args__(self) -> "ReprArgs":
# Place the id field first in the repr string
args = list(super().__repr_args__())
id_arg = next((arg for arg in args if arg[0] == "id"), None)
if id_arg is None:
return args
args.remove(id_arg)
args = [id_arg] + args
return args
@deprecated(
"copy is deprecated, please use model_copy instead",
)
def copy(
self: BaseT,
*,
include: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None,
exclude: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None,
update: Dict[str, Any] | None = None,
deep: bool = False,
) -> BaseT:
if include is not None or exclude is not None:
raise NotImplementedError(
"copy with include or exclude is not supported anymore, "
"please use `model_copy` instead"
)
return self.model_copy(update=update, deep=deep)
def model_copy(
self: BaseT,
*,
update: Optional["DictStrAny"] = None,
deep: bool = False,
) -> BaseT:
"""Duplicate a model, optionally choose which fields to change.
Danger:
The data is not validated before creating the new model: **you should trust
this data**.
Arguments:
update: values to change/add in the new model.
deep: set to `True` to make a deep copy of the model
Returns:
new model instance
"""
copied = super().model_copy(update=update, deep=deep)
copied._post_copy_update()
return copied
def _post_copy_update(self: BaseT) -> None:
"""Recursively update internal fields of the copied model after it has been
copied.
Set them as if they were modified to make sure they are saved in the database.
"""
object.__setattr__(self, "__fields_modified__", set(self.model_fields))
for field_name, field in self.__odm_fields__.items():
if isinstance(field, ODMEmbedded):
value = getattr(self, field_name)
value._post_copy_update()
@deprecated(
"update is deprecated, please use model_update instead",
)
def update(
self,
patch_object: Union[BaseModel, Dict[str, Any]],
*,
include: "IncEx" = None,
exclude: "IncEx" = None,
exclude_unset: bool = True,
exclude_defaults: bool = False,
exclude_none: bool = False,
) -> None:
self.model_update(
patch_object,
include=include,
exclude=exclude,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
)
def model_update(
self,
patch_object: Union[BaseModel, Dict[str, Any]],
*,
include: "IncEx" = None,
exclude: "IncEx" = None,
exclude_unset: bool = True,
exclude_defaults: bool = False,
exclude_none: bool = False,
) -> None:
"""Update instance fields from a Pydantic model or a dictionary.
If a pydantic model is provided, only the **fields set** will be
applied by default.
Args:
patch_object: object containing the values to update
include: fields to include from the `patch_object` (include all fields if
`None`)
exclude: fields to exclude from the `patch_object`, this takes
precedence over include
exclude_unset: only update fields explicitly set in the patch object (only
applies to Pydantic models)
exclude_defaults: only update fields that are different from their default
value in the patch object (only applies to Pydantic models)
exclude_none: only update fields different from None in the patch object
(only applies to Pydantic models)
Raises:
ValidationError: the modifications would make the instance invalid
"""
if isinstance(patch_object, BaseModel):
patch_dict = patch_object.model_dump(
include=include,
exclude=exclude,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
)
else:
odm_fields = set(self.__odm_fields__.keys())
patch_dict = {}
for k, v in patch_object.items():
if include is not None and k not in include:
continue
if exclude is not None and k in exclude:
continue
if k not in odm_fields:
continue
patch_dict[k] = v
patched_instance_dict = {**self.model_dump(), **patch_dict}
# FIXME: improve performance by only running updated field validators and then
# model validators
patched_instance = self.validate(patched_instance_dict)
for name, new_value in patched_instance.__dict__.items():
if self.__dict__[name] != new_value:
# Manually change the field to avoid running the validators again
self.__dict__[name] = new_value
self.model_fields_set.add(name)
self.__fields_modified__.add(name)
def __setattr__(self, name: str, value: Any) -> None:
super().__setattr__(name, value)
self.__fields_modified__.add(name)
@deprecated(
"doc is deprecated, please use model_dump_doc instead",
)
def doc(self, include: Optional["AbstractSetIntStr"] = None) -> Dict[str, Any]:
return self.model_dump_doc(include=include)
def model_dump_doc(
self, include: Optional["AbstractSetIntStr"] = None
) -> Dict[str, Any]:
"""Generate a document (BSON) representation of the instance (as a dictionary).
Args:
include: field that should be included; if None, every fields will be
included
Returns:
the document associated to the instance
"""
raw_doc = self.model_dump()
doc = self.__doc(raw_doc, type(self), include)
return doc
def __doc( # noqa C901 # TODO: refactor document generation
self,
raw_doc: Dict[str, Any],
model: Type["_BaseODMModel"],
include: Optional["AbstractSetIntStr"] = None,
) -> Dict[str, Any]:
doc: Dict[str, Any] = {}
for field_name, field in model.__odm_fields__.items():
if include is not None and field_name not in include:
continue
if isinstance(field, ODMReference):
doc[field.key_name] = raw_doc[field_name][field.model.__primary_field__]
elif isinstance(field, ODMEmbedded):
doc[field.key_name] = self.__doc(raw_doc[field_name], field.model, None)
elif isinstance(field, ODMEmbeddedGeneric):
if field.generic_origin is dict:
doc[field.key_name] = {
item_key: self.__doc(item_value, field.model)
for item_key, item_value in raw_doc[field_name].items()
}
else:
doc[field.key_name] = [
self.__doc(item, field.model) for item in raw_doc[field_name]
]
elif field_name in model.__bson_serializers__:
doc[field.key_name] = model.__bson_serializers__[field_name](
raw_doc[field_name]
)
else:
doc[field.key_name] = raw_doc[field_name]
if model.model_config["extra"] == "allow":
# raw_doc is indexed by field name so we compare against odm field names
extras = set(raw_doc.keys()) - set(self.__odm_fields__.keys())
for extra in extras:
value = raw_doc[extra]
subst_type = validate_type(type(value))
bson_serializer = _get_bson_serializer(subst_type)
if bson_serializer is not None:
doc[extra] = bson_serializer(value)
else:
doc[extra] = value
return doc
@classmethod
@deprecated(
"parse_doc is deprecated, please use model_validate_doc instead",
)
def parse_doc(cls: Type[BaseT], raw_doc: Dict) -> BaseT:
return cls.model_validate_doc(raw_doc)
@classmethod
def model_validate_doc(cls: Type[BaseT], raw_doc: Dict) -> BaseT:
"""Parse a BSON document into an instance of the Model
Args:
raw_doc: document to parse (as Dict)
Raises:
DocumentParsingError: the specified document is invalid
Returns:
an instance of the Model class this method is called on.
"""
errors, obj = cls._parse_doc_to_obj(raw_doc)
if len(errors) > 0:
raise DocumentParsingError(
errors=errors,
model=cls,
)
try:
instance = cls.model_validate(obj)
except ValidationError as e:
raise DocumentParsingError(
errors=e.errors(), # type: ignore
model=cls,
)
return instance
@classmethod
def _parse_doc_to_obj( # noqa C901 # TODO: refactor document parsing
cls: Type[BaseT], raw_doc: Dict, base_loc: Tuple[str, ...] = ()
) -> Tuple[List[InitErrorDetails], Dict[str, Any]]:
errors: List[InitErrorDetails] = []
obj: Dict[str, Any] = {}
for field_name, field in cls.__odm_fields__.items():
if isinstance(field, ODMReference):
sub_doc = raw_doc.get(field.key_name)
if sub_doc is None:
errors.append(
InitErrorDetails(
type=ReferencedDocumentNotFoundError(field.key_name),
loc=base_loc + (field_name,),
input=raw_doc,
)
)
else:
sub_errors, sub_obj = field.model._parse_doc_to_obj(
sub_doc, base_loc=base_loc + (field_name,)
)
errors.extend(sub_errors)
obj[field_name] = sub_obj
elif isinstance(field, ODMEmbedded):
value = raw_doc.get(field.key_name, Undefined)
if value is not Undefined:
sub_errors, value = field.model._parse_doc_to_obj(
value, base_loc=base_loc + (field_name,)
)
errors.extend(sub_errors)
else:
if not field.is_required_in_doc():
value = field.get_default_importing_value()
if value is Undefined:
errors.append(
InitErrorDetails(
type=KeyNotFoundInDocumentError(field.key_name),
loc=base_loc + (field_name,),
input=raw_doc,
)
)
obj[field_name] = value
elif isinstance(field, ODMEmbeddedGeneric):
value = Undefined
raw_value = raw_doc.get(field.key_name, Undefined)
if raw_value is not Undefined:
if isinstance(raw_value, list) and (
field.generic_origin is list
or field.generic_origin is tuple
or field.generic_origin is set
):
value = []
for i, item in enumerate(raw_value):
sub_errors, item = field.model._parse_doc_to_obj(
item, base_loc=base_loc + (field_name, f"[{i}]")
)
if len(sub_errors) > 0:
errors.extend(sub_errors)
else:
value.append(item)
obj[field_name] = value
elif isinstance(raw_value, dict) and field.generic_origin is dict:
value = {}
for item_key, item_value in raw_value.items():
sub_errors, item_value = field.model._parse_doc_to_obj(
item_value,
base_loc=base_loc + (field_name, f'["{item_key}"]'),
)
if len(sub_errors) > 0:
errors.extend(sub_errors)
else:
value[item_key] = item_value
obj[field_name] = value
else:
errors.append(
InitErrorDetails(
type=IncorrectGenericEmbeddedModelValue(raw_value),
loc=base_loc + (field_name,),
input=raw_doc,
)
)
else:
if not field.is_required_in_doc():
value = field.get_default_importing_value()
if value is Undefined:
errors.append(
InitErrorDetails(
type=KeyNotFoundInDocumentError(field.key_name),
loc=base_loc + (field_name,),
input=raw_doc,
)
)
else:
obj[field_name] = value
else:
field = cast(ODMField, field)
value = raw_doc.get(field.key_name, Undefined)
if value is Undefined and not field.is_required_in_doc():
value = field.get_default_importing_value()
if value is Undefined:
errors.append(
InitErrorDetails(
type=KeyNotFoundInDocumentError(field.key_name),
loc=base_loc + (field_name,),
input=raw_doc,
)
)
else:
obj[field_name] = value
if cls.model_config["extra"] == "allow":
extras = set(raw_doc.keys()) - set(obj.keys())
for extra in extras:
obj[extra] = raw_doc[extra]
return errors, obj
class Model(_BaseODMModel, metaclass=ModelMetaclass):
"""Class that can be extended to create an ODMantic Model.
Each model will be bound to a MongoDB collection. You can customize the collection
name by setting the `__collection__` class variable in the model classes.
"""
if TYPE_CHECKING:
__collection__: ClassVar[str] = ""
__primary_field__: ClassVar[str] = ""
id: Union[ObjectId, Any] = Field() # TODO fix basic id field typing
def __setattr__(self, name: str, value: Any) -> None:
if name == self.__primary_field__:
# TODO implement
raise NotImplementedError(
"Reassigning a new primary key is not supported yet"
)
super().__setattr__(name, value)
@classmethod
def __indexes__(cls) -> Tuple[Union[ODMBaseIndex, pymongo.IndexModel], ...]:
indexes: List[Union[ODMBaseIndex, pymongo.IndexModel]] = []
for field in cls.__odm_fields__.values():
if isinstance(field, ODMBaseIndexableField) and (
field.index or field.unique
):
indexes.append(
ODMSingleFieldIndex(
key_name=field.key_name,
unique=field.unique,
)
)
get_indexes_from_config = cls.model_config["indexes"]
if get_indexes_from_config is not None:
for index in get_indexes_from_config():
indexes.append(
index.to_odm_index() if isinstance(index, Index) else index
)
return tuple(indexes)
@deprecated(
"update is deprecated, please use model_update instead",
)
def update(
self,
patch_object: Union[BaseModel, Dict[str, Any]],
*,
include: "IncEx" = None,
exclude: "IncEx" = None,
exclude_unset: bool = True,
exclude_defaults: bool = False,
exclude_none: bool = False,
) -> None:
return self.model_update(
patch_object,
include=include,
exclude=exclude,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
)
def model_update(
self,
patch_object: Union[BaseModel, Dict[str, Any]],
*,
include: "IncEx" = None,
exclude: "IncEx" = None,
exclude_unset: bool = True,
exclude_defaults: bool = False,
exclude_none: bool = False,
) -> None:
is_primary_field_in_patch = (
isinstance(patch_object, BaseModel)
and self.__primary_field__ in patch_object.model_fields
) or (isinstance(patch_object, dict) and self.__primary_field__ in patch_object)
if is_primary_field_in_patch:
if (
include is None
and (exclude is None or self.__primary_field__ not in exclude)
) or (
include is not None
and self.__primary_field__ in include
and (exclude is None or self.__primary_field__ not in exclude)
):
raise ValueError(
"Updating the primary key is not supported. "
"See the copy method if you want to modify the primary field."
)
return super().model_update(
patch_object,
include=include,
exclude=exclude,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
)
class EmbeddedModel(_BaseODMModel, metaclass=EmbeddedModelMetaclass):
"""Class that can be extended to create an ODMantic Embedded Model.
An embedded document cannot be persisted directly to the database but should be
integrated in a regular ODMantic Model.
"""
python-odmantic-1.0.2/odmantic/py.typed 0000664 0000000 0000000 00000000000 14613034133 0020110 0 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/odmantic/query.py 0000664 0000000 0000000 00000011141 14613034133 0020140 0 ustar 00root root 0000000 0000000 import re
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, Iterable, Literal, Pattern, Union
if TYPE_CHECKING:
from odmantic.field import FieldProxy
class QueryExpression(Dict[str, Any]):
"""Base object used to build queries.
All comparison and logical operators returns `QueryExpression` objects.
The `|` and `&` operators are supported for respectively the
[or][odmantic.query.or_] and the [and][odmantic.query.and_] logical operators.
Warning:
When using those operators make sure to correctly bracket the expressions
to avoid python operator precedence issues.
"""
def __repr__(self) -> str:
parent_repr = super().__repr__()
if parent_repr == "{}":
parent_repr = ""
return f"QueryExpression({parent_repr})"
def __or__(self, other: "QueryExpression") -> "QueryExpression": # type: ignore
return or_(self, other)
def __and__(self, other: "QueryExpression") -> "QueryExpression":
return and_(self, other)
QueryDictBool = Union[QueryExpression, Dict, bool]
def and_(*elements: QueryDictBool) -> QueryExpression:
"""Logical **AND** operation between multiple `QueryExpression` objects."""
return QueryExpression({"$and": elements})
def or_(*elements: QueryDictBool) -> QueryExpression:
"""Logical **OR** operation between multiple `QueryExpression` objects."""
return QueryExpression({"$or": elements})
def nor_(*elements: QueryDictBool) -> QueryExpression:
"""Logical **NOR** operation between multiple `QueryExpression` objects."""
return QueryExpression({"$nor": elements})
def _cmp_expression(f: "FieldProxy", op: str, cmp_value: Any) -> QueryExpression:
# FIXME 🤮🤮🤮🤮🤮🤮🤮🤮🤮🤮🤮🤮🤮🤮🤮🤮
from odmantic.model import EmbeddedModel
if isinstance(cmp_value, EmbeddedModel):
value = cmp_value.model_dump_doc()
elif isinstance(cmp_value, Enum):
value = cmp_value.value
else:
value = cmp_value
return QueryExpression({+f: {op: value}})
FieldProxyAny = Union["FieldProxy", Any]
def eq(field: FieldProxyAny, value: Any) -> QueryExpression:
"""Equality comparison operator."""
return _cmp_expression(field, "$eq", value)
def ne(field: FieldProxyAny, value: Any) -> QueryExpression:
"""Inequality comparison operator (includes documents not containing the field)."""
return _cmp_expression(field, "$ne", value)
def gt(field: FieldProxyAny, value: Any) -> QueryExpression:
"""Greater than (strict) comparison operator (i.e. >)."""
return _cmp_expression(field, "$gt", value)
def gte(field: FieldProxyAny, value: Any) -> QueryExpression:
"""Greater than or equal comparison operator (i.e. >=)."""
return _cmp_expression(field, "$gte", value)
def lt(field: FieldProxyAny, value: Any) -> QueryExpression:
"""Less than (strict) comparison operator (i.e. <)."""
return _cmp_expression(field, "$lt", value)
def lte(field: FieldProxyAny, value: Any) -> QueryExpression:
"""Less than or equal comparison operator (i.e. <=)."""
return _cmp_expression(field, "$lte", value)
def in_(field: FieldProxyAny, sequence: Iterable) -> QueryExpression:
"""Select instances where `field` is contained in `sequence`."""
return _cmp_expression(field, "$in", list(sequence))
def not_in(field: FieldProxyAny, sequence: Iterable) -> QueryExpression:
"""Select instances where `field` is **not** contained in `sequence`."""
return _cmp_expression(field, "$nin", list(sequence))
def match(field: FieldProxyAny, pattern: Union[Pattern, str]) -> QueryExpression:
"""Select instances where `field` matches the `pattern` regular expression."""
# FIXME might create incompatibilities
# https://docs.mongodb.com/manual/reference/operator/query/regex/#regex-and-not
if isinstance(pattern, str):
r = re.compile(pattern)
else:
r = pattern
return QueryExpression({+field: r})
class SortExpression(Dict[str, Literal[-1, 1]]):
"""Base object used to build sort queries."""
def __repr__(self) -> str:
parent_repr = super().__repr__()
if parent_repr == "{}":
parent_repr = ""
return f"SortExpression({parent_repr})"
def _build_sort_expression(
field: FieldProxyAny, order: Literal[-1, 1]
) -> SortExpression:
return SortExpression({+field: order})
def asc(field: FieldProxyAny) -> SortExpression:
"""Sort by ascending `field`."""
return _build_sort_expression(field, 1)
def desc(field: FieldProxyAny) -> SortExpression:
"""Sort by descending `field`."""
return _build_sort_expression(field, -1)
python-odmantic-1.0.2/odmantic/reference.py 0000664 0000000 0000000 00000000745 14613034133 0020741 0 ustar 00root root 0000000 0000000 from typing import Any, Optional
def Reference(*, key_name: Optional[str] = None) -> Any:
"""Used to define reference fields.
Args:
key_name: name of the Mongo key that stores the foreign key
"""
return ODMReferenceInfo(key_name=key_name)
class ODMReferenceInfo:
"""Extra data for an ODM reference."""
__slots__ = ("key_name",)
def __init__(self, key_name: Optional[str]):
self.key_name = key_name
python-odmantic-1.0.2/odmantic/session.py 0000664 0000000 0000000 00000053734 14613034133 0020474 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from abc import ABCMeta
from types import TracebackType
from typing import (
Any,
AsyncContextManager,
ContextManager,
Dict,
List,
Optional,
Sequence,
Type,
Union,
)
from motor.motor_asyncio import AsyncIOMotorClientSession
from pymongo.client_session import ClientSession
import odmantic.engine as ODMEngine
from odmantic.query import QueryExpression
class AIOSessionBase(metaclass=ABCMeta):
engine: ODMEngine.AIOEngine
def find(
self,
model: Type[ODMEngine.ModelType],
*queries: Union[
QueryExpression, Dict, bool
], # bool: allow using binary operators with mypy
sort: Optional[Any] = None,
skip: int = 0,
limit: Optional[int] = None,
) -> ODMEngine.AIOCursor[ODMEngine.ModelType]:
"""Search for Model instances matching the query filter provided
Args:
model: model to perform the operation on
*queries: query filter to apply
sort: sort expression
skip: number of document to skip
limit: maximum number of instance fetched
Returns:
[odmantic.engine.AIOCursor][] of the query
"""
return self.engine.find(
model,
*queries,
sort=sort,
skip=skip,
limit=limit,
session=self.engine._get_session(self),
)
async def find_one(
self,
model: Type[ODMEngine.ModelType],
*queries: Union[
QueryExpression, Dict, bool
], # bool: allow using binary operators w/o plugin
sort: Optional[Any] = None,
) -> Optional[ODMEngine.ModelType]:
"""Search for a Model instance matching the query filter provided
Args:
model: model to perform the operation on
*queries: query filter to apply
sort: sort expression
Raises:
DocumentParsingError: unable to parse the resulting document
Returns:
the fetched instance if found otherwise None
"""
return await self.engine.find_one(
model, *queries, sort=sort, session=self.engine._get_session(self)
)
async def count(
self,
model: Type[ODMEngine.ModelType],
*queries: Union[QueryExpression, Dict, bool],
) -> int:
"""Get the count of documents matching a query
Args:
model: model to perform the operation on
*queries: query filters to apply
Returns:
number of document matching the query
"""
return await self.engine.count(
model, *queries, session=self.engine._get_session(self)
)
async def save(
self,
instance: ODMEngine.ModelType,
) -> ODMEngine.ModelType:
"""Persist an instance to the database
This method behaves as an 'upsert' operation. If a document already exists
with the same primary key, it will be overwritten.
All the other models referenced by this instance will be saved as well.
Args:
instance: instance to persist
Returns:
the saved instance
NOTE:
The save operation actually modify the instance argument in place. However,
the instance is still returned for convenience.
"""
return await self.engine.save(instance, session=self.engine._get_session(self))
async def save_all(
self,
instances: Sequence[ODMEngine.ModelType],
) -> List[ODMEngine.ModelType]:
"""Persist instances to the database
This method behaves as multiple 'upsert' operations. If one of the document
already exists with the same primary key, it will be overwritten.
All the other models referenced by this instance will be recursively saved as
well.
Args:
instances: instances to persist
Returns:
the saved instances
NOTE:
The save_all operation actually modify the arguments in place. However, the
instances are still returned for convenience.
"""
return await self.engine.save_all(
instances, session=self.engine._get_session(self)
)
async def delete(
self,
instance: ODMEngine.ModelType,
) -> None:
"""Delete an instance from the database
Args:
instance: the instance to delete
Raises:
DocumentNotFoundError: the instance has not been persisted to the database
"""
return await self.engine.delete(
instance, session=self.engine._get_session(self)
)
async def remove(
self,
model: Type[ODMEngine.ModelType],
*queries: Union[QueryExpression, Dict, bool],
just_one: bool = False,
) -> int:
"""Delete Model instances matching the query filter provided
Args:
model: model to perform the operation on
*queries: query filter to apply
just_one: limit the deletion to just one document
Returns:
the number of instances deleted from the database.
"""
return await self.engine.remove(
model, *queries, just_one=just_one, session=self.engine._get_session(self)
)
class AIOSession(AIOSessionBase, AsyncContextManager):
"""An AsyncIO session object for ordering sequential operations.
Sessions can be created from the engine directly by using the
[AIOEngine.session][odmantic.engine.AIOEngine.session] method.
Example usage as a context manager:
```python
engine = AIOEngine(...)
async with engine.session() as session:
john = await session.find(User, User.name == "John")
john.name = "Doe"
await session.save(john)
```
Example raw usage:
```python
engine = AIOEngine(...)
session = engine.session()
await session.start()
john = await session.find(User, User.name == "John")
john.name = "Doe"
await session.save(john)
await session.end()
```
"""
def __init__(self, engine: ODMEngine.AIOEngine):
self.engine = engine
self.session: Optional[AsyncIOMotorClientSession] = None
@property
def is_started(self) -> bool:
return self.session is not None
def get_driver_session(self) -> AsyncIOMotorClientSession:
"""Return the underlying Motor Session"""
if self.session is None:
raise RuntimeError("session not started")
return self.session
async def start(self) -> None:
"""Start the logical Mongo session."""
if self.is_started:
raise RuntimeError("Session is already started")
self.session = await self.engine.client.start_session()
async def end(self) -> None:
"""Finish the logical session."""
if self.session is None:
raise RuntimeError("Session is not started")
await self.session.end_session()
self.session = None
async def __aenter__(self) -> "AIOSession":
await self.start()
return self
async def __aexit__(
self,
exc_type: Optional[Type[BaseException]],
exc: Optional[BaseException],
traceback: Optional[TracebackType],
) -> None:
await self.end()
def transaction(self) -> AIOTransaction:
"""Create a transaction in the existing session"""
return AIOTransaction(self)
class AIOTransaction(AIOSessionBase, AsyncContextManager):
"""A transaction object to aggregate sequential operations.
Transactions can be created from the engine using the
[AIOEngine.transaction][odmantic.engine.AIOEngine.transaction]
method or they can be created during an existing session by using
[AIOSession.transaction][odmantic.session.AIOSession.transaction].
Example usage as a context manager:
```python
engine = AIOEngine(...)
async with engine.transaction() as transaction:
john = await transaction.find(User, User.name == "John")
john.name = "Doe"
await transaction.save(john)
await transaction.commit()
```
Example raw usage:
```python
engine = AIOEngine(...)
transaction = engine.transaction()
await transaction.start()
john = await transaction.find(User, User.name == "John")
john.name = "Doe"
await transaction.save(john)
await transaction.commit()
```
Warning:
MongoDB transaction are only supported on replicated clusters: either directly a
replicaSet or a sharded cluster with replication enabled.
"""
def __init__(self, context: Union[ODMEngine.AIOEngine, ODMEngine.AIOSession]):
self._session_provided = isinstance(context, ODMEngine.AIOSession)
if self._session_provided:
assert isinstance(context, ODMEngine.AIOSession)
if not context.is_started:
raise RuntimeError("provided session is not started")
self.session = context
self.engine = context.engine
else:
assert isinstance(context, ODMEngine.AIOEngine)
self.session = AIOSession(context)
self.engine = context
self._transaction_started = False
self._transaction_context: Optional[AsyncContextManager] = None
def get_driver_session(self) -> AsyncIOMotorClientSession:
"""Return the underlying Motor Session"""
if not self._transaction_started:
raise RuntimeError("transaction not started")
return self.session.get_driver_session()
async def start(self) -> None:
"""Initiate the transaction."""
if self._transaction_started:
raise RuntimeError("Transaction already started")
if not self._session_provided:
await self.session.start()
assert self.session.session is not None
self._transaction_context = (
await self.session.session.start_transaction().__aenter__()
)
self._transaction_started = True
async def commit(self) -> None:
"""Commit the changes and close the transaction."""
if not self._transaction_started:
raise RuntimeError("Transaction not started")
assert self.session.session is not None
await self.session.session.commit_transaction()
self._transaction_started = False
if not self._session_provided:
await self.session.end()
async def abort(self) -> None:
"""Discard the changes and drop the transaction"""
if not self._transaction_started:
raise RuntimeError("Transaction not started")
assert self.session.session is not None
await self.session.session.abort_transaction()
self._transaction_started = False
if not self._session_provided:
await self.session.end()
async def __aenter__(self) -> "AIOTransaction":
await self.start()
return self
async def __aexit__(
self,
exc_type: Optional[Type[BaseException]],
exc: Optional[BaseException],
traceback: Optional[TracebackType],
) -> None:
assert self._transaction_context is not None
await self._transaction_context.__aexit__(exc_type, exc, traceback)
self._transaction_started = False
class SyncSessionBase(metaclass=ABCMeta):
engine: ODMEngine.SyncEngine
def find(
self,
model: Type[ODMEngine.ModelType],
*queries: Union[
QueryExpression, Dict, bool
], # bool: allow using binary operators with mypy
sort: Optional[Any] = None,
skip: int = 0,
limit: Optional[int] = None,
) -> ODMEngine.SyncCursor[ODMEngine.ModelType]:
"""Search for Model instances matching the query filter provided
Args:
model: model to perform the operation on
*queries: query filter to apply
sort: sort expression
skip: number of document to skip
limit: maximum number of instance fetched
Returns:
[odmantic.engine.SyncCursor][] of the query
"""
return self.engine.find(
model,
*queries,
sort=sort,
skip=skip,
limit=limit,
session=self.engine._get_session(self),
)
def find_one(
self,
model: Type[ODMEngine.ModelType],
*queries: Union[
QueryExpression, Dict, bool
], # bool: allow using binary operators w/o plugin
sort: Optional[Any] = None,
) -> Optional[ODMEngine.ModelType]:
"""Search for a Model instance matching the query filter provided
Args:
model: model to perform the operation on
*queries: query filter to apply
sort: sort expression
Raises:
DocumentParsingError: unable to parse the resulting document
Returns:
the fetched instance if found otherwise None
"""
return self.engine.find_one(
model, *queries, sort=sort, session=self.engine._get_session(self)
)
def count(
self,
model: Type[ODMEngine.ModelType],
*queries: Union[QueryExpression, Dict, bool],
) -> int:
"""Get the count of documents matching a query
Args:
model: model to perform the operation on
*queries: query filters to apply
Returns:
number of document matching the query
"""
return self.engine.count(
model, *queries, session=self.engine._get_session(self)
)
def save(
self,
instance: ODMEngine.ModelType,
) -> ODMEngine.ModelType:
"""Persist an instance to the database
This method behaves as an 'upsert' operation. If a document already exists
with the same primary key, it will be overwritten.
All the other models referenced by this instance will be saved as well.
Args:
instance: instance to persist
Returns:
the saved instance
NOTE:
The save operation actually modify the instance argument in place. However,
the instance is still returned for convenience.
"""
return self.engine.save(instance, session=self.engine._get_session(self))
def save_all(
self,
instances: Sequence[ODMEngine.ModelType],
) -> List[ODMEngine.ModelType]:
"""Persist instances to the database
This method behaves as multiple 'upsert' operations. If one of the document
already exists with the same primary key, it will be overwritten.
All the other models referenced by this instance will be recursively saved as
well.
Args:
instances: instances to persist
Returns:
the saved instances
NOTE:
The save_all operation actually modify the arguments in place. However, the
instances are still returned for convenience.
"""
return self.engine.save_all(instances, session=self.engine._get_session(self))
def delete(
self,
instance: ODMEngine.ModelType,
) -> None:
"""Delete an instance from the database
Args:
instance: the instance to delete
Raises:
DocumentNotFoundError: the instance has not been persisted to the database
"""
return self.engine.delete(instance, session=self.engine._get_session(self))
def remove(
self,
model: Type[ODMEngine.ModelType],
*queries: Union[QueryExpression, Dict, bool],
just_one: bool = False,
) -> int:
"""Delete Model instances matching the query filter provided
Args:
model: model to perform the operation on
*queries: query filter to apply
just_one: limit the deletion to just one document
Returns:
the number of instances deleted from the database.
"""
return self.engine.remove(
model, *queries, just_one=just_one, session=self.engine._get_session(self)
)
class SyncSession(SyncSessionBase, ContextManager):
"""A session object for ordering sequential operations.
Sessions can be created from the engine directly by using the
[SyncEngine.session][odmantic.engine.SyncEngine.session] method.
Example usage as a context manager:
```python
engine = SyncEngine(...)
with engine.session() as session:
john = session.find(User, User.name == "John")
john.name = "Doe"
session.save(john)
```
Example raw usage:
```python
engine = SyncEngine(...)
session = engine.session()
session.start()
john = session.find(User, User.name == "John")
john.name = "Doe"
session.save(john)
session.end()
```
"""
def __init__(self, engine: ODMEngine.SyncEngine):
self.engine = engine
self.session: Optional[ClientSession] = None
@property
def is_started(self) -> bool:
return self.session is not None
def get_driver_session(self) -> ClientSession:
"""Return the underlying PyMongo Session"""
if self.session is None:
raise RuntimeError("session not started")
return self.session
def start(self) -> None:
"""Start the logical session."""
if self.is_started:
raise RuntimeError("Session is already started")
self.session = self.engine.client.start_session()
def end(self) -> None:
"""Finish the logical session."""
if self.session is None:
raise RuntimeError("Session is not started")
self.session.end_session()
self.session = None
def __enter__(self) -> "SyncSession":
self.start()
return self
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc: Optional[BaseException],
traceback: Optional[TracebackType],
) -> None:
self.end()
def transaction(self) -> SyncTransaction:
"""Create a transaction in the existing session"""
return SyncTransaction(self)
class SyncTransaction(SyncSessionBase, ContextManager):
"""A transaction object to aggregate sequential operations.
Transactions can be created from the engine using the
[SyncEngine.transaction][odmantic.engine.SyncEngine.transaction]
method or they can be created during an existing session by using
[SyncSession.transaction][odmantic.session.SyncSession.transaction].
Example usage as a context manager:
```python
engine = SyncEngine(...)
with engine.transaction() as transaction:
john = transaction.find(User, User.name == "John")
john.name = "Doe"
transaction.save(john)
transaction.commit()
```
Example raw usage:
```python
engine = SyncEngine(...)
transaction = engine.transaction()
transaction.start()
john = transaction.find(User, User.name == "John")
john.name = "Doe"
transaction.save(john)
transaction.commit()
```
Warning:
MongoDB transaction are only supported on replicated clusters: either directly a
replicaSet or a sharded cluster with replication enabled.
"""
def __init__(self, context: Union[ODMEngine.SyncEngine, ODMEngine.SyncSession]):
self._session_provided = isinstance(context, ODMEngine.SyncSession)
if self._session_provided:
assert isinstance(context, ODMEngine.SyncSession)
if not context.is_started:
raise RuntimeError("provided session is not started")
self.session = context
self.engine = context.engine
else:
assert isinstance(context, ODMEngine.SyncEngine)
self.session = SyncSession(context)
self.engine = context
self._transaction_started = False
self._transaction_context: Optional[ContextManager] = None
def get_driver_session(self) -> ClientSession:
"""Return the underlying PyMongo Session"""
if not self._transaction_started:
raise RuntimeError("transaction not started")
return self.session.get_driver_session()
def start(self) -> None:
"""Initiate the transaction."""
if self._transaction_started:
raise RuntimeError("Transaction already started")
if not self._session_provided:
self.session.start()
assert self.session.session is not None
self._transaction_context = self.session.session.start_transaction().__enter__()
self._transaction_started = True
def commit(self) -> None:
"""Commit the changes and close the transaction."""
if not self._transaction_started:
raise RuntimeError("Transaction not started")
assert self.session.session is not None
self.session.session.commit_transaction()
self._transaction_started = False
if not self._session_provided:
self.session.end()
def abort(self) -> None:
"""Discard the changes and drop the transaction."""
if not self._transaction_started:
raise RuntimeError("Transaction not started")
assert self.session.session is not None
self.session.session.abort_transaction()
self._transaction_started = False
if not self._session_provided:
self.session.end()
def __enter__(self) -> "SyncTransaction":
self.start()
return self
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc: Optional[BaseException],
traceback: Optional[TracebackType],
) -> None:
assert self._transaction_context is not None
self._transaction_context.__exit__(exc_type, exc, traceback)
self._transaction_started = False
python-odmantic-1.0.2/odmantic/typing.py 0000664 0000000 0000000 00000003661 14613034133 0020315 0 ustar 00root root 0000000 0000000 import sys
from typing import TYPE_CHECKING, AbstractSet, Any # noqa: F401
from typing import Callable as TypingCallable
from typing import Dict, Iterable, Mapping, Tuple, Type, TypeVar, Union # noqa: F401
from pydantic.v1.typing import is_classvar, resolve_annotations # noqa: F401
from pydantic.v1.utils import lenient_issubclass
if sys.version_info < (3, 11):
from typing_extensions import dataclass_transform
else:
from typing import dataclass_transform # noqa: F401
if sys.version_info < (3, 10):
from typing_extensions import TypeAlias
else:
from typing import TypeAlias
if sys.version_info < (3, 9):
from typing import _GenericAlias as GenericAlias # noqa: F401
# Even if get_args and get_origin are available in typing, it's important to
# import them from typing_extensions to have proper origins with Annotated fields
from typing_extensions import Annotated, get_args, get_origin
else:
from typing import GenericAlias # type: ignore # noqa: F401
from typing import Annotated, get_args, get_origin # noqa: F401
if TYPE_CHECKING:
NoArgAnyCallable: TypeAlias = TypingCallable[[], Any]
ReprArgs: TypeAlias = "Iterable[tuple[str | None, Any]]"
AbstractSetIntStr: TypeAlias = "AbstractSet[int] | AbstractSet[str]"
MappingIntStrAny: TypeAlias = "Mapping[int, Any] | Mapping[str, Any]"
DictStrAny: TypeAlias = Dict[str, Any]
IncEx: TypeAlias = "set[int] | set[str] | dict[int, Any] | dict[str, Any] | None"
def is_type_argument_subclass(
type_: Type, class_or_tuple: Union[Type[Any], Tuple[Type[Any], ...]]
) -> bool:
args = get_args(type_)
return any(lenient_issubclass(arg, class_or_tuple) for arg in args)
T = TypeVar("T")
def get_first_type_argument_subclassing(
type_: Type, cls: Type[T]
) -> Union[Type[T], None]:
args: Tuple[Type, ...] = get_args(type_)
for arg in args:
if lenient_issubclass(arg, cls):
return arg
return None
python-odmantic-1.0.2/odmantic/utils.py 0000664 0000000 0000000 00000002251 14613034133 0020135 0 ustar 00root root 0000000 0000000 import re
def is_dunder(name: str) -> bool:
return name.startswith("__") and name.endswith("__")
def raise_on_invalid_key_name(name: str) -> None:
# https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names
if name.startswith("$"):
raise TypeError("key_name cannot start with the dollar sign ($) character")
if "." in name:
raise TypeError("key_name cannot contain the dot (.) character")
def raise_on_invalid_collection_name(collection_name: str, cls_name: str) -> None:
# https://docs.mongodb.com/manual/reference/limits/#Restriction-on-Collection-Names
if "$" in collection_name:
raise TypeError(f"Invalid collection name for {cls_name}: cannot contain '$'")
if collection_name == "":
raise TypeError(f"Invalid collection name for {cls_name}: cannot be empty")
if collection_name.startswith("system."):
raise TypeError(
f"Invalid collection name for {cls_name}:" " cannot start with 'system.'"
)
def to_snake_case(s: str) -> str:
tmp = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", s)
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", tmp).lower()
class Undefined:
pass
python-odmantic-1.0.2/pyproject.toml 0000664 0000000 0000000 00000007731 14613034133 0017551 0 ustar 00root root 0000000 0000000 [project]
name = "odmantic"
version = "1.0.2"
description = "ODMantic, an AsyncIO MongoDB Object Document Mapper for Python using type hints "
authors = [{ name = "Arthur Pastel", email = "arthur.pastel@gmail.com" }]
license = { file = "LICENSE" }
readme = "README.md"
keywords = ["mongodb", "asyncio", "types", "pydantic", "motor"]
classifiers = [
"Intended Audience :: Information Technology",
"Intended Audience :: System Administrators",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Topic :: Internet",
"Topic :: Database",
"Topic :: Database :: Front-Ends",
"Topic :: Software Development :: Object Brokering",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development",
"Typing :: Typed",
"Development Status :: 4 - Beta",
"Framework :: AsyncIO",
"Environment :: Web Environment",
"License :: OSI Approved :: ISC License (ISCL)",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3",
"Programming Language :: Python",
"Framework :: Pydantic :: 2",
"Framework :: Pydantic",
]
requires-python = ">=3.8"
dependencies = [
"pydantic >=2.5.2",
"typing-extensions >= 4.2.0; python_version<'3.11'",
"motor >=3.1.1",
"pymongo >=4.1.0",
]
[project.optional-dependencies]
test = [
"pytest ~= 7.0",
"pytest-xdist ~= 2.1.0",
"pytest-asyncio ~= 0.16.0",
# "pytest-testmon ~= 1.3.1",
"pytest-sugar ~= 0.9.5",
"inline-snapshot ~= 0.6.0",
"async-asgi-testclient ~= 1.4.11",
"asyncmock ~= 0.4.2",
"coverage[toml] ~= 6.2",
"pytz ~= 2023.3",
"types-pytz ~= 2023.3.0.0",
"darglint ~= 1.8.1",
"uvicorn ~= 0.17.0",
"fastapi >=0.104.0",
"requests ~= 2.24",
"pytest-benchmark ~= 4.0.0",
"pytest-codspeed ~= 2.1.0",
"httpx ~= 0.24.1",
]
fastapi = ["fastapi >=0.100.0"]
doc = [
"pydocstyle[toml] ~= 6.3.0",
"mkdocs-material ~= 9.5.2",
"mkdocstrings[python] ~= 0.24.0",
"mkdocs-macros-plugin ~= 1.0.4",
]
lint = ["ruff ~= 0.3.3", "mypy ~= 1.4.1"]
dev = ["semver ~= 2.13.0", "typer ~= 0.4.1", "ipython ~= 7.16.1"]
[project.urls]
Documentation = "https://art049.github.io/odmantic"
Source = "https://github.com/art049/odmantic"
[build-system]
requires = ["flit_core >=3.2,<4"]
build-backend = "flit_core.buildapi"
[tool.ruff]
line-length = 88
[tool.ruff.lint]
per-file-ignores = { "tests/*" = ["C", "I"], "odmantic/typing.py" = ["I001"] }
select = ["E", "F", "I", "C"]
ignore = ["C405"]
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".hg",
".mypy_cache",
".nox",
".pants.d",
".ruff_cache",
".svn",
".tox",
".venv",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"venv",
]
mccabe.max-complexity = 10
[tool.pydocstyle]
convention = "google"
match_dir = "odmantic"
add_ignore = ["D1", "D205", "D415"]
[tool.isort]
line_length = 88
multi_line_output = 3
include_trailing_comma = true
use_parentheses = true
force_grid_wrap = 0
float_to_top = true
known_first_party = ["odmantic", "tests"]
skip = ["docs"]
[tool.pytest.ini_options]
filterwarnings = [
"ignore:\"@coroutine\" decorator is deprecated.*:DeprecationWarning:motor.*",
"ignore:the AIOEngineDependency object is deprecated.*:DeprecationWarning:odmantic.*",
]
pythonpath = "src tests"
addopts = "--benchmark-disable -W error::DeprecationWarning"
[tool.coverage.run]
branch = true
[tool.coverage.report]
include = ["odmantic/*", "tests/*"]
omit = ["**/conftest.py"]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"@pytest.mark.skip",
"@abstractmethod",
]
python-odmantic-1.0.2/tests/ 0000775 0000000 0000000 00000000000 14613034133 0015767 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/tests/__init__.py 0000664 0000000 0000000 00000000000 14613034133 0020066 0 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/tests/integration/ 0000775 0000000 0000000 00000000000 14613034133 0020312 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/tests/integration/__init__.py 0000664 0000000 0000000 00000000000 14613034133 0022411 0 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/tests/integration/benchmarks/ 0000775 0000000 0000000 00000000000 14613034133 0022427 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/tests/integration/benchmarks/__init__.py 0000664 0000000 0000000 00000000000 14613034133 0024526 0 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/tests/integration/benchmarks/models.py 0000664 0000000 0000000 00000004055 14613034133 0024270 0 ustar 00root root 0000000 0000000 """Models based on https://github.com/tortoise/orm-benchmarks"""
from datetime import datetime, timezone
from decimal import Decimal
from random import choice
from typing import Iterator, Optional
from typing_extensions import Literal, get_args
from odmantic import Field, Model
Level = Literal[10, 20, 30, 40, 50]
VALID_LEVELS = list(get_args(Level))
def utc_now():
return datetime.now(timezone.utc)
class SmallJournal(Model):
timestamp: datetime = Field(default_factory=utc_now)
level: Level = Field(index=True)
text: str = Field(index=True)
@classmethod
def get_random_instances(cls, context: str, count: int) -> Iterator["SmallJournal"]:
for i in range(count):
yield cls(level=choice(VALID_LEVELS), text=f"From {context}, item {i}")
class JournalWithRelations(Model):
timestamp: datetime = Field(default_factory=utc_now)
level: Level = Field(index=True)
text: str = Field(index=True)
# parent
class BigJournal(Model):
timestamp: datetime = Field(default_factory=utc_now)
level: Level = Field(index=True)
text: str = Field(index=True)
col_float1: float = Field(default=2.2)
col_smallint1: int = Field(default=2)
col_int1: int = Field(default=2000000)
col_bigint1: int = Field(default=99999999)
col_char1: str = Field(default=255, max_length=255)
col_text1: str = Field(
default="Moo,Foo,Baa,Waa,Moo,Foo,Baa,Waa,Moo,Foo,Baa,Waa",
)
col_decimal1: Decimal = Field(default=Decimal("2.2"))
col_json1: dict = Field(
default={"a": 1, "b": "b", "c": [2], "d": {"e": 3}, "f": True},
)
col_float2: Optional[float] = Field(default=None)
col_smallint2: Optional[int] = Field(default=None)
col_int2: Optional[int] = Field(default=None)
col_bigint2: Optional[int] = Field(default=None)
col_char2: Optional[str] = Field(default=None, max_length=255)
col_text2: Optional[str] = Field(
default=None,
)
col_decimal2: Optional[Decimal] = Field(default=None)
col_json2: Optional[dict] = Field(
default=None,
)
python-odmantic-1.0.2/tests/integration/benchmarks/test_bench_async.py 0000664 0000000 0000000 00000004127 14613034133 0026320 0 ustar 00root root 0000000 0000000 import pytest
from odmantic import AIOEngine
from .models import VALID_LEVELS, SmallJournal
pytestmark = [
pytest.mark.asyncio,
pytest.mark.skip("@benchmark does not support async functions yet"),
]
@pytest.fixture(params=[10, 50, 100])
def count(request):
return request.param
async def test_insert_small_single(benchmark, aio_engine: AIOEngine, count: int):
instances = list(SmallJournal.get_random_instances("test_write_small", count))
@benchmark
async def _():
for instance in instances:
await aio_engine.save(instance)
async def test_write_small_bulk(
benchmark,
aio_engine: AIOEngine,
count: int,
):
instances = list(SmallJournal.get_random_instances("test_write_small", count))
@benchmark
async def _():
await aio_engine.save_all(instances)
async def test_filter_by_level_small(benchmark, aio_engine: AIOEngine, count: int):
instances = list(SmallJournal.get_random_instances("test_write_small", count))
await aio_engine.save_all(instances)
@benchmark
async def _():
total = 0
for level in VALID_LEVELS:
total += len(
await aio_engine.find(SmallJournal, SmallJournal.level == level)
)
async def test_filter_limit_skip_by_level_small(
benchmark, aio_engine: AIOEngine, count: int
):
instances = list(SmallJournal.get_random_instances("test_write_small", count))
await aio_engine.save_all(instances)
@benchmark
async def _():
total = 0
for level in VALID_LEVELS:
total += len(
await aio_engine.find(
SmallJournal, SmallJournal.level == level, limit=20, skip=20
)
)
async def test_find_one_by_id(benchmark, aio_engine: AIOEngine, count: int):
instances = list(SmallJournal.get_random_instances("test_write_small", count))
await aio_engine.save_all(instances)
ids = [instance.id for instance in instances]
@benchmark
async def _():
for id_ in ids:
await aio_engine.find_one(SmallJournal, SmallJournal.id == id_)
python-odmantic-1.0.2/tests/integration/benchmarks/test_bench_sync.py 0000664 0000000 0000000 00000003704 14613034133 0026157 0 ustar 00root root 0000000 0000000 import pytest
from odmantic import SyncEngine
from .models import VALID_LEVELS, SmallJournal
@pytest.fixture(params=[10, 50, 100])
def count(request):
return request.param
def test_insert_small_single(benchmark, sync_engine: SyncEngine, count: int):
instances = list(SmallJournal.get_random_instances("test_write_small", count))
@benchmark
def _():
for instance in instances:
sync_engine.save(instance)
def test_write_small_bulk(
benchmark,
sync_engine: SyncEngine,
count: int,
):
instances = list(SmallJournal.get_random_instances("test_write_small", count))
@benchmark
def _():
sync_engine.save_all(instances)
def test_filter_by_level_small(benchmark, sync_engine: SyncEngine, count: int):
instances = list(SmallJournal.get_random_instances("test_write_small", count))
sync_engine.save_all(instances)
@benchmark
def _():
total = 0
for level in VALID_LEVELS:
total += len(
list(sync_engine.find(SmallJournal, SmallJournal.level == level))
)
def test_filter_limit_skip_by_level_small(
benchmark, sync_engine: SyncEngine, count: int
):
instances = list(SmallJournal.get_random_instances("test_write_small", count))
sync_engine.save_all(instances)
@benchmark
def _():
total = 0
for level in VALID_LEVELS:
total += len(
list(
sync_engine.find(
SmallJournal, SmallJournal.level == level, limit=20, skip=20
)
)
)
def test_find_one_by_id(benchmark, sync_engine: SyncEngine, count: int):
instances = list(SmallJournal.get_random_instances("test_write_small", count))
sync_engine.save_all(instances)
ids = [instance.id for instance in instances]
@benchmark
def _():
for id_ in ids:
sync_engine.find_one(SmallJournal, SmallJournal.id == id_)
python-odmantic-1.0.2/tests/integration/conftest.py 0000664 0000000 0000000 00000005770 14613034133 0022522 0 ustar 00root root 0000000 0000000 import asyncio
import os
from enum import Enum
from unittest.mock import Mock
from uuid import uuid4
import pytest
from motor.motor_asyncio import AsyncIOMotorClient
from pymongo import MongoClient
from odmantic.engine import AIOEngine, SyncEngine
try:
from unittest.mock import AsyncMock
except ImportError:
from mock import AsyncMock # type: ignore
TEST_MONGO_URI: str = os.getenv("TEST_MONGO_URI", "mongodb://localhost:27017/")
class MongoMode(str, Enum):
REPLICA = "replicaSet"
SHARDED = "sharded"
STANDALONE = "standalone"
DEFAULT = "default"
TEST_MONGO_MODE = MongoMode(os.getenv("TEST_MONGO_MODE", "default"))
only_on_replica = pytest.mark.skipif(
TEST_MONGO_MODE != MongoMode.REPLICA,
reason="Test transactions only with replicas/shards, as it's only supported there",
)
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop_policy().new_event_loop()
asyncio.set_event_loop(loop)
yield loop
loop.close()
@pytest.fixture(scope="session")
def motor_client(event_loop):
mongo_uri = TEST_MONGO_URI
client = AsyncIOMotorClient(mongo_uri, io_loop=event_loop)
yield client
client.close()
@pytest.fixture(scope="session")
def pymongo_client():
mongo_uri = TEST_MONGO_URI
client: MongoClient = MongoClient(mongo_uri)
yield client
client.close()
@pytest.fixture(scope="function")
def database_name():
return f"odmantic-test-{uuid4()}"
@pytest.mark.asyncio
@pytest.fixture(scope="function")
async def aio_engine(motor_client: AsyncIOMotorClient, database_name: str):
sess = AIOEngine(motor_client, database_name)
yield sess
if os.getenv("TEST_DEBUG") is None:
await motor_client.drop_database(database_name)
else:
print(f"Database {database_name} not dropped")
@pytest.fixture(scope="function")
def sync_engine(pymongo_client: MongoClient, database_name: str):
sess = SyncEngine(pymongo_client, database_name)
yield sess
if os.getenv("TEST_DEBUG") is None:
pymongo_client.drop_database(database_name)
@pytest.fixture(scope="function")
def motor_database(database_name: str, motor_client: AsyncIOMotorClient):
return motor_client[database_name]
@pytest.fixture(scope="function")
def pymongo_database(database_name: str, pymongo_client: MongoClient):
return pymongo_client[database_name]
@pytest.fixture(scope="function")
def aio_mock_collection(aio_engine: AIOEngine, monkeypatch):
def f():
collection = Mock()
collection.update_one = AsyncMock()
collection.aggregate = AsyncMock()
monkeypatch.setattr(aio_engine, "get_collection", lambda _: collection)
return collection
return f
@pytest.fixture(scope="function")
def sync_mock_collection(sync_engine: SyncEngine, monkeypatch):
def f():
collection = Mock()
collection.update_one = Mock()
collection.aggregate = Mock()
monkeypatch.setattr(sync_engine, "get_collection", lambda _: collection)
return collection
return f
python-odmantic-1.0.2/tests/integration/fastapi/ 0000775 0000000 0000000 00000000000 14613034133 0021741 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/tests/integration/fastapi/__init__.py 0000664 0000000 0000000 00000000000 14613034133 0024040 0 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/tests/integration/fastapi/conftest.py 0000664 0000000 0000000 00000000341 14613034133 0024136 0 ustar 00root root 0000000 0000000 import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
@pytest.fixture
def fastapi_app():
return FastAPI()
@pytest.fixture
def test_client(fastapi_app):
return TestClient(fastapi_app)
python-odmantic-1.0.2/tests/integration/fastapi/test_doc_example.py 0000664 0000000 0000000 00000006565 14613034133 0025646 0 ustar 00root root 0000000 0000000 from typing import Dict
from unittest.mock import patch
import pytest
from async_asgi_testclient import TestClient
from docs.examples_src.usage_fastapi.base_example import Tree
from odmantic.engine import AIOEngine
pytestmark = pytest.mark.asyncio
@pytest.fixture
async def base_example_client(aio_engine: AIOEngine) -> TestClient:
with patch("docs.examples_src.usage_fastapi.base_example.engine", aio_engine):
from docs.examples_src.usage_fastapi.base_example import app
async with TestClient(app) as client:
yield client
EXAMPLE_TREE_BODY = dict(name="MyTree", average_size=152, discovery_year=1992)
async def test_create_tree(base_example_client: TestClient, aio_engine: AIOEngine):
response = await base_example_client.put("/trees/", json=EXAMPLE_TREE_BODY)
assert response.status_code == 200
assert await aio_engine.find_one(Tree, EXAMPLE_TREE_BODY) is not None
def is_sub_dict(a: Dict, b: Dict) -> bool:
return set(a.items()).issubset(set(b.items()))
@pytest.mark.parametrize("count", [2, 10])
async def test_create_trees_count_get(
base_example_client: TestClient, aio_engine: AIOEngine, count: int
):
for _ in range(count):
response = await base_example_client.put("/trees/", json=EXAMPLE_TREE_BODY)
assert response.status_code == 200
assert await aio_engine.count(Tree) == count
async for tree in aio_engine.find(Tree):
assert is_sub_dict(EXAMPLE_TREE_BODY, tree.model_dump())
async def test_get_tree_by_id(base_example_client: TestClient, aio_engine: AIOEngine):
tree = Tree(**EXAMPLE_TREE_BODY)
await aio_engine.save(tree)
response = await base_example_client.get(
f"/trees/{tree.id}", json=EXAMPLE_TREE_BODY
)
assert response.status_code == 200
assert Tree(**response.json()) == tree
@pytest.fixture
async def example_update_client(aio_engine: AIOEngine) -> TestClient:
with patch("docs.examples_src.usage_fastapi.example_update.engine", aio_engine):
from docs.examples_src.usage_fastapi.example_update import app
async with TestClient(app) as client:
yield client
PATCHED_NAME = "New Tree Name"
async def test_update_tree_name_by_id(
example_update_client: TestClient, aio_engine: AIOEngine
):
tree = Tree(**EXAMPLE_TREE_BODY)
await aio_engine.save(tree)
response = await example_update_client.patch(
f"/trees/{tree.id}", json=dict(name=PATCHED_NAME)
)
assert response.status_code == 200
assert response.json()["name"] == PATCHED_NAME
assert await aio_engine.find_one(Tree, {"name": PATCHED_NAME}) is not None
@pytest.fixture
async def example_delete_client(aio_engine: AIOEngine) -> TestClient:
with patch("docs.examples_src.usage_fastapi.example_delete.engine", aio_engine):
from docs.examples_src.usage_fastapi.example_delete import app
async with TestClient(app) as client:
yield client
async def test_delete_tree_by_id(
example_delete_client: TestClient, aio_engine: AIOEngine
):
tree = Tree(**EXAMPLE_TREE_BODY)
await aio_engine.save(tree)
# Create other trees not affected by the delete to come
for _ in range(10):
await aio_engine.save(Tree(**EXAMPLE_TREE_BODY))
response = await example_delete_client.delete(f"/trees/{tree.id}")
assert response.status_code == 200
assert await aio_engine.find_one(Tree, Tree.id == tree.id) is None
python-odmantic-1.0.2/tests/integration/fastapi/test_models.py 0000664 0000000 0000000 00000012014 14613034133 0024633 0 ustar 00root root 0000000 0000000 from datetime import datetime
from inspect import getdoc
from typing import Type
import pytest
from bson import ObjectId as BSONObjectId
from odmantic import Model, ObjectId, Reference
from odmantic.bson import BaseBSONModel, Binary, Decimal128, Int64, Regex
from odmantic.model import EmbeddedModel
def test_object_id_fastapi_get_query(fastapi_app, test_client):
value_injected = None
@fastapi_app.get("/{id}")
def get(id: ObjectId):
nonlocal value_injected
value_injected = id
return "ok"
id_get_str = "5f79d7e8b305f24ca43593e2"
test_client.get(f"/{id_get_str}")
assert value_injected == BSONObjectId(id_get_str)
def test_object_id_fastapi_get_query_invalid_id(fastapi_app, test_client):
@fastapi_app.get("/{id}")
def get(id: ObjectId):
return "ok" # pragma: no cover
invalid_oid_str = "a"
response = test_client.get(f"/{invalid_oid_str}")
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == [
"path",
"id",
"is-instance[ObjectId]",
]
@pytest.mark.skip("Need to specify custom json_encoder or to use a root_type")
def test_object_id_fastapi_response(fastapi_app, test_client):
id_get_str = "5f79d7e8b305f24ca43593e2"
@fastapi_app.get("/")
def get():
return {"id": ObjectId(id_get_str)}
response = test_client.get("/")
assert response.json() == {"id": id_get_str}
def test_object_id_fastapi_pydantic_response_model(fastapi_app, test_client):
id_get_str = "5f79d7e8b305f24ca43593e2"
class PydanticModel(BaseBSONModel):
id: ObjectId
# Defining a config object WITHOUT json_encoders arguments
model_config = {}
@fastapi_app.get("/", response_model=PydanticModel)
def get():
return {"id": ObjectId(id_get_str)}
response = test_client.get("/")
assert response.json() == {"id": id_get_str}
def test_object_id_fastapi_odmantic_response_pydantic_model(fastapi_app, test_client):
class ODMModel(Model): ...
object = ODMModel()
@fastapi_app.get("/", response_model=ODMModel.__pydantic_model__)
def get():
return object
response = test_client.get("/")
assert response.json() == {"id": str(object.id)}
def test_object_id_fastapi_odmantic_response_model(fastapi_app, test_client):
class ODMModel(Model): ...
object = ODMModel()
@fastapi_app.get("/", response_model=ODMModel)
def get():
return object
response = test_client.get("/")
assert response.json() == {"id": str(object.id)}
def test_openapi_json_with_bson_fields(fastapi_app, test_client):
class ODMModel(Model):
oid: ObjectId
int64: Int64
decimal: Decimal128
binary: Binary
regex: Regex
datetime_: datetime
@fastapi_app.get("/", response_model=ODMModel)
def get():
return None # pragma: no cover
response = test_client.get("/openapi.json")
assert response.status_code == 200
@pytest.mark.parametrize("base", (Model, EmbeddedModel))
def test_docstring_not_nullified(base: Type):
class M(base):
"""My docstring"""
doc = getdoc(M)
assert doc is None or doc == "My docstring"
description = M.model_json_schema()["description"]
assert description == "My docstring"
@pytest.mark.parametrize("base", (Model, EmbeddedModel))
def test_docstring_nullified(base: Type):
class M(base): ...
doc = getdoc(M)
assert doc == ""
assert "description" not in M.model_json_schema()
@pytest.mark.parametrize("base", (Model, EmbeddedModel, BaseBSONModel))
def test_base_classes_docstring_not_nullified(base: Type):
doc = getdoc(base)
assert doc is not None and doc != ""
@pytest.mark.parametrize("base", (Model, EmbeddedModel))
def test_pydantic_model_title(base: Type):
class M(base): ...
assert M.__pydantic_model__.model_json_schema()["title"] == "M"
@pytest.mark.parametrize("base", (Model, EmbeddedModel))
def test_pydantic_model_custom_title(base: Type):
class M(base):
model_config = {"title": "CustomTitle"}
assert M.__pydantic_model__.model_json_schema()["title"] == "CustomTitle"
def test_pydantic_model_references():
class Referenced(Model): ...
class Base(Model):
field: Referenced = Reference()
assert not hasattr(
Base.__pydantic_model__, "field"
), "class attribute should be empty"
assert not issubclass(
Base.__pydantic_model__, Model
), "the pydantic_model should inherit from Model"
b_pure = Base.__pydantic_model__(field=Referenced().__pydantic_model__())
assert not issubclass(
type(b_pure.field), # type: ignore
Model,
), "the pure field should not inherit from Model"
def test_openapi_json_references(fastapi_app, test_client):
class Referenced(Model): ...
class Base(Model):
field: Referenced = Reference()
@fastapi_app.get("/", response_model=Base)
def get():
return None # pragma: no cover
response = test_client.get("/openapi.json")
assert response.status_code == 200
python-odmantic-1.0.2/tests/integration/test_embedded_model.py 0000664 0000000 0000000 00000023023 14613034133 0024634 0 ustar 00root root 0000000 0000000 from typing import Dict, List, Tuple
import pytest
from odmantic.engine import AIOEngine, SyncEngine
from odmantic.field import Field
from odmantic.model import EmbeddedModel, Model
from ..zoo.book_embedded import Book, Publisher
from ..zoo.patron_embedded import Address, Patron
pytestmark = pytest.mark.asyncio
async def test_add_fetch_single(aio_engine: AIOEngine):
publisher = Publisher(name="O'Reilly Media", founded=1980, location="CA")
book = Book(title="MongoDB: The Definitive Guide", pages=216, publisher=publisher)
instance = await aio_engine.save(book)
assert instance.id is not None
assert isinstance(instance.publisher, Publisher)
assert instance.publisher == publisher
fetched_instance = await aio_engine.find_one(Book, Book.id == instance.id)
assert fetched_instance is not None
assert fetched_instance.publisher == publisher
def test_sync_add_fetch_single(sync_engine: SyncEngine):
publisher = Publisher(name="O'Reilly Media", founded=1980, location="CA")
book = Book(title="MongoDB: The Definitive Guide", pages=216, publisher=publisher)
instance = sync_engine.save(book)
assert instance.id is not None
assert isinstance(instance.publisher, Publisher)
assert instance.publisher == publisher
fetched_instance = sync_engine.find_one(Book, Book.id == instance.id)
assert fetched_instance is not None
assert fetched_instance.publisher == publisher
async def test_add_multiple(aio_engine: AIOEngine):
addresses = [
Address(street="81 Lafayette St.", city="Brownsburg", state="IN", zip="46112"),
Address(
street="862 West Euclid St.", city="Indian Trail", state="NC", zip="28079"
),
]
patron = Patron(name="The Princess Royal", addresses=addresses)
instance = await aio_engine.save(patron)
assert instance.id is not None
assert isinstance(instance.addresses, list)
assert instance.addresses == addresses
fetched_instance = await aio_engine.find_one(Patron)
assert fetched_instance is not None
assert fetched_instance.addresses == addresses
def test_sync_add_multiple(sync_engine: SyncEngine):
addresses = [
Address(street="81 Lafayette St.", city="Brownsburg", state="IN", zip="46112"),
Address(
street="862 West Euclid St.", city="Indian Trail", state="NC", zip="28079"
),
]
patron = Patron(name="The Princess Royal", addresses=addresses)
instance = sync_engine.save(patron)
assert instance.id is not None
assert isinstance(instance.addresses, list)
assert instance.addresses == addresses
fetched_instance = sync_engine.find_one(Patron)
assert fetched_instance is not None
assert fetched_instance.addresses == addresses
@pytest.fixture
async def books_with_embedded_publisher(aio_engine: AIOEngine):
publisher_1 = Publisher(name="O'Reilly Media", founded=1980, location="CA")
book_1 = Book(
title="MongoDB: The Definitive Guide", pages=216, publisher=publisher_1
)
publisher_2 = Publisher(name="O'Reilly Media", founded=2020, location="EU")
book_2 = Book(title="MySQL: The Definitive Guide", pages=516, publisher=publisher_2)
return await aio_engine.save_all([book_1, book_2])
async def test_query_filter_on_embedded_doc(
aio_engine: AIOEngine, books_with_embedded_publisher: Tuple[Book, Book]
):
_, book_2 = books_with_embedded_publisher
fetched_instances = await aio_engine.find(Book, Book.publisher == book_2.publisher)
assert len(fetched_instances) == 1
assert fetched_instances[0] == book_2
def test_sync_query_filter_on_embedded_doc(
sync_engine: SyncEngine, books_with_embedded_publisher: Tuple[Book, Book]
):
_, book_2 = books_with_embedded_publisher
fetched_instances = list(sync_engine.find(Book, Book.publisher == book_2.publisher))
assert len(fetched_instances) == 1
assert fetched_instances[0] == book_2
async def test_query_filter_on_embedded_field(
aio_engine: AIOEngine, books_with_embedded_publisher: Tuple[Book, Book]
):
_, book_2 = books_with_embedded_publisher
fetched_instances = await aio_engine.find(Book, Book.publisher.location == "EU")
assert len(fetched_instances) == 1
assert fetched_instances[0] == book_2
def test_sync_query_filter_on_embedded_field(
sync_engine: SyncEngine, books_with_embedded_publisher: Tuple[Book, Book]
):
_, book_2 = books_with_embedded_publisher
fetched_instances = list(sync_engine.find(Book, Book.publisher.location == "EU"))
assert len(fetched_instances) == 1
assert fetched_instances[0] == book_2
async def test_query_filter_on_embedded_nested(aio_engine: AIOEngine):
class ThirdModel(EmbeddedModel):
field: int
class SecondaryModel(EmbeddedModel):
nested_1: ThirdModel
class TopModel(Model):
nested_0: SecondaryModel
instance_0 = TopModel(nested_0=SecondaryModel(nested_1=ThirdModel(field=12)))
instance_1 = TopModel(nested_0=SecondaryModel(nested_1=ThirdModel(field=0)))
await aio_engine.save_all([instance_0, instance_1])
fetched_instances = await aio_engine.find(
TopModel, TopModel.nested_0.nested_1.field == 12
)
assert len(fetched_instances) == 1
assert fetched_instances[0] == instance_0
def test_sync_query_filter_on_embedded_nested(sync_engine: SyncEngine):
class ThirdModel(EmbeddedModel):
field: int
class SecondaryModel(EmbeddedModel):
nested_1: ThirdModel
class TopModel(Model):
nested_0: SecondaryModel
instance_0 = TopModel(nested_0=SecondaryModel(nested_1=ThirdModel(field=12)))
instance_1 = TopModel(nested_0=SecondaryModel(nested_1=ThirdModel(field=0)))
sync_engine.save_all([instance_0, instance_1])
fetched_instances = list(
sync_engine.find(TopModel, TopModel.nested_0.nested_1.field == 12)
)
assert len(fetched_instances) == 1
assert fetched_instances[0] == instance_0
async def test_fields_modified_embedded_model_modification(aio_engine: AIOEngine):
class E(EmbeddedModel):
f: int
class M(Model):
e: E
e = E(f=0)
m = M(e=e)
await aio_engine.save(m)
e.f = 1
await aio_engine.save(m)
fetched = await aio_engine.find_one(M)
assert fetched is not None
assert fetched.e.f == 1
def test_sync_fields_modified_embedded_model_modification(sync_engine: SyncEngine):
class E(EmbeddedModel):
f: int
class M(Model):
e: E
e = E(f=0)
m = M(e=e)
sync_engine.save(m)
e.f = 1
sync_engine.save(m)
fetched = sync_engine.find_one(M)
assert fetched is not None
assert fetched.e.f == 1
async def test_embedded_model_as_primary_field_named_id(aio_engine: AIOEngine):
class Id(EmbeddedModel):
user: int
chat: int
class User(Model):
id: Id = Field(primary_field=True)
user = User(id=Id(user=1, chat=1001))
await aio_engine.save(user)
assert (
await aio_engine.find_one(User, User.id == Id(user=1, chat=1001))
) is not None
async def test_sync_embedded_model_as_primary_field_named_id(sync_engine: SyncEngine):
class Id(EmbeddedModel):
user: int
chat: int
class User(Model):
id: Id = Field(primary_field=True)
user = User(id=Id(user=1, chat=1001))
sync_engine.save(user)
assert sync_engine.find_one(User, User.id == Id(user=1, chat=1001)) is not None
async def test_embedded_model_custom_key_name_save_and_fetch(aio_engine: AIOEngine):
class In(EmbeddedModel):
a: int = Field(key_name="in-a")
class Out(Model):
inner: In = Field(key_name="in")
instance = Out(inner=In(a=3))
await aio_engine.save(instance)
fetched = await aio_engine.find_one(Out)
assert instance == fetched
def test_sync_embedded_model_custom_key_name_save_and_fetch(sync_engine: SyncEngine):
class In(EmbeddedModel):
a: int = Field(key_name="in-a")
class Out(Model):
inner: In = Field(key_name="in")
instance = Out(inner=In(a=3))
sync_engine.save(instance)
fetched = sync_engine.find_one(Out)
assert instance == fetched
async def test_embedded_model_list_custom_key_name_save_and_fetch(
aio_engine: AIOEngine,
):
class In(EmbeddedModel):
a: int = Field(key_name="in-a")
class Out(Model):
inner: List[In] = Field(key_name="in")
instance = Out(inner=[In(a=3)])
await aio_engine.save(instance)
fetched = await aio_engine.find_one(Out)
assert instance == fetched
def test_sync_embedded_model_list_custom_key_name_save_and_fetch(
sync_engine: SyncEngine,
):
class In(EmbeddedModel):
a: int = Field(key_name="in-a")
class Out(Model):
inner: List[In] = Field(key_name="in")
instance = Out(inner=[In(a=3)])
sync_engine.save(instance)
fetched = sync_engine.find_one(Out)
assert instance == fetched
async def test_embedded_model_dict_custom_key_name_save_and_fetch(
aio_engine: AIOEngine,
):
class In(EmbeddedModel):
a: int = Field(key_name="in-a")
class Out(Model):
inner: Dict[str, In] = Field(key_name="in")
instance = Out(inner={"key": In(a=3)})
await aio_engine.save(instance)
fetched = await aio_engine.find_one(Out)
assert instance == fetched
def test_sync_embedded_model_dict_custom_key_name_save_and_fetch(
sync_engine: SyncEngine,
):
class In(EmbeddedModel):
a: int = Field(key_name="in-a")
class Out(Model):
inner: Dict[str, In] = Field(key_name="in")
instance = Out(inner={"key": In(a=3)})
sync_engine.save(instance)
fetched = sync_engine.find_one(Out)
assert instance == fetched
python-odmantic-1.0.2/tests/integration/test_engine.py 0000664 0000000 0000000 00000117124 14613034133 0023176 0 ustar 00root root 0000000 0000000 from typing import Dict, List, Optional, Tuple
import pytest
from bson import ObjectId as BsonObjectId
from inline_snapshot import snapshot
from motor.motor_asyncio import AsyncIOMotorClient
from pymongo import MongoClient
from odmantic.bson import ObjectId
from odmantic.engine import AIOEngine, SyncEngine
from odmantic.exceptions import DocumentNotFoundError, DocumentParsingError
from odmantic.field import Field
from odmantic.model import EmbeddedModel, Model
from odmantic.query import asc, desc
from tests.integration.conftest import only_on_replica
from tests.integration.utils import redact_objectid
from tests.zoo.book_reference import Book, Publisher
from ..zoo.person import PersonModel
pytestmark = pytest.mark.asyncio
def test_default_motor_client_creation():
engine = AIOEngine()
assert isinstance(engine.client, AsyncIOMotorClient)
def test_no_motor_raises_for_aioengine_client_creation():
import odmantic.engine
motor = odmantic.engine.motor
odmantic.engine.motor = None
with pytest.raises(RuntimeError) as e:
AIOEngine()
assert 'pip install "odmantic[motor]"' in str(e)
odmantic.engine.motor = motor
def test_default_pymongo_client_creation():
engine = SyncEngine()
assert isinstance(engine.client, MongoClient)
def test_no_motor_passes_with_syncengine_client_creation():
import odmantic.engine
motor = odmantic.engine.motor
odmantic.engine.motor = None
engine = SyncEngine()
assert isinstance(engine.client, MongoClient)
odmantic.engine.motor = motor
@pytest.mark.parametrize("illegal_character", ("/", "\\", ".", '"', "$"))
def test_invalid_database_name(illegal_character: str):
with pytest.raises(ValueError, match="database name cannot contain"):
AIOEngine(database=f"prefix{illegal_character}suffix")
with pytest.raises(ValueError, match="database name cannot contain"):
SyncEngine(database=f"prefix{illegal_character}suffix")
async def test_save(aio_engine: AIOEngine):
instance = await aio_engine.save(
PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
)
assert isinstance(instance.id, BsonObjectId)
def test_sync_save(sync_engine: SyncEngine):
instance = sync_engine.save(
PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
)
assert isinstance(instance.id, BsonObjectId)
async def test_save_find_find_one(aio_engine: AIOEngine):
initial_instance = PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
await aio_engine.save(initial_instance)
found_instances = await aio_engine.find(PersonModel)
assert len(found_instances) == 1
assert found_instances[0].first_name == initial_instance.first_name
assert found_instances[0].last_name == initial_instance.last_name
single_fetched_instance = await aio_engine.find_one(
PersonModel, PersonModel.first_name == "Jean-Pierre"
)
assert single_fetched_instance is not None
assert single_fetched_instance.first_name == initial_instance.first_name
assert single_fetched_instance.last_name == initial_instance.last_name
def test_sync_save_find_find_one(sync_engine: SyncEngine):
initial_instance = PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
sync_engine.save(initial_instance)
found_instances = list(sync_engine.find(PersonModel))
assert len(found_instances) == 1
assert found_instances[0].first_name == initial_instance.first_name
assert found_instances[0].last_name == initial_instance.last_name
single_fetched_instance = sync_engine.find_one(
PersonModel, PersonModel.first_name == "Jean-Pierre"
)
assert single_fetched_instance is not None
assert single_fetched_instance.first_name == initial_instance.first_name
assert single_fetched_instance.last_name == initial_instance.last_name
async def test_find_one_not_existing(aio_engine: AIOEngine):
fetched = await aio_engine.find_one(PersonModel)
assert fetched is None
def test_sync_find_one_not_existing(sync_engine: SyncEngine):
fetched = sync_engine.find_one(PersonModel)
assert fetched is None
@pytest.fixture(scope="function")
async def person_persisted(aio_engine: AIOEngine):
initial_instances = [
PersonModel(first_name="Jean-Pierre", last_name="Pernaud"),
PersonModel(first_name="Jean-Pierre", last_name="Castaldi"),
PersonModel(first_name="Michel", last_name="Drucker"),
]
return await aio_engine.save_all(initial_instances)
async def test_save_multiple_simple_find_find_one(
aio_engine: AIOEngine, person_persisted: List[PersonModel]
):
found_instances = await aio_engine.find(
PersonModel, PersonModel.first_name == "Michel"
)
assert len(found_instances) == 1
assert found_instances[0].first_name == person_persisted[2].first_name
assert found_instances[0].last_name == person_persisted[2].last_name
found_instances = await aio_engine.find(
PersonModel, PersonModel.first_name == "Jean-Pierre"
)
assert len(found_instances) == 2
assert found_instances[0].id != found_instances[1].id
single_retrieved = await aio_engine.find_one(
PersonModel, PersonModel.first_name == "Jean-Pierre"
)
assert single_retrieved is not None
assert single_retrieved in person_persisted
def test_sync_save_multiple_simple_find_find_one(
sync_engine: SyncEngine, person_persisted: List[PersonModel]
):
found_instances = list(
sync_engine.find(PersonModel, PersonModel.first_name == "Michel")
)
assert len(found_instances) == 1
assert found_instances[0].first_name == person_persisted[2].first_name
assert found_instances[0].last_name == person_persisted[2].last_name
found_instances = list(
sync_engine.find(PersonModel, PersonModel.first_name == "Jean-Pierre")
)
assert len(found_instances) == 2
assert found_instances[0].id != found_instances[1].id
single_retrieved = sync_engine.find_one(
PersonModel, PersonModel.first_name == "Jean-Pierre"
)
assert single_retrieved is not None
assert single_retrieved in person_persisted
async def test_find_sync_iteration(
aio_engine: AIOEngine, person_persisted: List[PersonModel]
):
fetched = set()
for inst in await aio_engine.find(PersonModel):
fetched.add(inst.id)
assert set(i.id for i in person_persisted) == fetched
def test_sync_find_sync_iteration(
sync_engine: SyncEngine, person_persisted: List[PersonModel]
):
fetched = set()
for inst in sync_engine.find(PersonModel):
fetched.add(inst.id)
assert set(i.id for i in person_persisted) == fetched
@pytest.mark.usefixtures("person_persisted")
async def test_find_sync_iteration_cached(aio_engine: AIOEngine, aio_mock_collection):
cursor = aio_engine.find(PersonModel)
initial = await cursor
collection = aio_mock_collection()
cached = await cursor
collection.aggregate.assert_not_awaited()
assert cached == initial
@pytest.mark.usefixtures("person_persisted")
def test_sync_find_sync_iteration_cached(sync_engine: SyncEngine, sync_mock_collection):
cursor = sync_engine.find(PersonModel)
initial = list(cursor)
collection = sync_mock_collection()
cached = list(cursor)
collection.aggregate.assert_not_called()
assert cached == initial
@pytest.mark.usefixtures("person_persisted")
async def test_find_async_iteration_cached(aio_engine: AIOEngine, aio_mock_collection):
cursor = aio_engine.find(PersonModel)
initial = []
async for inst in cursor:
initial.append(inst)
collection = aio_mock_collection()
cached = []
async for inst in cursor:
cached.append(inst)
collection.aggregate.assert_not_awaited()
assert cached == initial
@pytest.mark.usefixtures("person_persisted")
def test_sync_find_async_iteration_cached(
sync_engine: SyncEngine, sync_mock_collection
):
cursor = sync_engine.find(PersonModel)
initial = []
for inst in cursor:
initial.append(inst)
collection = sync_mock_collection()
cached = []
for inst in cursor:
cached.append(inst)
collection.aggregate.assert_not_called()
assert cached == initial
async def test_find_skip(aio_engine: AIOEngine, person_persisted: List[PersonModel]):
results = await aio_engine.find(PersonModel, skip=1)
assert len(results) == 2
for instance in results:
assert instance in person_persisted
def test_sync_find_skip(sync_engine: SyncEngine, person_persisted: List[PersonModel]):
results = list(sync_engine.find(PersonModel, skip=1))
assert len(results) == 2
for instance in results:
assert instance in person_persisted
async def test_find_one_bad_query(aio_engine: AIOEngine):
with pytest.raises(TypeError):
await aio_engine.find_one(PersonModel, True, False)
def test_sync_find_one_bad_query(sync_engine: SyncEngine):
with pytest.raises(TypeError):
sync_engine.find_one(PersonModel, True, False)
async def test_find_one_on_non_model(aio_engine: AIOEngine):
class BadModel:
pass
with pytest.raises(TypeError):
await aio_engine.find_one(BadModel) # type: ignore
def test_sync_find_one_on_non_model(sync_engine: SyncEngine):
class BadModel:
pass
with pytest.raises(TypeError):
sync_engine.find_one(BadModel) # type: ignore
async def test_find_invalid_limit(aio_engine: AIOEngine):
with pytest.raises(ValueError):
await aio_engine.find(PersonModel, limit=0)
with pytest.raises(ValueError):
await aio_engine.find(PersonModel, limit=-12)
def test_sync_find_invalid_limit(sync_engine: SyncEngine):
with pytest.raises(ValueError):
sync_engine.find(PersonModel, limit=0)
with pytest.raises(ValueError):
sync_engine.find(PersonModel, limit=-12)
async def test_find_invalid_skip(aio_engine: AIOEngine):
with pytest.raises(ValueError):
await aio_engine.find(PersonModel, skip=-1)
with pytest.raises(ValueError):
await aio_engine.find(PersonModel, limit=-12)
def test_sync_find_invalid_skip(sync_engine: SyncEngine):
with pytest.raises(ValueError):
sync_engine.find(PersonModel, skip=-1)
with pytest.raises(ValueError):
sync_engine.find(PersonModel, limit=-12)
@pytest.mark.usefixtures("person_persisted")
async def test_skip(aio_engine: AIOEngine):
p = await aio_engine.find(PersonModel, skip=1)
assert len(p) == 2
@pytest.mark.usefixtures("person_persisted")
def test_sync_skip(sync_engine: SyncEngine):
p = list(sync_engine.find(PersonModel, skip=1))
assert len(p) == 2
@pytest.mark.usefixtures("person_persisted")
async def test_limit(aio_engine: AIOEngine):
p = await aio_engine.find(PersonModel, limit=1)
assert len(p) == 1
@pytest.mark.usefixtures("person_persisted")
def test_sync_limit(sync_engine: SyncEngine):
p = list(sync_engine.find(PersonModel, limit=1))
assert len(p) == 1
@pytest.mark.usefixtures("person_persisted")
async def test_skip_limit(aio_engine: AIOEngine):
p = await aio_engine.find(PersonModel, skip=1, limit=1)
assert len(p) == 1
@pytest.mark.usefixtures("person_persisted")
def test_sync_skip_limit(sync_engine: SyncEngine):
p = list(sync_engine.find(PersonModel, skip=1, limit=1))
assert len(p) == 1
async def test_save_multiple_time_same_document(aio_engine: AIOEngine):
fixed_id = ObjectId()
instance = PersonModel(first_name="Jean-Pierre", last_name="Pernaud", id=fixed_id)
await aio_engine.save(instance)
instance = PersonModel(first_name="Jean-Pierre", last_name="Pernaud", id=fixed_id)
await aio_engine.save(instance)
assert await aio_engine.count(PersonModel, PersonModel.id == fixed_id) == 1
def test_sync_save_multiple_time_same_document(sync_engine: SyncEngine):
fixed_id = ObjectId()
instance = PersonModel(first_name="Jean-Pierre", last_name="Pernaud", id=fixed_id)
sync_engine.save(instance)
instance = PersonModel(first_name="Jean-Pierre", last_name="Pernaud", id=fixed_id)
sync_engine.save(instance)
assert sync_engine.count(PersonModel, PersonModel.id == fixed_id) == 1
@pytest.mark.usefixtures("person_persisted")
async def test_count(aio_engine: AIOEngine):
count = await aio_engine.count(PersonModel)
assert count == 3
count = await aio_engine.count(PersonModel, PersonModel.first_name == "Michel")
assert count == 1
count = await aio_engine.count(PersonModel, PersonModel.first_name == "Gérard")
assert count == 0
@pytest.mark.usefixtures("person_persisted")
def test_sync_count(sync_engine: SyncEngine):
count = sync_engine.count(PersonModel)
assert count == 3
count = sync_engine.count(PersonModel, PersonModel.first_name == "Michel")
assert count == 1
count = sync_engine.count(PersonModel, PersonModel.first_name == "Gérard")
assert count == 0
async def test_count_on_non_model_fails(aio_engine: AIOEngine):
class BadModel:
pass
with pytest.raises(TypeError):
await aio_engine.count(BadModel) # type: ignore
def test_sync_count_on_non_model_fails(sync_engine: SyncEngine):
class BadModel:
pass
with pytest.raises(TypeError):
sync_engine.count(BadModel) # type: ignore
async def test_find_on_embedded_raises(aio_engine: AIOEngine):
class BadModel(EmbeddedModel):
field: int
with pytest.raises(TypeError):
await aio_engine.find(BadModel) # type: ignore
def test_sync_find_on_embedded_raises(sync_engine: SyncEngine):
class BadModel(EmbeddedModel):
field: int
with pytest.raises(TypeError):
sync_engine.find(BadModel) # type: ignore
async def test_save_on_embedded(aio_engine: AIOEngine):
class BadModel(EmbeddedModel):
field: int
instance = BadModel(field=12)
with pytest.raises(TypeError):
await aio_engine.save(instance) # type: ignore
def test_sync_save_on_embedded(sync_engine: SyncEngine):
class BadModel(EmbeddedModel):
field: int
instance = BadModel(field=12)
with pytest.raises(TypeError):
sync_engine.save(instance) # type: ignore
@pytest.mark.usefixtures("person_persisted")
async def test_implicit_and(aio_engine: AIOEngine):
count = await aio_engine.count(
PersonModel,
PersonModel.first_name == "Michel",
PersonModel.last_name == "Drucker",
)
assert count == 1
@pytest.mark.usefixtures("person_persisted")
def test_sync_implicit_and(sync_engine: SyncEngine):
count = sync_engine.count(
PersonModel,
PersonModel.first_name == "Michel",
PersonModel.last_name == "Drucker",
)
assert count == 1
async def test_save_update(aio_engine: AIOEngine):
instance = PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
await aio_engine.save(instance)
assert await aio_engine.count(PersonModel, PersonModel.last_name == "Pernaud") == 1
instance.last_name = "Dupuis"
await aio_engine.save(instance)
assert await aio_engine.count(PersonModel, PersonModel.last_name == "Pernaud") == 0
assert await aio_engine.count(PersonModel, PersonModel.last_name == "Dupuis") == 1
def test_sync_save_update(sync_engine: SyncEngine):
instance = PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
sync_engine.save(instance)
assert sync_engine.count(PersonModel, PersonModel.last_name == "Pernaud") == 1
instance.last_name = "Dupuis"
sync_engine.save(instance)
assert sync_engine.count(PersonModel, PersonModel.last_name == "Pernaud") == 0
assert sync_engine.count(PersonModel, PersonModel.last_name == "Dupuis") == 1
async def test_delete_and_count(
aio_engine: AIOEngine, person_persisted: List[PersonModel]
):
await aio_engine.delete(person_persisted[0])
assert await aio_engine.count(PersonModel) == 2
await aio_engine.delete(person_persisted[1])
assert await aio_engine.count(PersonModel) == 1
await aio_engine.delete(person_persisted[2])
assert await aio_engine.count(PersonModel) == 0
def test_sync_delete_and_count(
sync_engine: SyncEngine, person_persisted: List[PersonModel]
):
sync_engine.delete(person_persisted[0])
assert sync_engine.count(PersonModel) == 2
sync_engine.delete(person_persisted[1])
assert sync_engine.count(PersonModel) == 1
sync_engine.delete(person_persisted[2])
assert sync_engine.count(PersonModel) == 0
async def test_delete_not_existing(aio_engine: AIOEngine):
non_persisted_instance = PersonModel(first_name="Jean", last_name="Paul")
with pytest.raises(DocumentNotFoundError) as exc:
await aio_engine.delete(non_persisted_instance)
assert exc.value.instance == non_persisted_instance
def test_sync_delete_not_existing(sync_engine: SyncEngine):
non_persisted_instance = PersonModel(first_name="Jean", last_name="Paul")
with pytest.raises(DocumentNotFoundError) as exc:
sync_engine.delete(non_persisted_instance)
assert exc.value.instance == non_persisted_instance
@pytest.mark.usefixtures("person_persisted")
async def test_remove_and_count(aio_engine: AIOEngine):
actual_delete_count = await aio_engine.remove(
PersonModel, PersonModel.first_name == "Jean-Pierre"
)
assert actual_delete_count == 2
assert await aio_engine.count(PersonModel) == 1
@pytest.mark.usefixtures("person_persisted")
def test_sync_remove_and_count(sync_engine: SyncEngine):
actual_delete_count = sync_engine.remove(
PersonModel, PersonModel.first_name == "Jean-Pierre"
)
assert actual_delete_count == 2
assert sync_engine.count(PersonModel) == 1
@pytest.mark.usefixtures("person_persisted")
async def test_remove_just_one(aio_engine: AIOEngine):
actual_delete_count = await aio_engine.remove(
PersonModel, PersonModel.first_name == "Jean-Pierre", just_one=True
)
assert actual_delete_count == 1
assert await aio_engine.count(PersonModel) == 2
@pytest.mark.usefixtures("person_persisted")
def test_sync_remove_just_one(sync_engine: SyncEngine):
actual_delete_count = sync_engine.remove(
PersonModel, PersonModel.first_name == "Jean-Pierre", just_one=True
)
assert actual_delete_count == 1
assert sync_engine.count(PersonModel) == 2
@only_on_replica
@pytest.mark.usefixtures("person_persisted")
async def test_remove_just_one_transaction(aio_engine: AIOEngine):
async with await aio_engine.client.start_session() as session:
async with session.start_transaction():
actual_delete_count = await aio_engine.remove(
PersonModel,
PersonModel.first_name == "Jean-Pierre",
just_one=True,
session=session,
)
assert actual_delete_count == 1
assert await aio_engine.count(PersonModel) == 2
@only_on_replica
@pytest.mark.usefixtures("person_persisted")
def test_sync_remove_just_one_transaction(sync_engine: SyncEngine):
with sync_engine.client.start_session() as session:
with session.start_transaction():
actual_delete_count = sync_engine.remove(
PersonModel,
PersonModel.first_name == "Jean-Pierre",
just_one=True,
session=session,
)
assert actual_delete_count == 1
assert sync_engine.count(PersonModel) == 2
@only_on_replica
@pytest.mark.usefixtures("person_persisted")
async def test_remove_transaction_failure(aio_engine: AIOEngine):
with pytest.raises(Exception):
async with await aio_engine.client.start_session() as session:
async with session.start_transaction():
await aio_engine.remove(
PersonModel,
PersonModel.first_name == "Jean-Pierre",
session=session,
)
raise Exception("oops")
assert await aio_engine.count(PersonModel) == 3 # type: ignore # (unreachable)
@only_on_replica
@pytest.mark.usefixtures("person_persisted")
def test_sync_remove_transaction_failure(sync_engine: SyncEngine):
with pytest.raises(Exception):
with sync_engine.client.start_session() as session:
with session.start_transaction():
sync_engine.remove(
PersonModel,
PersonModel.first_name == "Jean-Pierre",
session=session,
)
raise Exception("oops")
assert sync_engine.count(PersonModel) == 3 # type: ignore # (unreachable)
@pytest.mark.usefixtures("person_persisted")
async def test_remove_not_existing(aio_engine: AIOEngine):
instance = await aio_engine.find_one(
PersonModel, PersonModel.last_name == "NotInDatabase"
)
assert instance is None
deleted_count = await aio_engine.remove(
PersonModel, PersonModel.last_name == "NotInDatabase"
)
assert deleted_count == 0
@pytest.mark.usefixtures("person_persisted")
def test_sync_remove_not_existing(sync_engine: SyncEngine):
instance = sync_engine.find_one(
PersonModel, PersonModel.last_name == "NotInDatabase"
)
assert instance is None
deleted_count = sync_engine.remove(
PersonModel, PersonModel.last_name == "NotInDatabase"
)
assert deleted_count == 0
async def test_modified_fields_cleared_on_document_saved(aio_engine: AIOEngine):
instance = PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
assert len(instance.__fields_modified__) > 0
await aio_engine.save(instance)
assert len(instance.__fields_modified__) == 0
def test_sync_modified_fields_cleared_on_document_saved(sync_engine: SyncEngine):
instance = PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
assert len(instance.__fields_modified__) > 0
sync_engine.save(instance)
assert len(instance.__fields_modified__) == 0
async def test_modified_fields_cleared_on_nested_document_saved(aio_engine: AIOEngine):
hachette = Publisher(name="Hachette Livre", founded=1826, location="FR")
book = Book(title="They Didn't See Us Coming", pages=304, publisher=hachette)
assert len(hachette.__fields_modified__) > 0
await aio_engine.save(book)
assert len(hachette.__fields_modified__) == 0
def test_sync_modified_fields_cleared_on_nested_document_saved(sync_engine: SyncEngine):
hachette = Publisher(name="Hachette Livre", founded=1826, location="FR")
book = Book(title="They Didn't See Us Coming", pages=304, publisher=hachette)
assert len(hachette.__fields_modified__) > 0
sync_engine.save(book)
assert len(hachette.__fields_modified__) == 0
@pytest.fixture()
async def engine_one_person(aio_engine: AIOEngine):
await aio_engine.save(PersonModel(first_name="Jean-Pierre", last_name="Pernaud"))
@pytest.mark.usefixtures("engine_one_person")
async def test_modified_fields_on_find(aio_engine: AIOEngine):
instance = await aio_engine.find_one(PersonModel)
assert instance is not None
assert len(instance.__fields_modified__) == 0
@pytest.mark.usefixtures("engine_one_person")
def test_sync_modified_fields_on_find(sync_engine: SyncEngine):
instance = sync_engine.find_one(PersonModel)
assert instance is not None
assert len(instance.__fields_modified__) == 0
@pytest.mark.usefixtures("engine_one_person")
async def test_modified_fields_on_document_change(aio_engine: AIOEngine):
instance = await aio_engine.find_one(PersonModel)
assert instance is not None
instance.first_name = "Jackie"
assert len(instance.__fields_modified__) == 1
instance.last_name = "Chan"
assert len(instance.__fields_modified__) == 2
@pytest.mark.usefixtures("engine_one_person")
def test_sync_modified_fields_on_document_change(sync_engine: SyncEngine):
instance = sync_engine.find_one(PersonModel)
assert instance is not None
instance.first_name = "Jackie"
assert len(instance.__fields_modified__) == 1
instance.last_name = "Chan"
assert len(instance.__fields_modified__) == 2
@pytest.mark.usefixtures("engine_one_person")
async def test_no_set_on_save_fetched_document(
aio_engine: AIOEngine, sync_mock_collection
):
instance = await aio_engine.find_one(PersonModel)
assert instance is not None
collection = sync_mock_collection()
await aio_engine.save(instance)
collection.update_one.assert_not_called()
@pytest.mark.usefixtures("engine_one_person")
def test_sync_no_set_on_save_fetched_document(
sync_engine: SyncEngine, sync_mock_collection
):
instance = sync_engine.find_one(PersonModel)
assert instance is not None
collection = sync_mock_collection()
sync_engine.save(instance)
collection.update_one.assert_not_called()
@pytest.mark.usefixtures("engine_one_person")
async def test_only_modified_set_on_save(aio_engine: AIOEngine, aio_mock_collection):
instance = await aio_engine.find_one(PersonModel)
assert instance is not None
instance.first_name = "John"
collection = aio_mock_collection()
await aio_engine.save(instance)
collection.update_one.assert_awaited_once()
(_, set_arg), _ = collection.update_one.await_args
assert set_arg == {"$set": {"first_name": "John"}}
@pytest.mark.usefixtures("engine_one_person")
def test_sync_only_modified_set_on_save(sync_engine: SyncEngine, sync_mock_collection):
instance = sync_engine.find_one(PersonModel)
assert instance is not None
instance.first_name = "John"
collection = sync_mock_collection()
sync_engine.save(instance)
collection.update_one.assert_called_once()
(_, set_arg), _ = collection.update_one.call_args
assert set_arg == {"$set": {"first_name": "John"}}
async def test_only_mutable_list_set_on_save(
aio_engine: AIOEngine, aio_mock_collection
):
class M(Model):
field: List[str]
immutable_field: int
instance = M(field=["hello"], immutable_field=12)
await aio_engine.save(instance)
collection = aio_mock_collection()
await aio_engine.save(instance)
collection.update_one.assert_awaited_once()
(_, set_arg), _ = collection.update_one.await_args
set_dict = set_arg["$set"]
assert list(set_dict.keys()) == ["field"]
def test_sync_only_mutable_list_set_on_save(
sync_engine: SyncEngine, sync_mock_collection
):
class M(Model):
field: List[str]
immutable_field: int
instance = M(field=["hello"], immutable_field=12)
sync_engine.save(instance)
collection = sync_mock_collection()
sync_engine.save(instance)
collection.update_one.assert_called_once()
(_, set_arg), _ = collection.update_one.call_args
set_dict = set_arg["$set"]
assert list(set_dict.keys()) == ["field"]
async def test_only_mutable_list_of_embedded_set_on_save(
aio_engine: AIOEngine, aio_mock_collection
):
class E(EmbeddedModel):
a: str
class M(Model):
field: List[E]
instance = M(field=[E(a="hello")])
await aio_engine.save(instance)
collection = aio_mock_collection()
await aio_engine.save(instance)
collection.update_one.assert_awaited_once()
(_, set_arg), _ = collection.update_one.await_args
set_dict = set_arg["$set"]
assert set_dict == {"field": [{"a": "hello"}]}
def test_sync_only_mutable_list_of_embedded_set_on_save(
sync_engine: SyncEngine, sync_mock_collection
):
class E(EmbeddedModel):
a: str
class M(Model):
field: List[E]
instance = M(field=[E(a="hello")])
sync_engine.save(instance)
collection = sync_mock_collection()
sync_engine.save(instance)
collection.update_one.assert_called_once()
(_, set_arg), _ = collection.update_one.call_args
set_dict = set_arg["$set"]
assert set_dict == {"field": [{"a": "hello"}]}
async def test_only_mutable_dict_of_embedded_set_on_save(
aio_engine: AIOEngine, aio_mock_collection
):
class E(EmbeddedModel):
a: str
class M(Model):
field: Dict[str, E]
instance = M(field={"hello": E(a="world")})
await aio_engine.save(instance)
collection = aio_mock_collection()
await aio_engine.save(instance)
collection.update_one.assert_awaited_once()
(_, set_arg), _ = collection.update_one.await_args
set_dict = set_arg["$set"]
assert set_dict == {"field": {"hello": {"a": "world"}}}
def test_sync_only_mutable_dict_of_embedded_set_on_save(
sync_engine: SyncEngine, sync_mock_collection
):
class E(EmbeddedModel):
a: str
class M(Model):
field: Dict[str, E]
instance = M(field={"hello": E(a="world")})
sync_engine.save(instance)
collection = sync_mock_collection()
sync_engine.save(instance)
collection.update_one.assert_called_once()
(_, set_arg), _ = collection.update_one.call_args
set_dict = set_arg["$set"]
assert set_dict == {"field": {"hello": {"a": "world"}}}
async def test_only_tuple_of_embedded_set_on_save(
aio_engine: AIOEngine, aio_mock_collection
):
class E(EmbeddedModel):
a: str
class M(Model):
field: Tuple[E]
instance = M(field=(E(a="world"),))
await aio_engine.save(instance)
collection = aio_mock_collection()
await aio_engine.save(instance)
collection.update_one.assert_awaited_once()
(_, set_arg), _ = collection.update_one.await_args
set_dict = set_arg["$set"]
assert set_dict == {
"field": [
{"a": "world"},
]
}
def test_sync_only_tuple_of_embedded_set_on_save(
sync_engine: SyncEngine, sync_mock_collection
):
class E(EmbeddedModel):
a: str
class M(Model):
field: Tuple[E]
instance = M(field=(E(a="world"),))
sync_engine.save(instance)
collection = sync_mock_collection()
sync_engine.save(instance)
collection.update_one.assert_called_once()
(_, set_arg), _ = collection.update_one.call_args
set_dict = set_arg["$set"]
assert set_dict == {
"field": [
{"a": "world"},
]
}
async def test_find_sort_asc(
aio_engine: AIOEngine, person_persisted: List[PersonModel]
):
results = await aio_engine.find(PersonModel, sort=PersonModel.last_name)
assert results == sorted(person_persisted, key=lambda person: person.last_name)
def test_sync_find_sort_asc(
sync_engine: SyncEngine, person_persisted: List[PersonModel]
):
results = list(sync_engine.find(PersonModel, sort=PersonModel.last_name))
assert results == sorted(person_persisted, key=lambda person: person.last_name)
async def test_find_sort_list(
aio_engine: AIOEngine, person_persisted: List[PersonModel]
):
results = await aio_engine.find(
PersonModel, sort=(PersonModel.first_name, PersonModel.last_name)
)
assert results == sorted(
person_persisted, key=lambda person: (person.first_name, person.last_name)
)
def test_sync_find_sort_list(
sync_engine: SyncEngine, person_persisted: List[PersonModel]
):
results = list(
sync_engine.find(
PersonModel, sort=(PersonModel.first_name, PersonModel.last_name)
)
)
assert results == sorted(
person_persisted, key=lambda person: (person.first_name, person.last_name)
)
async def test_find_sort_wrong_argument(aio_engine: AIOEngine):
with pytest.raises(
TypeError,
match=(
"sort has to be a Model field or "
"asc, desc descriptors or a tuple of these"
),
):
await aio_engine.find(PersonModel, sort="first_name")
def test_sync_find_sort_wrong_argument(sync_engine: SyncEngine):
with pytest.raises(
TypeError,
match=(
"sort has to be a Model field or "
"asc, desc descriptors or a tuple of these"
),
):
sync_engine.find(PersonModel, sort="first_name")
async def test_find_sort_wrong_tuple_argument(aio_engine: AIOEngine):
with pytest.raises(
TypeError,
match="sort elements have to be Model fields or asc, desc descriptors",
):
await aio_engine.find(PersonModel, sort=("first_name",))
def test_sync_find_sort_wrong_tuple_argument(sync_engine: SyncEngine):
with pytest.raises(
TypeError,
match="sort elements have to be Model fields or asc, desc descriptors",
):
sync_engine.find(PersonModel, sort=("first_name",))
async def test_find_sort_desc(
aio_engine: AIOEngine, person_persisted: List[PersonModel]
):
results = await aio_engine.find(
PersonModel,
sort=PersonModel.last_name.desc(), # type: ignore
)
assert results == list(
reversed(sorted(person_persisted, key=lambda person: person.last_name))
)
def test_sync_find_sort_desc(
sync_engine: SyncEngine, person_persisted: List[PersonModel]
):
results = list(
sync_engine.find(PersonModel, sort=PersonModel.last_name.desc()) # type: ignore
)
assert results == list(
reversed(sorted(person_persisted, key=lambda person: person.last_name))
)
async def test_find_sort_asc_function(
aio_engine: AIOEngine, person_persisted: List[PersonModel]
):
results = await aio_engine.find(PersonModel, sort=asc(PersonModel.last_name))
assert results == sorted(person_persisted, key=lambda person: person.last_name)
def test_sync_find_sort_asc_function(
sync_engine: SyncEngine, person_persisted: List[PersonModel]
):
results = list(sync_engine.find(PersonModel, sort=asc(PersonModel.last_name)))
assert results == sorted(person_persisted, key=lambda person: person.last_name)
async def test_find_sort_multiple_descriptors(aio_engine: AIOEngine):
class TestModel(Model):
a: int
b: int
c: int
persisted_models = [
TestModel(a=1, b=2, c=3),
TestModel(a=2, b=2, c=3),
TestModel(a=3, b=3, c=2),
]
await aio_engine.save_all(persisted_models)
results = await aio_engine.find(
TestModel,
sort=(
desc(TestModel.a),
TestModel.b,
TestModel.c.asc(), # type: ignore
),
)
assert results == sorted(
persisted_models,
key=lambda test_model: (-test_model.a, test_model.b, test_model.c),
)
def test_sync_find_sort_multiple_descriptors(sync_engine: SyncEngine):
class TestModel(Model):
a: int
b: int
c: int
persisted_models = [
TestModel(a=1, b=2, c=3),
TestModel(a=2, b=2, c=3),
TestModel(a=3, b=3, c=2),
]
sync_engine.save_all(persisted_models)
results = list(
sync_engine.find(
TestModel,
sort=(
desc(TestModel.a),
TestModel.b,
TestModel.c.asc(), # type: ignore
),
)
)
assert results == sorted(
persisted_models,
key=lambda test_model: (-test_model.a, test_model.b, test_model.c),
)
async def test_sort_embedded_field(aio_engine: AIOEngine):
class E(EmbeddedModel):
field: int
class M(Model):
e: E
instances = [M(e=E(field=0)), M(e=E(field=1)), M(e=E(field=2))]
await aio_engine.save_all(instances)
results = await aio_engine.find(M, sort=desc(M.e.field))
assert results == sorted(instances, key=lambda instance: -instance.e.field)
def test_sync_sort_embedded_field(sync_engine: SyncEngine):
class E(EmbeddedModel):
field: int
class M(Model):
e: E
instances = [M(e=E(field=0)), M(e=E(field=1)), M(e=E(field=2))]
sync_engine.save_all(instances)
results = list(sync_engine.find(M, sort=desc(M.e.field)))
assert results == sorted(instances, key=lambda instance: -instance.e.field)
async def test_find_one_sort(
aio_engine: AIOEngine, person_persisted: List[PersonModel]
):
person = await aio_engine.find_one(PersonModel, sort=PersonModel.last_name)
assert person is not None
assert person.last_name == "Castaldi"
def test_sync_find_one_sort(
sync_engine: SyncEngine, person_persisted: List[PersonModel]
):
person = sync_engine.find_one(PersonModel, sort=PersonModel.last_name)
assert person is not None
assert person.last_name == "Castaldi"
async def test_find_document_field_not_set_with_default(aio_engine: AIOEngine):
class M(Model):
field: Optional[str] = None
await aio_engine.get_collection(M).insert_one({"_id": ObjectId()})
gathered = await aio_engine.find_one(M)
assert gathered is not None
assert gathered.field is None
def test_sync_find_document_field_not_set_with_default(sync_engine: SyncEngine):
class M(Model):
field: Optional[str] = None
sync_engine.get_collection(M).insert_one({"_id": ObjectId()})
gathered = sync_engine.find_one(M)
assert gathered is not None
assert gathered.field is None
async def test_find_document_field_not_set_with_default_field_descriptor(
aio_engine: AIOEngine,
):
class M(Model):
field: str = Field(default="hello world")
await aio_engine.get_collection(M).insert_one({"_id": ObjectId()})
gathered = await aio_engine.find_one(M)
assert gathered is not None
assert gathered.field == "hello world"
def test_sync_find_document_field_not_set_with_default_field_descriptor(
sync_engine: SyncEngine,
):
class M(Model):
field: str = Field(default="hello world")
sync_engine.get_collection(M).insert_one({"_id": ObjectId()})
gathered = sync_engine.find_one(M)
assert gathered is not None
assert gathered.field == "hello world"
async def test_find_document_field_not_set_with_no_default(aio_engine: AIOEngine):
class M(Model):
field: str
oid = ObjectId()
await aio_engine.get_collection(M).insert_one({"_id": oid})
with pytest.raises(DocumentParsingError) as exc_info:
await aio_engine.find_one(M)
assert redact_objectid(str(exc_info.value), oid) == snapshot(
"""\
1 validation error for M
field
Key 'field' not found in document [type=odmantic::key_not_found_in_document, input_value={'_id': ObjectId('')}, input_type=dict]\
""" # noqa: E501
)
def test_sync_find_document_field_not_set_with_no_default(sync_engine: SyncEngine):
class M(Model):
field: str
oid = ObjectId()
sync_engine.get_collection(M).insert_one({"_id": oid})
with pytest.raises(DocumentParsingError) as exc_info:
sync_engine.find_one(M)
assert redact_objectid(str(exc_info.value), oid) == snapshot(
"""\
1 validation error for M
field
Key 'field' not found in document [type=odmantic::key_not_found_in_document, input_value={'_id': ObjectId('')}, input_type=dict]\
""" # noqa: E501
)
async def test_find_document_field_not_set_with_default_factory_disabled(
aio_engine: AIOEngine,
):
class M(Model):
field: str = Field(default_factory=lambda: "hello") # pragma: no cover
await aio_engine.get_collection(M).insert_one({"_id": ObjectId()})
with pytest.raises(DocumentParsingError, match="Key 'field' not found in document"):
await aio_engine.find_one(M)
def test_sync_find_document_field_not_set_with_default_factory_disabled(
sync_engine: SyncEngine,
):
class M(Model):
field: str = Field(default_factory=lambda: "hello") # pragma: no cover
sync_engine.get_collection(M).insert_one({"_id": ObjectId()})
with pytest.raises(DocumentParsingError, match="Key 'field' not found in document"):
sync_engine.find_one(M)
async def test_find_document_field_not_set_with_default_factory_enabled(
aio_engine: AIOEngine,
):
class M(Model):
field: str = Field(default_factory=lambda: "hello")
model_config = {"parse_doc_with_default_factories": True}
await aio_engine.get_collection(M).insert_one({"_id": ObjectId()})
instance = await aio_engine.find_one(M)
assert instance is not None
assert instance.field == "hello"
def test_sync_find_document_field_not_set_with_default_factory_enabled(
sync_engine: SyncEngine,
):
class M(Model):
field: str = Field(default_factory=lambda: "hello")
model_config = {
"parse_doc_with_default_factories": True,
}
sync_engine.get_collection(M).insert_one({"_id": ObjectId()})
instance = sync_engine.find_one(M)
assert instance is not None
assert instance.field == "hello"
python-odmantic-1.0.2/tests/integration/test_engine_reference.py 0000664 0000000 0000000 00000027333 14613034133 0025216 0 ustar 00root root 0000000 0000000 import pytest
from inline_snapshot import snapshot
from odmantic.bson import ObjectId
from odmantic.engine import AIOEngine, SyncEngine
from odmantic.exceptions import DocumentParsingError
from odmantic.model import Model
from odmantic.reference import Reference
from tests.integration.conftest import only_on_replica
from tests.integration.utils import redact_objectid
from tests.zoo.deeply_nested import NestedLevel1, NestedLevel2, NestedLevel3
from ..zoo.book_reference import Book, Publisher
pytestmark = pytest.mark.asyncio
async def test_add_with_references(aio_engine: AIOEngine):
publisher = Publisher(name="O'Reilly Media", founded=1980, location="CA")
book = Book(title="MongoDB: The Definitive Guide", pages=216, publisher=publisher)
instance = await aio_engine.save(book)
fetched_subinstance = await aio_engine.find_one(
Publisher, Publisher.id == instance.publisher.id
)
assert fetched_subinstance == publisher
def test_sync_add_with_references(sync_engine: SyncEngine):
publisher = Publisher(name="O'Reilly Media", founded=1980, location="CA")
book = Book(title="MongoDB: The Definitive Guide", pages=216, publisher=publisher)
instance = sync_engine.save(book)
fetched_subinstance = sync_engine.find_one(
Publisher, Publisher.id == instance.publisher.id
)
assert fetched_subinstance == publisher
# TODO Handle the case where the referenced object already exists
# TODO test add with duplicated reference id
async def test_save_deeply_nested(aio_engine: AIOEngine):
instance = NestedLevel1(next_=NestedLevel2(next_=NestedLevel3()))
await aio_engine.save(instance)
assert await aio_engine.count(NestedLevel3) == 1
assert await aio_engine.count(NestedLevel2) == 1
assert await aio_engine.count(NestedLevel1) == 1
def test_sync_save_deeply_nested(sync_engine: SyncEngine):
instance = NestedLevel1(next_=NestedLevel2(next_=NestedLevel3()))
sync_engine.save(instance)
assert sync_engine.count(NestedLevel3) == 1
assert sync_engine.count(NestedLevel2) == 1
assert sync_engine.count(NestedLevel1) == 1
async def test_update_deeply_nested(aio_engine: AIOEngine):
inst3 = NestedLevel3(
field=0
) # Isolate inst3 to make sure it's not internaly copied
instance = NestedLevel1(next_=NestedLevel2(next_=inst3))
await aio_engine.save(instance)
assert await aio_engine.count(NestedLevel3, NestedLevel3.field == 42) == 0
inst3.field = 42
await aio_engine.save(instance)
assert await aio_engine.count(NestedLevel3, NestedLevel3.field == 42) == 1
def test_sync_update_deeply_nested(sync_engine: SyncEngine):
inst3 = NestedLevel3(
field=0
) # Isolate inst3 to make sure it's not internaly copied
instance = NestedLevel1(next_=NestedLevel2(next_=inst3))
sync_engine.save(instance)
assert sync_engine.count(NestedLevel3, NestedLevel3.field == 42) == 0
inst3.field = 42
sync_engine.save(instance)
assert sync_engine.count(NestedLevel3, NestedLevel3.field == 42) == 1
async def test_save_deeply_nested_and_fetch(aio_engine: AIOEngine):
instance = NestedLevel1(next_=NestedLevel2(next_=NestedLevel3(field=0)))
await aio_engine.save(instance)
fetched = await aio_engine.find_one(NestedLevel1)
assert fetched == instance
def test_sync_save_deeply_nested_and_fetch(sync_engine: SyncEngine):
instance = NestedLevel1(next_=NestedLevel2(next_=NestedLevel3(field=0)))
sync_engine.save(instance)
fetched = sync_engine.find_one(NestedLevel1)
assert fetched == instance
@only_on_replica
async def test_save_deeply_nested_and_fetch_with_transaction(aio_engine: AIOEngine):
# Before MongoDB 4.4 it's necessary to create the collections before trying to use
# them inside a transaction
await aio_engine.database.create_collection(
aio_engine.get_collection(NestedLevel1).name
)
await aio_engine.database.create_collection(
aio_engine.get_collection(NestedLevel2).name
)
await aio_engine.database.create_collection(
aio_engine.get_collection(NestedLevel3).name
)
instance = NestedLevel1(next_=NestedLevel2(next_=NestedLevel3(field=0)))
async with await aio_engine.client.start_session() as session:
async with session.start_transaction():
await aio_engine.save(instance, session=session)
fetched = await aio_engine.find_one(NestedLevel1)
assert fetched == instance
@only_on_replica
def test_sync_save_deeply_nested_and_fetch_with_transaction(sync_engine: SyncEngine):
# Before MongoDB 4.4 it's necessary to create the collections before trying to use
# them inside a transaction
sync_engine.database.create_collection(
sync_engine.get_collection(NestedLevel1).name
)
sync_engine.database.create_collection(
sync_engine.get_collection(NestedLevel2).name
)
sync_engine.database.create_collection(
sync_engine.get_collection(NestedLevel3).name
)
instance = NestedLevel1(next_=NestedLevel2(next_=NestedLevel3(field=0)))
with sync_engine.client.start_session() as session:
with session.start_transaction():
sync_engine.save(instance, session=session)
fetched = sync_engine.find_one(NestedLevel1)
assert fetched == instance
async def test_multiple_save_deeply_nested_and_fetch(aio_engine: AIOEngine):
instances = [
NestedLevel1(field=1, next_=NestedLevel2(field=2, next_=NestedLevel3(field=3))),
NestedLevel1(field=4, next_=NestedLevel2(field=5, next_=NestedLevel3(field=6))),
]
await aio_engine.save_all(instances)
fetched = await aio_engine.find(NestedLevel1)
assert len(fetched) == 2
assert fetched[0] in instances
assert fetched[1] in instances
@only_on_replica
async def test_multiple_save_deeply_nested_and_fetch_with_transaction(
aio_engine: AIOEngine,
):
# Before MongoDB 4.4 it's necessary to create the collections before trying to use
# them inside a transaction
await aio_engine.database.create_collection(
aio_engine.get_collection(NestedLevel1).name
)
await aio_engine.database.create_collection(
aio_engine.get_collection(NestedLevel2).name
)
await aio_engine.database.create_collection(
aio_engine.get_collection(NestedLevel3).name
)
instances = [
NestedLevel1(field=1, next_=NestedLevel2(field=2, next_=NestedLevel3(field=3))),
NestedLevel1(field=4, next_=NestedLevel2(field=5, next_=NestedLevel3(field=6))),
]
async with await aio_engine.client.start_session() as session:
async with session.start_transaction():
await aio_engine.save_all(instances, session=session)
fetched = await aio_engine.find(NestedLevel1)
assert len(fetched) == 2
assert fetched[0] in instances
assert fetched[1] in instances
@only_on_replica
def test_sync_multiple_save_deeply_nested_and_fetch_with_transaction(
sync_engine: SyncEngine,
):
# Before MongoDB 4.4 it's necessary to create the collections before trying to use
# them inside a transaction
sync_engine.database.create_collection(
sync_engine.get_collection(NestedLevel1).name
)
sync_engine.database.create_collection(
sync_engine.get_collection(NestedLevel2).name
)
sync_engine.database.create_collection(
sync_engine.get_collection(NestedLevel3).name
)
instances = [
NestedLevel1(field=1, next_=NestedLevel2(field=2, next_=NestedLevel3(field=3))),
NestedLevel1(field=4, next_=NestedLevel2(field=5, next_=NestedLevel3(field=6))),
]
with sync_engine.client.start_session() as session:
with session.start_transaction():
sync_engine.save_all(instances, session=session)
fetched = list(sync_engine.find(NestedLevel1))
assert len(fetched) == 2
assert fetched[0] in instances
assert fetched[1] in instances
def test_sync_multiple_save_deeply_nested_and_fetch(sync_engine: SyncEngine):
instances = [
NestedLevel1(field=1, next_=NestedLevel2(field=2, next_=NestedLevel3(field=3))),
NestedLevel1(field=4, next_=NestedLevel2(field=5, next_=NestedLevel3(field=6))),
]
sync_engine.save_all(instances)
fetched = list(sync_engine.find(NestedLevel1))
assert len(fetched) == 2
assert fetched[0] in instances
assert fetched[1] in instances
async def test_reference_with_key_name(aio_engine: AIOEngine):
class R(Model):
field: int
class M(Model):
r: R = Reference(key_name="fancy_key_name")
instance = M(r=R(field=3))
assert "fancy_key_name" in instance.model_dump_doc()
await aio_engine.save(instance)
fetched = await aio_engine.find_one(M)
assert fetched is not None
assert fetched.r.field == 3
def test_sync_reference_with_key_name(sync_engine: SyncEngine):
class R(Model):
field: int
class M(Model):
r: R = Reference(key_name="fancy_key_name")
instance = M(r=R(field=3))
assert "fancy_key_name" in instance.model_dump_doc()
sync_engine.save(instance)
fetched = sync_engine.find_one(M)
assert fetched is not None
assert fetched.r.field == 3
async def test_reference_not_set_in_database(aio_engine: AIOEngine):
class R(Model):
field: int
class M(Model):
r: R = Reference()
oid = ObjectId()
await aio_engine.get_collection(M).insert_one({"_id": oid})
with pytest.raises(DocumentParsingError) as exc_info:
await aio_engine.find_one(M)
assert redact_objectid(str(exc_info.value), oid) == snapshot(
"""\
1 validation error for M
r
Referenced document not found for foreign key 'r' [type=odmantic::referenced_document_not_found, input_value={'_id': ObjectId('')}, input_type=dict]\
""" # noqa: E501
)
def test_sync_reference_not_set_in_database(sync_engine: SyncEngine):
class R(Model):
field: int
class M(Model):
r: R = Reference()
oid = ObjectId()
sync_engine.get_collection(M).insert_one({"_id": oid})
with pytest.raises(DocumentParsingError) as exc_info:
sync_engine.find_one(M)
assert redact_objectid(str(exc_info.value), oid) == snapshot(
"""\
1 validation error for M
r
Referenced document not found for foreign key 'r' [type=odmantic::referenced_document_not_found, input_value={'_id': ObjectId('')}, input_type=dict]\
""" # noqa: E501
)
async def test_reference_incorect_reference_structure(aio_engine: AIOEngine):
class R(Model):
field: int
class M(Model):
r: R = Reference()
r = R(field=12)
r_doc = r.model_dump_doc()
del r_doc["field"]
m = M(r=r)
await aio_engine.get_collection(R).insert_one(r_doc)
await aio_engine.get_collection(M).insert_one(m.model_dump_doc())
with pytest.raises(DocumentParsingError) as exc_info:
await aio_engine.find_one(M)
assert redact_objectid(str(exc_info.value), r.id) == snapshot(
"""\
1 validation error for M
r.field
Key 'field' not found in document [type=odmantic::key_not_found_in_document, input_value={'_id': ObjectId('')}, input_type=dict]\
""" # noqa: E501
)
def test_sync_reference_incorect_reference_structure(sync_engine: SyncEngine):
class R(Model):
field: int
class M(Model):
r: R = Reference()
r = R(field=12)
r_doc = r.model_dump_doc()
del r_doc["field"]
m = M(r=r)
sync_engine.get_collection(R).insert_one(r_doc)
sync_engine.get_collection(M).insert_one(m.model_dump_doc())
with pytest.raises(DocumentParsingError) as exc_info:
sync_engine.find_one(M)
assert redact_objectid(str(exc_info.value), r.id) == snapshot(
"""\
1 validation error for M
r.field
Key 'field' not found in document [type=odmantic::key_not_found_in_document, input_value={'_id': ObjectId('')}, input_type=dict]\
""" # noqa: E501
)
python-odmantic-1.0.2/tests/integration/test_index.py 0000664 0000000 0000000 00000022776 14613034133 0023050 0 ustar 00root root 0000000 0000000 import pymongo
import pytest
from odmantic.engine import AIOEngine, SyncEngine
from odmantic.exceptions import DuplicateKeyError
from odmantic.field import Field
from odmantic.index import Index
from odmantic.model import Model
from odmantic.query import asc, desc
pytestmark = pytest.mark.asyncio
async def test_single_field_index_creation(aio_engine: AIOEngine):
class M(Model):
f: int = Field(index=True)
await aio_engine.configure_database([M])
info = await aio_engine.get_collection(M).index_information()
assert (
next(
filter(lambda v: v["key"] == [("f", 1)], info.values()),
None,
)
is not None
)
def test_sync_single_field_index_creation(sync_engine: SyncEngine):
class M(Model):
f: int = Field(index=True)
sync_engine.configure_database([M])
info = sync_engine.get_collection(M).index_information()
assert (
next(
filter(lambda v: v["key"] == [("f", 1)], info.values()),
None,
)
is not None
)
async def test_single_field_index_creation_unique(aio_engine: AIOEngine):
class M(Model):
f: int = Field(unique=True)
await aio_engine.configure_database([M])
info = await aio_engine.get_collection(M).index_information()
assert (
next(
filter(lambda v: v["key"] == [("f", 1)] and v["unique"], info.values()),
None,
)
is not None
)
def test_sync_single_field_index_creation_unique(sync_engine: SyncEngine):
class M(Model):
f: int = Field(unique=True)
sync_engine.configure_database([M])
info = sync_engine.get_collection(M).index_information()
assert (
next(
filter(lambda v: v["key"] == [("f", 1)] and v["unique"], info.values()),
None,
)
is not None
)
async def test_compound_index_with_name(aio_engine: AIOEngine):
class M(Model):
f: int
g: int
model_config = {"indexes": lambda: [Index(asc(M.f), desc(M.g), name="test")]}
await aio_engine.configure_database([M])
info = await aio_engine.get_collection(M).index_information()
assert "test" in info
assert info["test"]["key"] == [("f", 1), ("g", -1)]
def test_sync_compound_index_with_name(sync_engine: SyncEngine):
class M(Model):
f: int
g: int
model_config = {"indexes": lambda: [Index(asc(M.f), desc(M.g), name="test")]}
sync_engine.configure_database([M])
info = sync_engine.get_collection(M).index_information()
assert "test" in info
assert info["test"]["key"] == [("f", 1), ("g", -1)]
async def test_multiple_indexes(aio_engine: AIOEngine):
class M(Model):
f: int
g: int = Field(unique=True)
model_config = {
"indexes": lambda: [Index(asc(M.f)), Index(asc(M.f), desc(M.g))]
}
await aio_engine.configure_database([M])
info = await aio_engine.get_collection(M).index_information()
assert (
next(
filter(lambda v: v["key"] == [("f", 1)], info.values()),
None,
)
is not None
)
assert (
next(
filter(lambda v: v["key"] == [("g", 1)] and v["unique"], info.values()),
None,
)
is not None
)
assert (
next(
filter(lambda v: v["key"] == [("f", 1), ("g", -1)], info.values()),
None,
)
is not None
)
def test_sync_multiple_indexes(sync_engine: SyncEngine):
class M(Model):
f: int
g: int = Field(unique=True)
model_config = {
"indexes": lambda: [Index(asc(M.f)), Index(asc(M.f), desc(M.g))]
}
sync_engine.configure_database([M])
info = sync_engine.get_collection(M).index_information()
assert (
next(
filter(lambda v: v["key"] == [("f", 1)], info.values()),
None,
)
is not None
)
assert (
next(
filter(lambda v: v["key"] == [("g", 1)] and v["unique"], info.values()),
None,
)
is not None
)
assert (
next(
filter(lambda v: v["key"] == [("f", 1), ("g", -1)], info.values()),
None,
)
is not None
)
async def test_unique_index_duplicate_save(aio_engine: AIOEngine):
class M(Model):
f: int = Field(unique=True)
await aio_engine.configure_database([M])
await aio_engine.save(M(f=1))
duplicated_instance = M(f=1)
with pytest.raises(DuplicateKeyError) as e:
await aio_engine.save(duplicated_instance)
assert e.value.instance == duplicated_instance
def test_sync_unique_index_duplicate_save(sync_engine: SyncEngine):
class M(Model):
f: int = Field(unique=True)
sync_engine.configure_database([M])
sync_engine.save(M(f=1))
duplicated_instance = M(f=1)
with pytest.raises(DuplicateKeyError) as e:
sync_engine.save(duplicated_instance)
assert e.value.instance == duplicated_instance
async def test_double_index_creation(aio_engine: AIOEngine):
class M(Model):
f: int = Field(index=True)
await aio_engine.configure_database([M])
await aio_engine.configure_database([M])
info = await aio_engine.get_collection(M).index_information()
assert (
next(
filter(lambda v: v["key"] == [("f", 1)], info.values()),
None,
)
is not None
)
def test_sync_double_index_creation(sync_engine: SyncEngine):
class M(Model):
f: int = Field(index=True)
sync_engine.configure_database([M])
sync_engine.configure_database([M])
info = sync_engine.get_collection(M).index_information()
assert (
next(
filter(lambda v: v["key"] == [("f", 1)], info.values()),
None,
)
is not None
)
async def test_index_update_failure(aio_engine: AIOEngine):
class M(Model):
f: int = Field(unique=True)
model_config = {
"collection": "test",
}
await aio_engine.configure_database([M])
class M2(Model):
f: int = Field(index=True)
model_config = {
"collection": "test",
}
with pytest.raises(pymongo.errors.OperationFailure):
await aio_engine.configure_database([M2])
def test_sync_index_update_failure(sync_engine: SyncEngine):
class M(Model):
f: int = Field(unique=True)
model_config = {
"collection": "test",
}
sync_engine.configure_database([M])
class M2(Model):
f: int = Field(index=True)
model_config = {
"collection": "test",
}
with pytest.raises(pymongo.errors.OperationFailure):
sync_engine.configure_database([M2])
async def test_index_replacement(aio_engine: AIOEngine):
class M(Model):
f: int = Field(unique=True)
model_config = {
"collection": "test",
}
await aio_engine.configure_database([M])
info = await aio_engine.get_collection(M).index_information()
assert (
next(
filter(lambda v: v["key"] == [("f", 1)] and v["unique"], info.values()),
None,
)
is not None
)
class M2(Model):
f: int = Field(index=True)
model_config = {
"collection": "test",
}
await aio_engine.configure_database([M2], update_existing_indexes=True)
info = await aio_engine.get_collection(M).index_information()
assert (
next(
filter(
lambda v: v["key"] == [("f", 1)] and "unique" not in v, info.values()
),
None,
)
is not None
)
def test_sync_index_replacement(sync_engine: SyncEngine):
class M(Model):
f: int = Field(unique=True)
model_config = {
"collection": "test",
}
sync_engine.configure_database([M])
info = sync_engine.get_collection(M).index_information()
assert (
next(
filter(lambda v: v["key"] == [("f", 1)] and v["unique"], info.values()),
None,
)
is not None
)
class M2(Model):
f: int = Field(index=True)
model_config = {
"collection": "test",
}
sync_engine.configure_database([M2], update_existing_indexes=True)
info = sync_engine.get_collection(M).index_information()
assert (
next(
filter(
lambda v: v["key"] == [("f", 1)] and "unique" not in v, info.values()
),
None,
)
is not None
)
async def test_custom_text_index(aio_engine: AIOEngine):
class Post(Model):
title: str
content: str
model_config = {
"indexes": lambda: [
pymongo.IndexModel([("title", pymongo.TEXT), ("content", pymongo.TEXT)])
]
}
await aio_engine.configure_database([Post])
await aio_engine.save(Post(title="My post on python", content="It's awesome!"))
assert await aio_engine.find_one(Post, {"$text": {"$search": "python"}}) is not None
async def test_sync_custom_text_index(sync_engine: SyncEngine):
class Post(Model):
title: str
content: str
model_config = {
"indexes": lambda: [
pymongo.IndexModel([("title", pymongo.TEXT), ("content", pymongo.TEXT)])
]
}
sync_engine.configure_database([Post])
sync_engine.save(Post(title="My post on python", content="It's awesome!"))
assert sync_engine.find_one(Post, {"$text": {"$search": "python"}}) is not None
python-odmantic-1.0.2/tests/integration/test_query.py 0000664 0000000 0000000 00000024633 14613034133 0023100 0 ustar 00root root 0000000 0000000 import re
from typing import cast
import pytest
from odmantic import Model
from odmantic.engine import AIOEngine, SyncEngine
from odmantic.query import (
QueryExpression,
and_,
eq,
gt,
gte,
in_,
lt,
lte,
match,
ne,
nor_,
not_in,
or_,
)
from ..zoo.person import PersonModel
pytestmark = pytest.mark.asyncio
@pytest.fixture(scope="function")
async def person_persisted(aio_engine: AIOEngine):
initial_instances = [
PersonModel(first_name="Jean-Pierre", last_name="Pernaud"),
PersonModel(first_name="Jean-Pierre", last_name="Castaldi"),
PersonModel(first_name="Michel", last_name="Drucker"),
]
return await aio_engine.save_all(initial_instances)
@pytest.mark.usefixtures("person_persisted")
async def test_and(aio_engine: AIOEngine):
query = (PersonModel.first_name == "Michel") & (PersonModel.last_name == "Drucker")
assert query == and_(
PersonModel.first_name == "Michel", PersonModel.last_name == "Drucker"
)
count = await aio_engine.count(PersonModel, query)
assert count == 1
@pytest.mark.usefixtures("person_persisted")
def test_sync_and(sync_engine: SyncEngine):
query = (PersonModel.first_name == "Michel") & (PersonModel.last_name == "Drucker")
assert query == and_(
PersonModel.first_name == "Michel", PersonModel.last_name == "Drucker"
)
count = sync_engine.count(PersonModel, query)
assert count == 1
@pytest.mark.usefixtures("person_persisted")
async def test_or(aio_engine: AIOEngine):
query = (PersonModel.first_name == "Michel") | (PersonModel.last_name == "Castaldi")
assert query == or_(
PersonModel.first_name == "Michel", PersonModel.last_name == "Castaldi"
)
count = await aio_engine.count(PersonModel, query)
assert count == 2
@pytest.mark.usefixtures("person_persisted")
def test_sync_or(sync_engine: SyncEngine):
query = (PersonModel.first_name == "Michel") | (PersonModel.last_name == "Castaldi")
assert query == or_(
PersonModel.first_name == "Michel", PersonModel.last_name == "Castaldi"
)
count = sync_engine.count(PersonModel, query)
assert count == 2
@pytest.mark.usefixtures("person_persisted")
async def test_nor(aio_engine: AIOEngine):
count = await aio_engine.count(
PersonModel,
nor_(PersonModel.first_name == "Michel", PersonModel.last_name == "Castaldi"),
)
assert count == 1
@pytest.mark.usefixtures("person_persisted")
async def test_eq(aio_engine: AIOEngine):
query = cast(QueryExpression, PersonModel.first_name == "Michel")
assert query == eq(PersonModel.first_name, "Michel")
count = await aio_engine.count(PersonModel, query)
assert count == 1
@pytest.mark.usefixtures("person_persisted")
def test_sync_eq(sync_engine: SyncEngine):
query = cast(QueryExpression, PersonModel.first_name == "Michel")
assert query == eq(PersonModel.first_name, "Michel")
count = sync_engine.count(PersonModel, query)
assert count == 1
@pytest.mark.usefixtures("person_persisted")
async def test_ne(aio_engine: AIOEngine):
query = PersonModel.first_name != "Michel"
assert query == ne(PersonModel.first_name, "Michel")
count = await aio_engine.count(PersonModel, query)
assert count == 2
@pytest.mark.usefixtures("person_persisted")
def test_sync_ne(sync_engine: SyncEngine):
query = PersonModel.first_name != "Michel"
assert query == ne(PersonModel.first_name, "Michel")
count = sync_engine.count(PersonModel, query)
assert count == 2
@pytest.mark.usefixtures("person_persisted")
async def test_in_(aio_engine: AIOEngine):
query = in_(PersonModel.first_name, ["Michel", "Jean-Pierre"])
# TODO allow this with a mypy plugin
assert query == PersonModel.first_name.in_( # type: ignore
["Michel", "Jean-Pierre"]
)
count = await aio_engine.count(PersonModel, query)
assert count == 3
@pytest.mark.usefixtures("person_persisted")
def test_sync_in_(sync_engine: SyncEngine):
query = in_(PersonModel.first_name, ["Michel", "Jean-Pierre"])
# TODO allow this with a mypy plugin
assert query == PersonModel.first_name.in_( # type: ignore
["Michel", "Jean-Pierre"]
)
count = sync_engine.count(PersonModel, query)
assert count == 3
@pytest.mark.usefixtures("person_persisted")
async def test_in__generator(aio_engine: AIOEngine):
query = in_(PersonModel.first_name, ["Michel", "Jean-Pierre"])
# TODO allow this with a mypy plugin
assert query == PersonModel.first_name.in_( # type: ignore
e for e in ["Michel", "Jean-Pierre"]
)
count = await aio_engine.count(PersonModel, query)
assert count == 3
@pytest.mark.usefixtures("person_persisted")
def test_sync_in__generator(sync_engine: SyncEngine):
query = in_(PersonModel.first_name, ["Michel", "Jean-Pierre"])
# TODO allow this with a mypy plugin
assert query == PersonModel.first_name.in_( # type: ignore
e for e in ["Michel", "Jean-Pierre"]
)
count = sync_engine.count(PersonModel, query)
assert count == 3
@pytest.mark.usefixtures("person_persisted")
async def test_not_in(aio_engine: AIOEngine):
query = not_in(PersonModel.first_name, ["Michel", "Jean-Pierre"])
# TODO allow this with a mypy plugin
assert query == PersonModel.first_name.not_in( # type: ignore
["Michel", "Jean-Pierre"]
)
count = await aio_engine.count(PersonModel, query)
assert count == 0
@pytest.mark.usefixtures("person_persisted")
def test_sync_not_in(sync_engine: SyncEngine):
query = not_in(PersonModel.first_name, ["Michel", "Jean-Pierre"])
# TODO allow this with a mypy plugin
assert query == PersonModel.first_name.not_in( # type: ignore
["Michel", "Jean-Pierre"]
)
count = sync_engine.count(PersonModel, query)
assert count == 0
@pytest.mark.usefixtures("person_persisted")
async def test_not_in_generator(aio_engine: AIOEngine):
query = not_in(PersonModel.first_name, ["Michel", "Jean-Pierre"])
# TODO allow this with a mypy plugin
assert query == PersonModel.first_name.not_in( # type: ignore
e for e in ["Michel", "Jean-Pierre"]
)
count = await aio_engine.count(PersonModel, query)
assert count == 0
@pytest.mark.usefixtures("person_persisted")
def test_sync_not_in_generator(sync_engine: SyncEngine):
query = not_in(PersonModel.first_name, ["Michel", "Jean-Pierre"])
# TODO allow this with a mypy plugin
assert query == PersonModel.first_name.not_in( # type: ignore
e for e in ["Michel", "Jean-Pierre"]
)
count = sync_engine.count(PersonModel, query)
assert count == 0
class AgedPerson(Model):
name: str
age: int
@pytest.fixture(scope="function")
async def aged_person_persisted(aio_engine: AIOEngine):
initial_instances = [
AgedPerson(name="Jean-Pierre", age=25),
AgedPerson(name="Jean-Paul", age=40),
AgedPerson(name="Michel", age=70),
]
return await aio_engine.save_all(initial_instances)
@pytest.mark.usefixtures("aged_person_persisted")
async def test_gt(aio_engine: AIOEngine):
query = AgedPerson.age > 40
assert query == AgedPerson.age.gt(40) # type: ignore
assert query == gt(AgedPerson.age, 40)
count = await aio_engine.count(AgedPerson, query)
assert count == 1
@pytest.mark.usefixtures("aged_person_persisted")
def test_sync_gt(sync_engine: SyncEngine):
query = AgedPerson.age > 40
assert query == AgedPerson.age.gt(40) # type: ignore
assert query == gt(AgedPerson.age, 40)
count = sync_engine.count(AgedPerson, query)
assert count == 1
@pytest.mark.usefixtures("aged_person_persisted")
async def test_gte(aio_engine: AIOEngine):
query = AgedPerson.age >= 40
assert query == AgedPerson.age.gte(40) # type: ignore
assert query == gte(AgedPerson.age, 40)
count = await aio_engine.count(AgedPerson, query)
assert count == 2
@pytest.mark.usefixtures("aged_person_persisted")
async def test_lt(aio_engine: AIOEngine):
query = AgedPerson.age < 40
assert query == AgedPerson.age.lt(40) # type: ignore
assert query == lt(AgedPerson.age, 40)
count = await aio_engine.count(AgedPerson, query)
assert count == 1
@pytest.mark.usefixtures("aged_person_persisted")
def test_sync_lt(sync_engine: SyncEngine):
query = AgedPerson.age < 40
assert query == AgedPerson.age.lt(40) # type: ignore
assert query == lt(AgedPerson.age, 40)
count = sync_engine.count(AgedPerson, query)
assert count == 1
@pytest.mark.usefixtures("aged_person_persisted")
async def test_lte(aio_engine: AIOEngine):
query = AgedPerson.age <= 40
assert query == AgedPerson.age.lte(40) # type: ignore
assert query == lte(AgedPerson.age, 40)
count = await aio_engine.count(AgedPerson, query)
assert count == 2
@pytest.mark.usefixtures("aged_person_persisted")
def test_sync_lte(sync_engine: SyncEngine):
query = AgedPerson.age <= 40
assert query == AgedPerson.age.lte(40) # type: ignore
assert query == lte(AgedPerson.age, 40)
count = sync_engine.count(AgedPerson, query)
assert count == 2
@pytest.mark.usefixtures("person_persisted")
async def test_match_pattern_string(aio_engine: AIOEngine):
# TODO allow this with a mypy plugin
query = PersonModel.first_name.match(r"^Jean-.*") # type: ignore
assert query == match(PersonModel.first_name, "^Jean-.*")
count = await aio_engine.count(PersonModel, query)
assert count == 2
@pytest.mark.usefixtures("person_persisted")
def test_sync_match_pattern_string(sync_engine: SyncEngine):
# TODO allow this with a mypy plugin
query = PersonModel.first_name.match(r"^Jean-.*") # type: ignore
assert query == match(PersonModel.first_name, "^Jean-.*")
count = sync_engine.count(PersonModel, query)
assert count == 2
@pytest.mark.usefixtures("person_persisted")
async def test_match_pattern_compiled(aio_engine: AIOEngine):
# TODO allow this with a mypy plugin
r = re.compile(r"^Jean-.*")
query = PersonModel.first_name.match(r) # type: ignore
assert query == match(PersonModel.first_name, r)
count = await aio_engine.count(PersonModel, query)
assert count == 2
@pytest.mark.usefixtures("person_persisted")
def test_sync_match_pattern_compiled(sync_engine: SyncEngine):
# TODO allow this with a mypy plugin
r = re.compile(r"^Jean-.*")
query = PersonModel.first_name.match(r) # type: ignore
assert query == match(PersonModel.first_name, r)
count = sync_engine.count(PersonModel, query)
assert count == 2
python-odmantic-1.0.2/tests/integration/test_session.py 0000664 0000000 0000000 00000034057 14613034133 0023417 0 ustar 00root root 0000000 0000000 import pytest
from odmantic.engine import AIOEngine, SyncEngine
from odmantic.session import AIOTransaction, SyncTransaction
from tests.integration.conftest import only_on_replica
from ..zoo.person import PersonModel
pytestmark = pytest.mark.asyncio
class CustomException(Exception):
pass
async def test_session_exception_propagation(aio_engine: AIOEngine):
with pytest.raises(CustomException):
async with aio_engine.session():
raise CustomException()
def test_sync_session_exception_propagation(sync_engine: SyncEngine):
with pytest.raises(CustomException):
with sync_engine.session():
raise CustomException()
@only_on_replica
async def test_transaction_exception_propagation(aio_engine: AIOEngine):
with pytest.raises(CustomException):
async with aio_engine.transaction():
raise CustomException()
@only_on_replica
def test_sync_transaction_exception_propagation(sync_engine: SyncEngine):
with pytest.raises(CustomException):
with sync_engine.session():
raise CustomException()
async def test_start_session_twice(aio_engine: AIOEngine):
with pytest.raises(RuntimeError, match="Session is already started"):
async with aio_engine.session() as session:
await session.start()
def test_sync_start_session_twice(sync_engine: SyncEngine):
with pytest.raises(RuntimeError, match="Session is already started"):
with sync_engine.session() as session:
session.start()
async def test_end_a_non_started_session(aio_engine: AIOEngine):
with pytest.raises(RuntimeError, match="Session is not started"):
await aio_engine.session().end()
def test_sync_end_a_non_started_session(sync_engine: SyncEngine):
with pytest.raises(RuntimeError, match="Session is not started"):
sync_engine.session().end()
@only_on_replica
async def test_start_transaction_twice(aio_engine: AIOEngine):
with pytest.raises(RuntimeError, match="Transaction already started"):
async with aio_engine.transaction() as transaction:
await transaction.start()
@only_on_replica
def test_sync_start_transaction_twice(sync_engine: SyncEngine):
with pytest.raises(RuntimeError, match="Transaction already started"):
with sync_engine.transaction() as transaction:
transaction.start()
@only_on_replica
async def test_abort_a_non_started_transaction(aio_engine: AIOEngine):
with pytest.raises(RuntimeError, match="Transaction not started"):
await aio_engine.transaction().abort()
@only_on_replica
def test_sync_abort_a_non_started_transaction(sync_engine: SyncEngine):
with pytest.raises(RuntimeError, match="Transaction not started"):
sync_engine.transaction().abort()
@only_on_replica
async def test_commit_a_non_started_transaction(aio_engine: AIOEngine):
with pytest.raises(RuntimeError, match="Transaction not started"):
await aio_engine.transaction().commit()
@only_on_replica
def test_sync_commit_a_non_started_transaction(sync_engine: SyncEngine):
with pytest.raises(RuntimeError, match="Transaction not started"):
sync_engine.transaction().commit()
@only_on_replica
async def test_create_transaction_with_a_non_started_session(aio_engine: AIOEngine):
with pytest.raises(RuntimeError, match="provided session is not started"):
session = aio_engine.session()
AIOTransaction(session)
@only_on_replica
async def test_sync_create_transaction_with_a_non_started_session(
sync_engine: SyncEngine,
):
with pytest.raises(RuntimeError, match="provided session is not started"):
session = sync_engine.session()
SyncTransaction(session)
async def test_operation_on_ended_session_should_fail(aio_engine: AIOEngine):
session = aio_engine.session()
await session.start()
await session.end()
with pytest.raises(RuntimeError, match="session not started"):
await session.save(PersonModel(first_name="Jean-Pierre", last_name="Pernaud"))
def test_sync_operation_on_ended_session_should_fail(sync_engine: SyncEngine):
session = sync_engine.session()
session.start()
session.end()
with pytest.raises(RuntimeError, match="session not started"):
session.save(PersonModel(first_name="Jean-Pierre", last_name="Pernaud"))
@only_on_replica
async def test_operation_on_aborted_transaction_should_fail(aio_engine: AIOEngine):
transaction = aio_engine.transaction()
await transaction.start()
await transaction.abort()
with pytest.raises(RuntimeError, match="transaction not started"):
await transaction.save(
PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
)
@only_on_replica
def test_sync_operation_on_aborted_transaction_should_fail(sync_engine: SyncEngine):
transaction = sync_engine.transaction()
transaction.start()
transaction.abort()
with pytest.raises(RuntimeError, match="transaction not started"):
transaction.save(PersonModel(first_name="Jean-Pierre", last_name="Pernaud"))
@only_on_replica
async def test_operation_on_comitted_transaction_should_fail(aio_engine: AIOEngine):
transaction = aio_engine.transaction()
await transaction.start()
await transaction.commit()
with pytest.raises(RuntimeError, match="transaction not started"):
await transaction.save(
PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
)
@only_on_replica
def test_sync_operation_on_comitted_transaction_should_fail(sync_engine: SyncEngine):
transaction = sync_engine.transaction()
transaction.start()
transaction.commit()
with pytest.raises(RuntimeError, match="transaction not started"):
transaction.save(PersonModel(first_name="Jean-Pierre", last_name="Pernaud"))
async def test_operation_on_exited_context_session_sould_fail(aio_engine: AIOEngine):
async with aio_engine.session() as session:
pass
with pytest.raises(RuntimeError, match="session not started"):
await session.save(PersonModel(first_name="Jean-Pierre", last_name="Pernaud"))
async def test_sync_operation_on_exited_context_session_sould_fail(
sync_engine: SyncEngine,
):
with sync_engine.session() as session:
pass
with pytest.raises(RuntimeError, match="session not started"):
session.save(PersonModel(first_name="Jean-Pierre", last_name="Pernaud"))
@only_on_replica
async def test_operation_on_exited_context_transaction_sould_fail(
aio_engine: AIOEngine,
):
async with aio_engine.transaction() as transaction:
pass
with pytest.raises(RuntimeError, match="transaction not started"):
await transaction.save(
PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
)
@only_on_replica
async def test_sync_operation_on_exited_context_transaction_sould_fail(
sync_engine: SyncEngine,
):
with sync_engine.transaction() as transaction:
pass
with pytest.raises(RuntimeError, match="transaction not started"):
transaction.save(PersonModel(first_name="Jean-Pierre", last_name="Pernaud"))
@only_on_replica
async def test_operation_on_exited_context_aborted_transaction_sould_fail(
aio_engine: AIOEngine,
):
async with aio_engine.transaction() as transaction:
await transaction.abort()
with pytest.raises(RuntimeError, match="transaction not started"):
await transaction.find_one(PersonModel, PersonModel.first_name == "Jean-Pierre")
@only_on_replica
def test_sync_operation_on_exited_context_aborted_transaction_sould_fail(
sync_engine: SyncEngine,
):
with sync_engine.transaction() as transaction:
transaction.abort()
with pytest.raises(RuntimeError, match="transaction not started"):
transaction.find_one(PersonModel, PersonModel.first_name == "Jean-Pierre")
@only_on_replica
async def test_session_stopped_on_manual_transaction_commit(
aio_engine: AIOEngine,
):
transaction = aio_engine.transaction()
await transaction.start()
await transaction.commit()
assert not transaction.session.is_started
@only_on_replica
def test_sync_session_stopped_on_manual_transaction_commit(
sync_engine: SyncEngine,
):
transaction = sync_engine.transaction()
transaction.start()
transaction.commit()
assert not transaction.session.is_started
@only_on_replica
async def test_session_stopped_on_manual_transaction_abort(
aio_engine: AIOEngine,
):
transaction = aio_engine.transaction()
await transaction.start()
await transaction.abort()
assert not transaction.session.is_started
@only_on_replica
def test_sync_session_stopped_on_manual_transaction_abort(
sync_engine: SyncEngine,
):
transaction = sync_engine.transaction()
transaction.start()
transaction.abort()
assert not transaction.session.is_started
@only_on_replica
async def test_abort_transaction_keep_provided_session_opened(aio_engine: AIOEngine):
async with aio_engine.session() as session:
async with session.transaction() as transaction:
await transaction.abort()
assert session.is_started
@only_on_replica
def test_sync_abort_transaction_keep_provided_session_opened(sync_engine: SyncEngine):
with sync_engine.session() as session:
with session.transaction() as transaction:
transaction.abort()
assert session.is_started
async def test_save_find_find_one_session(aio_engine: AIOEngine):
initial_instance = PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
async with aio_engine.session() as session:
await session.save(initial_instance)
found_instances = await session.find(PersonModel)
assert len(found_instances) == 1
assert found_instances[0].first_name == initial_instance.first_name
assert found_instances[0].last_name == initial_instance.last_name
single_fetched_instance = await session.find_one(
PersonModel, PersonModel.first_name == "Jean-Pierre"
)
assert single_fetched_instance is not None
assert single_fetched_instance.first_name == initial_instance.first_name
assert single_fetched_instance.last_name == initial_instance.last_name
def test_sync_save_find_find_one_session(sync_engine: SyncEngine):
initial_instance = PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
with sync_engine.session() as session:
session.save(initial_instance)
found_instances = list(session.find(PersonModel))
assert len(found_instances) == 1
assert found_instances[0].first_name == initial_instance.first_name
assert found_instances[0].last_name == initial_instance.last_name
single_fetched_instance = session.find_one(
PersonModel, PersonModel.first_name == "Jean-Pierre"
)
assert single_fetched_instance is not None
assert single_fetched_instance.first_name == initial_instance.first_name
assert single_fetched_instance.last_name == initial_instance.last_name
@only_on_replica
async def test_save_find_find_one_session_transaction(aio_engine: AIOEngine):
initial_instance = PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
async with aio_engine.session() as session:
async with session.transaction() as tx:
await tx.save(initial_instance)
found_instances = await tx.find(PersonModel)
assert len(found_instances) == 1
assert found_instances[0].first_name == initial_instance.first_name
assert found_instances[0].last_name == initial_instance.last_name
single_fetched_instance = await tx.find_one(
PersonModel, PersonModel.first_name == "Jean-Pierre"
)
assert single_fetched_instance is not None
assert single_fetched_instance.first_name == initial_instance.first_name
assert single_fetched_instance.last_name == initial_instance.last_name
await tx.commit()
@only_on_replica
def test_sync_save_find_find_one_session_transaction(sync_engine: SyncEngine):
initial_instance = PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
with sync_engine.session() as session:
with session.transaction() as tx:
tx.save(initial_instance)
found_instances = list(tx.find(PersonModel))
assert len(found_instances) == 1
assert found_instances[0].first_name == initial_instance.first_name
assert found_instances[0].last_name == initial_instance.last_name
single_fetched_instance = tx.find_one(
PersonModel, PersonModel.first_name == "Jean-Pierre"
)
assert single_fetched_instance is not None
assert single_fetched_instance.first_name == initial_instance.first_name
assert single_fetched_instance.last_name == initial_instance.last_name
tx.commit()
@only_on_replica
async def test_save_transaction_abort(aio_engine: AIOEngine):
initial_instance = PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
async with aio_engine.transaction() as tx:
await tx.save(initial_instance)
found_instances = await tx.find(PersonModel)
assert len(found_instances) == 1
assert found_instances[0].first_name == initial_instance.first_name
assert found_instances[0].last_name == initial_instance.last_name
await tx.abort()
single_fetched_instance = await aio_engine.find_one(
PersonModel, PersonModel.first_name == "Jean-Pierre"
)
assert single_fetched_instance is None
@only_on_replica
def test_sync_save_transaction_abort(sync_engine: SyncEngine):
initial_instance = PersonModel(first_name="Jean-Pierre", last_name="Pernaud")
with sync_engine.transaction() as tx:
tx.save(initial_instance)
found_instances = list(tx.find(PersonModel))
assert len(found_instances) == 1
assert found_instances[0].first_name == initial_instance.first_name
assert found_instances[0].last_name == initial_instance.last_name
tx.abort()
single_fetched_instance = sync_engine.find_one(
PersonModel, PersonModel.first_name == "Jean-Pierre"
)
assert single_fetched_instance is None
python-odmantic-1.0.2/tests/integration/test_types.py 0000664 0000000 0000000 00000013433 14613034133 0023073 0 ustar 00root root 0000000 0000000 import dataclasses
import re
from datetime import datetime
from decimal import Decimal
from typing import Any, Dict, Generic, List, Pattern, Tuple, Type, TypeVar, Union
import pytest
from bson import Binary, Decimal128, Int64, ObjectId, Regex
from motor.motor_asyncio import AsyncIOMotorDatabase
from pymongo.database import Database
from odmantic.bson import WithBsonSerializer
from odmantic.engine import AIOEngine, SyncEngine
from odmantic.model import Model
from odmantic.typing import Annotated
pytestmark = pytest.mark.asyncio
T = TypeVar("T")
@dataclasses.dataclass
class TypeTestCase(Generic[T]):
python_type: Type[T]
bson_type: str
sample_value: T
MIN_INT32 = -(2**31)
UNDER_INT32_VALUE = MIN_INT32 - 1
MAX_INT32 = 2**31 - 1
OVER_INT32_VALUE = MAX_INT32 + 1
sample_datetime = datetime.now()
type_test_data = [
# Simple types
TypeTestCase(int, "int", 15),
TypeTestCase(int, "int", MIN_INT32),
TypeTestCase(int, "int", MAX_INT32),
TypeTestCase(int, "long", UNDER_INT32_VALUE),
TypeTestCase(int, "long", OVER_INT32_VALUE),
TypeTestCase(Int64, "long", 13),
TypeTestCase(Int64, "long", Int64(13)),
TypeTestCase(str, "string", "foo"),
TypeTestCase(float, "double", 3.14),
TypeTestCase(Decimal, "decimal", Decimal("3.14159265359")),
TypeTestCase(
Decimal, "decimal", "3.14159265359"
), # TODO split tests for odmantic type inference
TypeTestCase(Decimal128, "decimal", Decimal128(Decimal("3.14159265359"))),
TypeTestCase(Dict[str, Any], "object", {"foo": "bar", "fizz": {"foo": "bar"}}),
TypeTestCase(bool, "bool", False),
TypeTestCase(Pattern, "regex", re.compile(r"^.*$")),
TypeTestCase(Pattern, "regex", re.compile(r"^.*$", flags=re.IGNORECASE)),
TypeTestCase(
Pattern, "regex", re.compile(r"^.*$", flags=re.IGNORECASE | re.MULTILINE)
),
TypeTestCase(Regex, "regex", Regex(r"^.*$", flags=32)),
TypeTestCase(ObjectId, "objectId", ObjectId()),
TypeTestCase(bytes, "binData", b"\xf0\xf1\xf2"),
TypeTestCase(Binary, "binData", Binary(b"\xf0\xf1\xf2")),
TypeTestCase(datetime, "date", sample_datetime),
TypeTestCase(List[str], "array", ["one"]),
# Compound Types
TypeTestCase(Tuple[str, ...], "array", ("one",)), # type: ignore
TypeTestCase(List[ObjectId], "array", [ObjectId() for _ in range(5)]),
TypeTestCase(
Union[Tuple[ObjectId, ...], None], # type: ignore
"array",
tuple(ObjectId() for _ in range(5)),
),
]
def id_from_test_case(case: TypeTestCase):
return f"{case.bson_type}"
@pytest.mark.parametrize("case", type_test_data, ids=id_from_test_case)
async def test_bson_type_inference(
motor_database: AsyncIOMotorDatabase, aio_engine: AIOEngine, case: TypeTestCase
):
class ModelWithTypedField(Model):
field: case.python_type # type: ignore
# TODO: Fix objectid optional (type: ignore)
instance = await aio_engine.save(ModelWithTypedField(field=case.sample_value))
document = await motor_database[ModelWithTypedField.__collection__].find_one(
{
+ModelWithTypedField.id: instance.id, # type: ignore
+ModelWithTypedField.field: {"$type": case.bson_type},
}
)
assert document is not None, (
f"Type inference error: {case.python_type} -> {case.bson_type}"
f" ({case.sample_value})"
)
recovered_instance = ModelWithTypedField(field=document["field"])
assert recovered_instance.field == instance.field
@pytest.mark.parametrize("case", type_test_data, ids=id_from_test_case)
def test_sync_bson_type_inference(
pymongo_database: Database, sync_engine: SyncEngine, case: TypeTestCase
):
class ModelWithTypedField(Model):
field: case.python_type # type: ignore
# TODO: Fix objectid optional (type: ignore)
instance = sync_engine.save(ModelWithTypedField(field=case.sample_value))
document = pymongo_database[ModelWithTypedField.__collection__].find_one(
{
+ModelWithTypedField.id: instance.id, # type: ignore
+ModelWithTypedField.field: {"$type": case.bson_type},
}
)
assert document is not None, (
f"Type inference error: {case.python_type} -> {case.bson_type}"
f" ({case.sample_value})"
)
recovered_instance = ModelWithTypedField(field=document["field"])
assert recovered_instance.field == instance.field
async def test_custom_bson_serializable(
motor_database: AsyncIOMotorDatabase, aio_engine
):
FancyFloat = Annotated[float, WithBsonSerializer(str)]
class ModelWithCustomField(Model):
field: FancyFloat
instance = await aio_engine.save(ModelWithCustomField(field=3.14))
document = await motor_database[ModelWithCustomField.__collection__].find_one(
{
+ModelWithCustomField.id: instance.id, # type: ignore
+ModelWithCustomField.field: {"$type": "string"},
}
)
assert document is not None, "Couldn't retrieve the document with it's string value"
recovered_instance = ModelWithCustomField.model_validate_doc(document)
assert recovered_instance.field == instance.field
def test_sync_custom_bson_serializable(
pymongo_database: Database, sync_engine: SyncEngine
):
FancyFloat = Annotated[float, WithBsonSerializer(str)]
class ModelWithCustomField(Model):
field: FancyFloat
instance = sync_engine.save(ModelWithCustomField(field=3.14))
document = pymongo_database[ModelWithCustomField.__collection__].find_one(
{
+ModelWithCustomField.id: instance.id, # type: ignore
+ModelWithCustomField.field: {"$type": "string"},
}
)
assert document is not None, "Couldn't retrieve the document with it's string value"
recovered_instance = ModelWithCustomField.model_validate_doc(document)
assert recovered_instance.field == instance.field
python-odmantic-1.0.2/tests/integration/test_zoo.py 0000664 0000000 0000000 00000003112 14613034133 0022527 0 ustar 00root root 0000000 0000000 import pytest
from odmantic import AIOEngine
from odmantic.engine import SyncEngine
from tests.zoo.player import Player
from tests.zoo.twitter_user import TwitterUser
pytestmark = pytest.mark.asyncio
async def test_twitter_user(aio_engine: AIOEngine):
main = TwitterUser()
await aio_engine.save(main)
friends = [TwitterUser() for _ in range(25)]
await aio_engine.save_all(friends)
friend_ids = [f.id for f in friends]
main.following = friend_ids
await aio_engine.save(main)
fetched_main = await aio_engine.find_one(TwitterUser, TwitterUser.id == main.id)
assert fetched_main is not None
assert fetched_main == main
assert set(friend_ids) == set(fetched_main.following)
def test_sync_twitter_user(sync_engine: SyncEngine):
main = TwitterUser()
sync_engine.save(main)
friends = [TwitterUser() for _ in range(25)]
sync_engine.save_all(friends)
friend_ids = [f.id for f in friends]
main.following = friend_ids
sync_engine.save(main)
fetched_main = sync_engine.find_one(TwitterUser, TwitterUser.id == main.id)
assert fetched_main is not None
assert fetched_main == main
assert set(friend_ids) == set(fetched_main.following)
async def test_player(aio_engine: AIOEngine):
leeroy = Player(name="Leeroy Jenkins")
await aio_engine.save(leeroy)
fetched = await aio_engine.find_one(Player)
assert fetched == leeroy
def test_sync_player(sync_engine: SyncEngine):
leeroy = Player(name="Leeroy Jenkins")
sync_engine.save(leeroy)
fetched = sync_engine.find_one(Player)
assert fetched == leeroy
python-odmantic-1.0.2/tests/integration/utils.py 0000664 0000000 0000000 00000000304 14613034133 0022021 0 ustar 00root root 0000000 0000000 from odmantic.bson import ObjectId
def redact_objectid(s: str, oid: ObjectId) -> str:
"""Replace the ObjectId in a string with a placeholder."""
return s.replace(str(oid), "")
python-odmantic-1.0.2/tests/test_typing_utils.py 0000664 0000000 0000000 00000001135 14613034133 0022132 0 ustar 00root root 0000000 0000000 from typing import Dict, List, Set, Tuple
from typing_utils import are_generics_equal
def test_are_generics_equal_two_different_origin():
assert not are_generics_equal(List[str], Set[str])
def test_are_generics_equal_different_arg_count():
assert not are_generics_equal(Tuple[str], Tuple[str, str])
assert not are_generics_equal(Tuple[str], Tuple[str, ...])
def test_are_generics_equal_different_args():
assert not are_generics_equal(Tuple[str, int], Tuple[int, str])
assert not are_generics_equal(
Tuple[str, int, Dict[int, str]], Tuple[str, int, Dict[int, int]]
)
python-odmantic-1.0.2/tests/typing_utils.py 0000664 0000000 0000000 00000001060 14613034133 0021070 0 ustar 00root root 0000000 0000000 from typing import Type
from odmantic.typing import get_args, get_origin
def are_generics_equal(g1: Type, g2: Type) -> bool:
"""Check if two generic types are equal."""
if g1 == g2:
return True
origin_g1 = get_origin(g1)
origin_g2 = get_origin(g2)
if origin_g1 is None or origin_g2 is None or origin_g1 != origin_g2:
return False
args_g1 = get_args(g1)
args_g2 = get_args(g2)
if len(args_g1) != len(args_g2):
return False
return all(are_generics_equal(a1, a2) for a1, a2 in zip(args_g1, args_g2))
python-odmantic-1.0.2/tests/unit/ 0000775 0000000 0000000 00000000000 14613034133 0016746 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/tests/unit/__init__.py 0000664 0000000 0000000 00000000000 14613034133 0021045 0 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/tests/unit/test_bson_fields.py 0000664 0000000 0000000 00000015624 14613034133 0022656 0 ustar 00root root 0000000 0000000 import re
from datetime import datetime
from decimal import Decimal
from typing import Pattern
import pytest
import pytz
from bson.decimal128 import Decimal128
from bson.objectid import ObjectId
from bson.regex import Regex
from pydantic import ValidationError
from odmantic.bson import WithBsonSerializer
from odmantic.field import Field
from odmantic.model import Model
from odmantic.typing import Annotated
pytestmark = pytest.mark.asyncio
def test_datetime_non_naive():
class ModelWithDate(Model):
field: datetime
with pytest.raises(ValueError):
ModelWithDate(field=datetime.now(tz=pytz.timezone("Europe/Amsterdam")))
with pytest.raises(ValueError):
ModelWithDate(field="2018-11-02T23:59:01.824+10:00")
def test_datetime_non_naive_utc():
class ModelWithDate(Model):
field: datetime = Field(datetime.now(tz=pytz.utc))
ModelWithDate()
def test_datetime_non_naive_utc_as_simplified_extended_iso_format_string():
class ModelWithDate(Model):
field: datetime = Field("2018-11-02T23:59:01.824Z")
ModelWithDate()
def test_datetime_non_naive_utc_as_gmt_zero_offset_string():
class ModelWithDate(Model):
field: datetime = Field("2018-11-02T23:59:01.824+00:00")
ModelWithDate()
def test_datetime_naive():
class ModelWithDate(Model):
field: datetime = Field(default_factory=datetime.utcnow)
ModelWithDate()
def test_datetime_milliseconds_rounding():
class ModelWithDate(Model):
field: datetime
sample_datetime = datetime.now()
sample_datetime = sample_datetime.replace(
microsecond=10001
) # Ensure we have some micro seconds that will be truncated
inst = ModelWithDate(field=sample_datetime)
assert inst.field.microsecond != sample_datetime.microsecond
assert inst.field == sample_datetime.replace(microsecond=10000)
sample_datetime = sample_datetime.replace(microsecond=999501)
inst = ModelWithDate(field=sample_datetime)
assert inst.field == sample_datetime.replace(microsecond=999000)
def test_validate_datetime_from_strings():
class ModelWithDate(Model):
field: datetime
sample_datetime = datetime.now().replace(
microsecond=10000
) # Ensure we have no micro seconds that will be truncated
sample_datetime_str = str(sample_datetime)
inst = ModelWithDate(field=sample_datetime_str)
assert inst.field == sample_datetime
def test_validate_bson_objectid():
class MyModel(Model):
pass
my_oid = ObjectId()
instance = MyModel(id=str(my_oid))
assert instance.id == my_oid
def test_validate_invalid_bson_objectid():
class MyModel(Model):
pass
with pytest.raises(ValidationError) as exc_info:
MyModel(id="not an objectid")
errors = exc_info.value.errors()
assert len(errors) == 2
assert all(error["loc"][0] == "id" for error in errors)
assert "Value error, Invalid ObjectId" in [error["msg"] for error in errors]
def test_validate_decimal_valid_string():
class MyModel(Model):
field: Decimal
value = "3.152345596"
instance = MyModel(field=value)
assert isinstance(instance.field, Decimal)
assert str(instance.field) == value
def test_validate_decimal_valid_bson_decimal():
class MyModel(Model):
field: Decimal
str_value = "3.152345596"
value = Decimal128(str_value)
instance = MyModel(field=value)
assert isinstance(instance.field, Decimal)
assert str(instance.field) == str_value
def test_validate_decimal_invalid_string():
class MyModel(Model):
field: Decimal
with pytest.raises(ValidationError) as exc_info:
MyModel(field="3abcd.15")
errors = exc_info.value.errors()
assert len(errors) == 3
assert all(error["loc"][0] == "field" for error in errors)
assert "Value error, Invalid decimal string" in [error["msg"] for error in errors]
def test_validate_bson_decimal_valid_string():
class MyModel(Model):
field: Decimal128
value = "3.152345596"
instance = MyModel(field=value)
assert isinstance(instance.field, Decimal128)
assert str(instance.field) == value
def test_validate_bson_decimal_valid_bson_decimal():
class MyModel(Model):
field: Decimal128
value = Decimal128("3.152345596")
instance = MyModel(field=value)
assert isinstance(instance.field, Decimal128)
assert instance.field == value
def test_validate_bson_decimal_invalid_string():
class MyModel(Model):
field: Decimal128
with pytest.raises(ValidationError) as exc_info:
MyModel(field="3abcd.15")
errors = exc_info.value.errors()
assert len(errors) == 2
assert all(error["loc"][0] == "field" for error in errors)
assert "Value error, Invalid Decimal128 value" in [error["msg"] for error in errors]
def test_validate_regex_valid_regex():
class MyModel(Model):
field: Regex
regex = Regex("^.*$")
instance = MyModel(field=regex)
assert isinstance(instance.field, Regex)
assert instance.field == regex
def test_validate_regex_valid_string():
class MyModel(Model):
field: Regex
value = "^.*$"
instance = MyModel(field=value)
assert isinstance(instance.field, Regex)
assert instance.field.pattern == value
def test_validate_regex_invalid_string():
class MyModel(Model):
field: Regex
with pytest.raises(ValidationError) as exc_info:
MyModel(field="^((")
errors = exc_info.value.errors()
assert len(errors) == 3
assert all(error["loc"][0] == "field" for error in errors)
assert "Value error, Invalid Pattern value" in [error["msg"] for error in errors]
def test_validate_pattern_valid_string():
class MyModel(Model):
field: Pattern
value = "^.*$"
instance = MyModel(field=value)
assert isinstance(instance.field, Pattern)
assert instance.field.pattern == value
def test_validate_pattern_valid_bson_regex():
class MyModel(Model):
field: Pattern
value = Regex("^.*$", flags="im")
flags_int_value = re.compile("", flags=re.I | re.M).flags
instance = MyModel(field=value)
assert isinstance(instance.field, Pattern)
assert instance.field.pattern == value.pattern
assert instance.field.flags == flags_int_value
def test_validate_pattern_invalid_string():
class MyModel(Model):
field: Pattern
with pytest.raises(ValidationError) as exc_info:
MyModel(field="^((")
errors = exc_info.value.errors()
assert len(errors) == 3
assert all(error["loc"][0] == "field" for error in errors)
assert "Value error, Invalid Pattern value" in [error["msg"] for error in errors]
def test_with_bson_serializer_override_builtin_bson():
MyObjectId = Annotated[ObjectId, WithBsonSerializer(lambda _: "encoded")]
class M(Model):
id: MyObjectId = Field(..., default_factory=ObjectId, primary_field=True)
instance = M()
parsed = instance.model_dump_doc()
assert parsed == {"_id": "encoded"}
python-odmantic-1.0.2/tests/unit/test_config.py 0000664 0000000 0000000 00000002077 14613034133 0021632 0 ustar 00root root 0000000 0000000 import pytest
from inline_snapshot import snapshot
from odmantic import Model
def test_config_enforced_pydantic_option():
with pytest.raises(ValueError) as exc_info:
class M(Model):
a: int
model_config = {"validate_assignment": True}
assert str(exc_info.value) == snapshot(
"'M': configuration attribute 'validate_assignment' is enforced to True by ODMantic and cannot be changed" # noqa: E501
)
def test_config_unsupported_pydantic_option():
with pytest.raises(ValueError) as exc_info:
class M(Model):
a: int
model_config = {"frozen": True}
assert str(exc_info.value) == snapshot(
"'M': configuration attribute 'frozen' from Pydantic is not supported"
)
def test_config_unknown_option():
with pytest.raises(ValueError) as exc_info:
class M(Model):
a: int
model_config = {"this_config_doesnt_exist": True}
assert str(exc_info.value) == snapshot(
"'M': unknown configuration attribute 'this_config_doesnt_exist'"
)
python-odmantic-1.0.2/tests/unit/test_deprecations.py 0000664 0000000 0000000 00000001315 14613034133 0023037 0 ustar 00root root 0000000 0000000 import pytest
from odmantic import EmbeddedModel, Model
class M(Model): ...
def test_deprecated_copy():
with pytest.deprecated_call():
M().copy()
def test_deprecated_update():
with pytest.deprecated_call():
M().update({})
def test_deprecated_update_basemodel():
# EmbeddedModel is a subclass of BaseModel not redefine update
class E(EmbeddedModel): ...
with pytest.deprecated_call():
E().update({})
def test_deprecated_doc():
with pytest.deprecated_call():
M().doc()
def test_deprecated_parse_doc():
with pytest.deprecated_call():
M().parse_doc(
{
"_id": "5f8352a87a733b8b18b0cb27",
}
)
python-odmantic-1.0.2/tests/unit/test_document_serialization.py 0000664 0000000 0000000 00000001027 14613034133 0025132 0 ustar 00root root 0000000 0000000 from decimal import Decimal
import bson
from odmantic import Model
def test_objectid_serialization():
class M(Model): ...
instance = M()
doc = instance.model_dump_doc()
assert isinstance(doc["_id"], bson.ObjectId)
assert doc["_id"] == instance.id
def test_extra_allowed_bson_serialization():
class M(Model):
...
model_config = {"extra": "allow"}
instance = M(extra_field=Decimal("1.1"))
doc = instance.model_dump_doc()
assert isinstance(doc["extra_field"], bson.Decimal128)
python-odmantic-1.0.2/tests/unit/test_field.py 0000664 0000000 0000000 00000005732 14613034133 0021451 0 ustar 00root root 0000000 0000000 from datetime import datetime
from typing import Optional
import pytest
import odmantic
from odmantic.field import Field
from odmantic.model import EmbeddedModel, Model
from odmantic.reference import Reference
def test_field_defined_as_primary_key_and_custom_name():
with pytest.raises(
ValueError, match="cannot specify a primary field with a custom key_name"
):
Field(primary_field=True, key_name="not _id")
def test_field_defined_as_primary_key_default_name():
f = Field(primary_field=True)
assert f.key_name == "_id"
def test_field_define_key_as__id_without_setting_as_primary():
with pytest.raises(
ValueError,
match="cannot specify key_name='_id' without defining the field as primary",
):
Field(key_name="_id")
def test_pos_key_name():
class M(Model):
field: int = Field(key_name="alternate_name")
assert +M.field == "alternate_name"
assert ++M.field == "$alternate_name"
def test_unknown_attr_embedded_model():
class E(EmbeddedModel): ...
class M(Model):
field: E
with pytest.raises(AttributeError):
M.field.unknown_attr # type: ignore
@pytest.mark.parametrize("operator_name", ("lt", "lte", "gt", "gte", "match"))
def test_reference_field_operator_not_allowed(operator_name: str):
class E(Model): ...
class M(Model):
field: E = Reference()
with pytest.raises(
AttributeError,
match=f"operator {operator_name} not allowed for ODMReference fields",
):
getattr(M.field, operator_name)
def test_field_required_in_doc_without_default():
class M(Model):
field: str
assert M.__odm_fields__["field"].is_required_in_doc()
def test_field_required_in_doc_with_default():
class M(Model):
field: str = Field("hi")
assert not M.__odm_fields__["field"].is_required_in_doc()
def test_field_required_in_doc_default_factory_disabled():
class M(Model):
field: str = Field(default_factory=lambda: "hi") # pragma: no cover
assert M.__odm_fields__["field"].is_required_in_doc()
def test_field_required_in_doc_default_factory_enabled():
class M(Model):
field: str = Field(default_factory=lambda: "hi") # pragma: no cover
model_config = {
"parse_doc_with_default_factories": True,
}
assert not M.__odm_fields__["field"].is_required_in_doc()
def test_multiple_optional_fields():
class M(Model):
field: str = Field(default_factory=lambda: "hi") # pragma: no cover
optionalBoolField: Optional[bool] = None
optionalDatetimeField: Optional[datetime] = None
assert (
M.__odm_fields__["optionalBoolField"].pydantic_field.annotation
== Optional[bool]
)
assert (
M.__odm_fields__["optionalDatetimeField"].pydantic_field.annotation
== Optional[odmantic.bson._datetime]
)
instance = M(field="Hi")
# This should work and never throw a value error
instance.optionalBoolField = True
python-odmantic-1.0.2/tests/unit/test_index_definition.py 0000664 0000000 0000000 00000007470 14613034133 0023706 0 ustar 00root root 0000000 0000000 from odmantic.field import Field
from odmantic.index import Index, ODMCompoundIndex, ODMSingleFieldIndex
from odmantic.model import EmbeddedModel, Model
from odmantic.query import asc, desc
def test_single_index_definition():
class M(Model):
f: int = Field(index=True)
indexes = M.__indexes__()
assert len(indexes) == 1
index = indexes[0]
assert isinstance(index, ODMSingleFieldIndex)
assert not index.unique
assert index.key_name == "f"
def test_single_index_with_key_name_definition():
class M(Model):
f: int = Field(key_name="custom", index=True)
indexes = M.__indexes__()
assert len(indexes) == 1
index = indexes[0]
assert isinstance(index, ODMSingleFieldIndex)
assert not index.unique
assert index.key_name == "custom"
def test_single_index_unique_definition():
class M(Model):
f: int = Field(unique=True)
indexes = M.__indexes__()
assert len(indexes) == 1
index = indexes[0]
assert isinstance(index, ODMSingleFieldIndex)
assert index.unique
def test_single_index_index_and_unique_definition():
class M(Model):
f: int = Field(index=True, unique=True)
indexes = M.__indexes__()
assert len(indexes) == 1
index = indexes[0]
assert isinstance(index, ODMSingleFieldIndex)
assert index.unique
def test_single_index_definition_from_generator():
class M(Model):
f: int
model_config = {
"indexes": lambda: [Index(M.f, unique=True)],
}
indexes = M.__indexes__()
assert len(indexes) == 1
index = indexes[0]
assert isinstance(index, ODMSingleFieldIndex)
assert index.unique
def test_compound_index_definition():
class M(Model):
f: int
g: str
model_config = {
"indexes": lambda: [Index(M.f, desc(M.g), unique=True)],
}
indexes = M.__indexes__()
assert len(indexes) == 1
index = indexes[0]
assert isinstance(index, ODMCompoundIndex)
assert index.fields == (asc(M.f), desc(M.g))
assert index.unique
def test_multiple_indexes_definition():
class M(Model):
f: int
g: str
h: float = Field(index=True)
model_config = {
"indexes": lambda: [
Index(M.f, desc(M.g), unique=True, name="asc_desc"),
Index(M.g),
],
}
indexes = M.__indexes__()
assert len(indexes) == 3
assert isinstance(indexes[0], ODMSingleFieldIndex)
assert indexes[0].key_name == "h"
assert isinstance(indexes[1], ODMCompoundIndex)
assert indexes[1].fields == (asc(M.f), desc(M.g))
assert indexes[1].unique
assert indexes[1].index_name == "asc_desc"
assert indexes[1].unique
assert isinstance(indexes[2], ODMSingleFieldIndex)
assert not indexes[2].unique
def test_embedded_index_definition():
class E(EmbeddedModel):
f: int
class M(Model):
e: E = Field(index=True)
indexes = M.__indexes__()
assert len(indexes) == 1
index = indexes[0]
assert isinstance(index, ODMSingleFieldIndex)
assert index.key_name == "e"
def test_embedded_index_definition_generator():
class E(EmbeddedModel):
f: int
class M(Model):
e: E
model_config = {
"indexes": lambda: [Index(M.e)],
}
indexes = M.__indexes__()
assert len(indexes) == 1
index = indexes[0]
assert isinstance(index, ODMSingleFieldIndex)
assert index.key_name == "e"
def test_embedded_field_index_definition():
class E(EmbeddedModel):
f: int
class M(Model):
e: E
model_config = {
"indexes": lambda: [Index(M.e.f)],
}
indexes = M.__indexes__()
assert len(indexes) == 1
index = indexes[0]
assert isinstance(index, ODMSingleFieldIndex)
assert index.key_name == "e.f"
python-odmantic-1.0.2/tests/unit/test_json_serialization.py 0000664 0000000 0000000 00000010657 14613034133 0024276 0 ustar 00root root 0000000 0000000 import json
from datetime import datetime
from typing import Dict
import pytest
from bson import ObjectId
from bson.binary import Binary
from bson.decimal128 import Decimal128
from bson.int64 import Int64
from bson.regex import Regex
from odmantic import Model
from tests.zoo.book_embedded import Book, Publisher
from tests.zoo.full_bson import FullBsonModel
from tests.zoo.patron_embedded import Address, Patron
from tests.zoo.person import PersonModel
from tests.zoo.tree import TreeKind, TreeModel
from tests.zoo.twitter_user import TwitterUser
pytestmark = pytest.mark.asyncio
def test_simple_model_serialization():
class M(Model): ...
id_ = ObjectId()
assert json.loads(M(id=id_).model_dump_json()) == {"id": str(id_)}
TWITTER_USERS = [TwitterUser(), TwitterUser(), TwitterUser()]
MAIN_TWITTER_USER = TwitterUser(following=[e.id for e in TWITTER_USERS])
@pytest.mark.parametrize(
"instance, expected_parsed_json",
(
(
PersonModel(first_name="Johnny", last_name="Cash"),
dict(first_name="Johnny", last_name="Cash"),
),
(
TreeModel(
name="Secoya",
average_size=100.3,
discovery_year=1253,
kind=TreeKind.BIG,
genesis_continents=["Asia"],
per_continent_density={"Asia": 20.3},
),
dict(
name="Secoya",
average_size=100.3,
discovery_year=1253,
kind="big",
genesis_continents=["Asia"],
per_continent_density={"Asia": 20.3},
),
),
(
Book(
title="Harry Potter",
pages=550,
publisher=Publisher(name="A publisher", founded=1995, location="CA"),
),
dict(
title="Harry Potter",
pages=550,
publisher=dict(name="A publisher", founded=1995, location="CA"),
),
),
(
Patron(
name="Jean Michel",
addresses=[
Address(
street="212 Rue de Tolbiac",
city="Paris",
state="Ile de France",
zip="75013",
)
],
),
dict(
name="Jean Michel",
addresses=[
dict(
street="212 Rue de Tolbiac",
city="Paris",
state="Ile de France",
zip="75013",
)
],
),
),
(MAIN_TWITTER_USER, dict(following=[str(u.id) for u in TWITTER_USERS])),
(
FullBsonModel(
objectId_=ObjectId("5f6bd0f85cac5a450e8eb9e8"),
long_=Int64(258),
decimal_=Decimal128("256.123457"),
# TODO: document some bytes value might be rejected because of utf8
# encoding: encode in base64 before
binary_=Binary(b"\x48\x49"),
regex_=Regex(r"^.*$"),
),
dict(
objectId_="5f6bd0f85cac5a450e8eb9e8",
long_=258,
decimal_="256.123457",
binary_=b"\x48\x49".decode(),
regex_=r"^.*$",
),
),
),
)
def test_zoo_serialization_no_id(instance: Model, expected_parsed_json: Dict):
parsed_data = json.loads(instance.model_dump_json())
del parsed_data["id"]
assert parsed_data == expected_parsed_json
@pytest.mark.filterwarnings("ignore:`json_encoders` is deprecated")
def test_custom_json_encoders():
class M(Model):
a: datetime = datetime.now()
model_config = {"json_encoders": {datetime: lambda _: "encoded"}}
instance = M()
parsed = json.loads(instance.model_dump_json())
assert parsed == {"id": str(instance.id), "a": "encoded"}
@pytest.mark.xfail(
reason=(
"This doesn't work any more with pydantic v2 since bson fields are "
"now annotated and take precedence over the custom json encoder."
)
)
def test_custom_json_encoders_override_builtin_bson():
class M(Model):
model_config = {"json_encoders": {ObjectId: lambda _: "encoded"}}
instance = M()
parsed = json.loads(instance.model_dump_json())
assert parsed == {"id": "encoded"}
python-odmantic-1.0.2/tests/unit/test_model_definition.py 0000664 0000000 0000000 00000030564 14613034133 0023677 0 ustar 00root root 0000000 0000000 import sys
from types import FunctionType
from typing import (
Any,
Callable,
ClassVar,
Dict,
FrozenSet,
List,
Literal,
Mapping,
Optional,
Set,
Tuple,
Type,
TypeVar,
Union,
)
import pytest
from bson import ObjectId
from bson.decimal128 import Decimal128
from bson.regex import Regex
from pydantic import Field as PDField
from pydantic import ValidationError
from odmantic import ObjectId as ODMObjectId
from odmantic.field import Field
from odmantic.model import EmbeddedModel, Model
from odmantic.reference import Reference
class TheClassName(Model): ...
class TheClassNameModel(Model): ...
class TheClassNameOverriden(Model):
model_config = {"collection": "collection_name"}
def test_auto_collection_name():
assert TheClassName.__collection__ == "the_class_name"
assert TheClassNameModel.__collection__ == "the_class_name"
assert TheClassNameOverriden.__collection__ == "collection_name"
def test_auto_collection_name_nested():
class theNestedClassName(Model): ...
assert theNestedClassName.__collection__ == "the_nested_class_name"
class TheNestedClassNameOverriden(Model):
model_config = {"collection": "collection_name"}
assert TheNestedClassNameOverriden.__collection__ == "collection_name"
def test_get_collection_name_pos():
class Thing(Model): ...
assert +Thing == "thing"
def test_duplicated_key_name():
with pytest.raises(TypeError):
class M(Model):
a: int
b: int = Field(key_name="a")
def test_duplicated_key_name_in_reference():
class Referenced(Model):
a: int
with pytest.raises(TypeError):
class Base(Model):
a: int = Field(key_name="referenced")
referenced: Referenced = Reference()
def test_duplicate_key_name_definition():
with pytest.raises(TypeError):
class Base(Model):
a: int = Field(key_name="referenced")
b: int = Field(key_name="referenced")
def test_key_name_containing_dollar_sign():
class Base(Model):
a: int = Field(key_name="a$b")
def test_key_starting_with_dollar_sign():
with pytest.raises(TypeError):
class Base(Model):
a: int = Field(key_name="$a")
def test_key_containing_dot():
with pytest.raises(TypeError):
class Base(Model):
b: int = Field(key_name="a.b")
def test_wrong_model_field():
with pytest.raises(TypeError, match="use odmantic.Field instead of pydantic.Field"):
class M(Model):
a: int = PDField()
def test_unknown_model_field():
class UnknownType:
pass
def U() -> Any:
return UnknownType()
with pytest.raises(TypeError):
class M(Model):
a: int = U()
def test_model_default_simple():
class M(Model):
f: int = 3
instance = M()
assert instance.f == 3
def test_model_default_with_field():
class M(Model):
f: int = Field(default=3)
instance = M()
assert instance.f == 3
def test_optional_field_with_default():
class M(Model):
f: Optional[str] = None
assert M().f is None
assert M(f="hello world").f == "hello world"
def test_field_with_invalid_default_type():
with pytest.raises(TypeError, match="Unhandled field definition"):
class M(Model):
f: Optional[int] = "a" # type: ignore
@pytest.mark.skip("Wait for feedback on an pydantic issue #1936")
def test_field_with_invalid_default_type_in_field():
with pytest.raises(TypeError, match="Unhandled field definition"):
class M(Model):
f: Optional[int] = Field("a")
@pytest.mark.skip("Wait for feedback on an pydantic issue #1936")
def test_field_with_invalid_default_value_in_field_at_definition():
with pytest.raises(TypeError, match="Unhandled field definition"):
class M(Model):
f: Optional[int] = Field(3, gt=5)
def test_field_with_invalid_default_value_in_field_at_instantiation():
class M(Model):
f: Optional[int] = Field(3, gt=5)
with pytest.raises(ValidationError):
M()
def test_optional_field_with_field_settings():
class M(Model):
f: Optional[str] = Field("hello world", key_name="my_field")
assert M().f == "hello world"
assert M(f=None).f is None
def test_unable_to_generate_primary_field():
with pytest.raises(TypeError, match="can't automatically generate a primary field"):
class A(Model):
id: str
def test_define_alternate_primary_key():
class M(Model):
name: str = Field(primary_field=True)
instance = M(name="Jack")
assert instance.model_dump_doc() == {"_id": "Jack"}
def test_weird_overload_id_field():
class M(Model):
id: int
name: str = Field(primary_field=True)
instance = M(id=15, name="Johnny")
assert instance.model_dump_doc() == {"_id": "Johnny", "id": 15}
@pytest.mark.skip("Not implemented, see if it should be supported...")
def test_overload_id_with_another_primary_key():
with pytest.raises(TypeError, match="cannot define multiple primary keys"):
class M(Model):
id: int
number: int = Field(primary_key=True)
def test_untyped_field_definition():
with pytest.raises(TypeError, match="defined without type annotation"):
class M(Model):
a = 3
def test_multiple_primary_key():
with pytest.raises(TypeError, match="Duplicated key_name"):
class M(Model):
a: int = Field(primary_field=True)
b: int = Field(primary_field=True)
def test_model_with_implicit_reference_error():
class A(Model):
pass
with pytest.raises(TypeError, match="without a Reference assigned"):
class B(Model):
a: A
def test_embedded_model_with_primary_key():
with pytest.raises(TypeError, match="cannot define a primary field"):
class A(EmbeddedModel):
f: int = Field(primary_field=True)
T = TypeVar("T")
@pytest.mark.parametrize("generic", [List, Set, Tuple])
def test_embedded_model_generics_as_primary_key(generic: Type):
class E(EmbeddedModel):
f: int
with pytest.raises(
TypeError,
match="Declaring a generic type of embedded models as a primary field"
" is not allowed",
):
class M(Model):
e: generic[E] = Field(primary_field=True) # type: ignore
@pytest.mark.parametrize(
"generic",
[
lambda e: List[e], # type: ignore
lambda e: Set[e], # type: ignore
lambda e: Dict[str, e], # type: ignore
lambda e: Tuple[e],
lambda e: Tuple[e, ...],
],
)
def test_embedded_model_generics_with_references(generic: Callable[[Type], Type]):
class AnotherModel(Model):
a: float
class E(EmbeddedModel):
f: AnotherModel = Reference()
with pytest.raises(
TypeError,
match="Declaring a generic type of embedded models containing references"
" is not allowed",
):
class M(Model):
e: generic(E) # type: ignore
def test_invalid_collection_name_dollar():
with pytest.raises(TypeError, match=r"cannot contain '\$'"):
class A(Model):
model_config = {"collection": "hello$world"}
def test_invalid_collection_name_empty():
with pytest.raises(TypeError, match="cannot be empty"):
class A(Model):
model_config = {"collection": ""}
def test_invalid_collection_name_contain_system_dot():
with pytest.raises(TypeError, match="cannot start with 'system.'"):
class A(Model):
model_config = {"collection": "system.hi"}
def test_custom_collection_name():
class M(Model):
model_config = {"collection": "collection_name"}
assert M.__collection__ == "collection_name"
def test_embedded_model_key_name():
class E(EmbeddedModel):
f: int = 3
class M(Model):
field: E = Field(E(), key_name="hello")
doc = M().model_dump_doc()
assert "hello" in doc
assert doc["hello"] == {"f": 3}
def test_embedded_model_as_primary_field():
class E(EmbeddedModel):
f: int
class M(Model):
field: E = Field(primary_field=True)
assert M(field=E(f=1)).model_dump_doc() == {"_id": {"f": 1}}
def test_untouched_types_function():
def id_str(self) -> str: # pragma: no cover
return str(self.id)
class M(Model):
model_config = {"arbitrary_types_allowed": True}
id_: FunctionType = id_str # type: ignore
assert "id_" not in M.__odm_fields__.keys()
@pytest.mark.parametrize(
"t",
[
Optional[ObjectId],
List[ObjectId],
List[Decimal128],
List[Regex],
FrozenSet[Regex],
Union[Regex, ObjectId],
Dict[ObjectId, str],
Dict[Tuple[ObjectId, ...], str],
Dict[Union[ObjectId, str], str],
Mapping[Union[ObjectId, str], str],
],
)
def test_compound_bson_field(t: Type):
class M(Model):
children: t # type: ignore
def test_forbidden_field():
with pytest.raises(TypeError, match="fields are not supported"):
class M(Model):
children: Callable
def test_model_with_class_var():
class M(Model):
cls_var: ClassVar[str] = "theclassvar"
field: int
assert M.cls_var == "theclassvar"
m = M(field=5)
assert m.cls_var == "theclassvar"
assert m.field == 5
assert "cls_var" not in m.model_dump_doc().keys()
def test_model_definition_extra_allow():
class M(Model):
model_config = {"extra": "allow"}
f: int
instance = M(f=1, g=2)
assert instance.model_dump_doc(include={"f", "g"}) == {"f": 1, "g": 2}
def test_model_definition_extra_ignore():
class M(Model):
model_config = {"extra": "ignore"}
f: int
instance = M(f=1, g=2)
assert instance.model_dump_doc(include={"f", "g"}) == {"f": 1}
def test_model_definition_extra_forbid():
class M(Model):
model_config = {"extra": "forbid"}
f: int
with pytest.raises(ValidationError, match="Extra inputs are not permitted"):
M(f=1, g=2)
def test_extra_field_type_subst():
class M(Model):
model_config = {"extra": "allow"}
f: int
instance = M(f=1, oid=ODMObjectId())
assert isinstance(instance.model_dump_doc()["oid"], ObjectId)
def test_extra_field_document_parsing():
class M(Model):
model_config = {"extra": "allow"}
f: int
instance = M.model_validate_doc({"_id": ObjectId(), "f": 1, "extra": "hello"})
assert "extra" in instance.model_dump_doc()
class EmForGenericDefinitionTest(EmbeddedModel):
f: int
@pytest.mark.skipif(
sys.version_info[:3] < (3, 9, 0),
reason="Standard collection generics not supported by python < 3.9",
)
@pytest.mark.parametrize(
"get_type, value",
[
(lambda: list[int], [1, 2, 3]),
(lambda: dict[str, int], {"a": 1, "b": 2}),
(lambda: set[int], {1, 2, 3}),
(lambda: tuple[int, ...], (1, 2, 3)),
(
lambda: list[EmForGenericDefinitionTest],
[EmForGenericDefinitionTest(f=1), EmForGenericDefinitionTest(f=2)],
),
(
lambda: dict[str, EmForGenericDefinitionTest],
{
"a": EmForGenericDefinitionTest(f=1),
"b": EmForGenericDefinitionTest(f=2),
},
),
(
lambda: tuple[EmForGenericDefinitionTest, ...],
(EmForGenericDefinitionTest(f=1), EmForGenericDefinitionTest(f=2)),
),
],
)
def test_model_definition_with_new_generics(get_type: Callable, value: Any):
class M(Model):
f: get_type() # type: ignore # 3.9 + syntax
assert M(f=value).f == value
def test_model_definition_with_literal():
class M(Model):
f: Literal["a", "b", "c"] # noqa: F821
assert M(f="a").f == "a"
def test_model_definition_with_literal_fail():
class M(Model):
f: Literal["a", "b", "c"] # noqa: F821
with pytest.raises(ValidationError):
M(f="w")
def test_model_definition_with_generic_literals():
class M(Model):
f: List[Literal["a", "b", "c"]] # noqa: F821
assert M(f=["a", "c"]).f == ["a", "c"]
def test_model_with_multiple_optional_fields():
class Person(Model):
hashed_password: Optional[str]
totp_secret: Optional[str] = Field(default=None)
totp_counter: Optional[int] = Field(default=None)
user = {
"hashed_password": "hashed_password",
}
Person(**user)
python-odmantic-1.0.2/tests/unit/test_model_logic.py 0000664 0000000 0000000 00000046446 14613034133 0022652 0 ustar 00root root 0000000 0000000 from typing import Dict, List, Optional, Tuple
import pytest
from bson.objectid import ObjectId
from inline_snapshot import snapshot
from pydantic import ValidationError, model_validator, root_validator
from pydantic.main import BaseModel
from odmantic.exceptions import DocumentParsingError
from odmantic.field import Field
from odmantic.model import EmbeddedModel, Model
from odmantic.reference import Reference
from tests.integration.utils import redact_objectid
from tests.zoo.person import PersonModel
def test_repr_model():
class M(Model):
a: int
instance = M(a=5)
assert repr(instance) == f"M(id={repr(instance.id)}, a=5)"
def test_repr_embedded_model():
class M(EmbeddedModel):
a: int
instance = M(a=5)
assert repr(instance) == "M(a=5)"
def test_fields_modified_no_modification():
class M(Model):
f: int
instance = M(f=0)
assert instance.__fields_modified__ == set(["f", "id"])
def test_fields_embedded_modified_no_modification():
class M(EmbeddedModel):
f: int
instance = M(f=0)
assert instance.__fields_modified__ == set(["f"])
def test_fields_modified_with_default():
class M(Model):
f: int = 5
instance = M(f=0)
assert instance.__fields_modified__ == set(["f", "id"])
@pytest.mark.parametrize("model_cls", [Model, EmbeddedModel])
def test_fields_modified_one_update(model_cls):
class M(model_cls): # type: ignore
f: int
instance = M(f=0)
instance.__fields_modified__.clear()
instance.f = 1
assert instance.__fields_modified__ == set(["f"])
def test_field_update_with_invalid_data_type():
class M(Model):
f: int
instance = M(f=0)
with pytest.raises(ValidationError):
instance.f = "aa" # type: ignore
def test_field_update_with_invalid_data():
class M(Model):
f: int = Field(gt=0)
instance = M(f=1)
with pytest.raises(ValidationError):
instance.f = -1
def test_validate_does_not_copy():
instance = PersonModel(first_name="Jean", last_name="Pierre")
assert PersonModel.validate(instance) is instance
def test_validate_from_dict():
instance = PersonModel.validate({"first_name": "Jean", "last_name": "Pierre"})
assert isinstance(instance, PersonModel)
assert instance.first_name == "Jean" and instance.last_name == "Pierre"
def test_fields_modified_on_construction():
instance = PersonModel(first_name="Jean", last_name="Pierre")
assert instance.__fields_modified__ == set(["first_name", "last_name", "id"])
def test_fields_modified_on_document_parsing():
instance = PersonModel.model_validate_doc(
{"_id": ObjectId(), "first_name": "Jackie", "last_name": "Chan"}
)
assert instance.__fields_modified__ == set(["first_name", "last_name", "id"])
def test_document_parsing_error_keyname():
class M(Model):
field: str = Field(key_name="custom")
id = ObjectId()
with pytest.raises(DocumentParsingError) as exc_info:
M.model_validate_doc({"_id": id})
assert redact_objectid(str(exc_info.value), id) == snapshot(
"""\
1 validation error for M
field
Key 'custom' not found in document [type=odmantic::key_not_found_in_document, input_value={'_id': ObjectId('')}, input_type=dict]\
""" # noqa: E501
)
def test_document_parsing_error_embedded_keyname():
class F(EmbeddedModel):
a: int
class E(EmbeddedModel):
f: F
class M(Model):
e: E
with pytest.raises(DocumentParsingError) as exc_info:
M.model_validate_doc({"_id": ObjectId(), "e": {"f": {}}})
assert str(exc_info.value) == snapshot(
"""\
1 validation error for M
e.f.a
Key 'a' not found in document [type=odmantic::key_not_found_in_document, input_value={}, input_type=dict]\
""" # noqa: E501
)
def test_embedded_document_parsing_error():
class E(EmbeddedModel):
f: int
with pytest.raises(DocumentParsingError) as exc_info:
E.model_validate_doc({})
assert str(exc_info.value) == snapshot(
"""\
1 validation error for E
f
Key 'f' not found in document [type=odmantic::key_not_found_in_document, input_value={}, input_type=dict]\
""" # noqa: E501
)
def test_embedded_document_parsing_validation_error():
class E(EmbeddedModel):
f: int
with pytest.raises(DocumentParsingError) as exc_info:
E.model_validate_doc({"f": "aa"})
assert str(exc_info.value).splitlines()[:-1] == snapshot(
[
"1 validation error for E",
"f",
" Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='aa', input_type=str]", # noqa: E501
]
)
def test_embedded_model_alternate_key_name_with_default():
class Em(EmbeddedModel):
name: str = Field(key_name="username")
class M(Model):
f: Em = Em(name="Jack")
_id = ObjectId()
doc = {"_id": _id}
parsed = M.model_validate_doc(doc)
assert parsed.f.name == "Jack"
def test_embedded_model_alternate_key_name_parsing_exception():
class Em(EmbeddedModel):
name: str = Field(key_name="username")
class M(Model):
f: Em
_id = ObjectId()
doc = {"_id": _id}
with pytest.raises(DocumentParsingError):
M.model_validate_doc(doc)
def test_embedded_model_alternate_key_name():
class Em(EmbeddedModel):
name: str = Field(key_name="username")
class M(Model):
f: Em
instance = M(f=Em(name="Jack"))
doc = instance.model_dump_doc()
assert doc["f"] == {"username": "Jack"}
parsed = M.model_validate_doc(doc)
assert parsed == instance
def test_embedded_model_list_alternate_key_name():
class Em(EmbeddedModel):
name: str = Field(key_name="username")
class M(Model):
f: List[Em]
instance = M(f=[Em(name="Jack")])
doc = instance.model_dump_doc()
assert doc["f"] == [{"username": "Jack"}]
parsed = M.model_validate_doc(doc)
assert parsed == instance
def test_embedded_model_tuple_alternate_key_name():
class Em(EmbeddedModel):
name: str = Field(key_name="username")
class M(Model):
f: Tuple[Em, ...]
instance = M(f=(Em(name="Jack"),))
doc = instance.model_dump_doc()
assert doc["f"] == [{"username": "Jack"}]
parsed = M.model_validate_doc(doc)
assert parsed == instance
def test_embedded_model_list_parsing_invalid_type():
class Em(EmbeddedModel):
name: str
class M(Model):
f: List[Em]
with pytest.raises(DocumentParsingError) as exc_info:
M.model_validate_doc({"_id": 1, "f": {1: {"name": "Jack"}}})
assert str(exc_info.value) == snapshot(
"""\
1 validation error for M
f
Incorrect generic embedded model value '{1: {'name': 'Jack'}}' [type=odmantic::incorrect_generic_embedded_model_value, input_value={'_id': 1, 'f': {1: {'name': 'Jack'}}}, input_type=dict]\
""" # noqa: E501
)
def test_embedded_model_list_parsing_missing_value():
class Em(EmbeddedModel):
name: str
class M(Model):
f: List[Em]
with pytest.raises(
DocumentParsingError,
) as exc_info:
M.model_validate_doc({"_id": 1})
assert str(exc_info.value) == snapshot(
"""\
1 validation error for M
f
Key 'f' not found in document [type=odmantic::key_not_found_in_document, input_value={'_id': 1}, input_type=dict]\
""" # noqa: E501
)
def test_embedded_model_list_parsing_missing_value_with_default():
class Em(EmbeddedModel):
name: str
class M(Model):
f: List[Em] = [Em(name="John")]
parsed = M.model_validate_doc({"_id": ObjectId()})
assert parsed.f == [Em(name="John")]
def test_embedded_model_dict_parsing_invalid_value():
class Em(EmbeddedModel):
name: str
class M(Model):
f: Dict[str, Em]
with pytest.raises(DocumentParsingError) as exc_info:
M.model_validate_doc({"_id": 1, "f": []})
assert str(exc_info.value) == snapshot(
"""\
1 validation error for M
f
Incorrect generic embedded model value '[]' [type=odmantic::incorrect_generic_embedded_model_value, input_value={'_id': 1, 'f': []}, input_type=dict]\
""" # noqa: E501
)
def test_embedded_model_dict_parsing_invalid_sub_value():
class Em(EmbeddedModel):
e: int
class M(Model):
f: Dict[str, Em]
with pytest.raises(DocumentParsingError) as exc_info:
M.model_validate_doc({"_id": ObjectId(), "f": {"key": {"not_there": "a"}}})
assert str(exc_info.value) == snapshot(
"""\
1 validation error for M
f.["key"].e
Key 'e' not found in document [type=odmantic::key_not_found_in_document, input_value={'not_there': 'a'}, input_type=dict]\
""" # noqa: E501
)
def test_embedded_model_list_parsing_invalid_sub_value():
class Em(EmbeddedModel):
e: int
class M(Model):
f: List[Em]
with pytest.raises(DocumentParsingError) as exc_info:
M.model_validate_doc({"_id": ObjectId(), "f": [{"not_there": "a"}]})
assert str(exc_info.value) == snapshot(
"""\
1 validation error for M
f.[0].e
Key 'e' not found in document [type=odmantic::key_not_found_in_document, input_value={'not_there': 'a'}, input_type=dict]\
""" # noqa: E501
)
def test_fields_modified_on_object_parsing():
instance = PersonModel.model_validate(
{"_id": ObjectId(), "first_name": "Jackie", "last_name": "Chan"}
)
assert instance.__fields_modified__ == set(["first_name", "last_name", "id"])
def test_change_primary_key_value():
class M(Model): ...
instance = M()
with pytest.raises(NotImplementedError, match="assigning a new primary key"):
instance.id = 12
def test_model_copy_without_update():
instance = PersonModel(first_name="Jean", last_name="Valjean")
copied = instance.model_copy()
assert instance == copied
def test_model_copy_with_update():
instance = PersonModel(first_name="Jean", last_name="Valjean")
copied = instance.model_copy(update={"last_name": "Pierre"})
assert instance.id == copied.id
assert instance.first_name == copied.first_name
assert copied.last_name == "Pierre"
def test_model_copy_with_update_primary_key():
instance = PersonModel(first_name="Jean", last_name="Valjean")
copied = instance.model_copy(update={"id": ObjectId()})
assert instance.first_name == copied.first_name
assert copied.last_name == copied.last_name
assert instance.id != copied.id
@pytest.mark.filterwarnings("ignore:copy is deprecated")
def test_deprecated_model_copy_call():
class M(Model): ...
with pytest.raises(NotImplementedError):
M().copy(include={"id"})
with pytest.raises(NotImplementedError):
M().copy(exclude={"id"})
def test_model_copy_deep_embedded():
class E(EmbeddedModel):
f: int
class M(Model):
e: E
instance = M(e=E(f=1))
copied = instance.model_copy(deep=True)
assert instance.e is not copied.e
def test_model_copy_deep_embedded_mutability():
class F(EmbeddedModel):
g: int
class E(EmbeddedModel):
f: F
class M(Model):
e: E
instance = M(e=E(f=F(g=1)))
copied = instance.model_copy(deep=True)
copied.e.f.g = 42
assert instance.e.f.g != copied.e.f.g
def test_model_copy_not_deep_embedded():
class E(EmbeddedModel):
f: int
class M(Model):
e: E
instance = M(e=E(f=1))
copied = instance.model_copy(deep=False)
assert instance.e is copied.e
@pytest.mark.parametrize("deep", [True, False])
def test_model_copy_with_reference(deep: bool):
class R(Model):
f: int
class M(Model):
r: R = Reference()
ref_instance = R(f=12)
instance = M(r=ref_instance)
copied = instance.model_copy(deep=deep)
assert instance.model_dump_doc() == copied.model_dump_doc()
assert instance.r == copied.r
@pytest.mark.parametrize("deep", [True, False])
def test_model_copy_field_modified(deep: bool):
class M(Model):
f: int
instance = M(f=5)
object.__setattr__(instance, "__fields_modified__", set())
copied = instance.model_copy(update={"f": 12}, deep=deep)
assert "f" in copied.__fields_modified__
@pytest.mark.parametrize("deep", [True, False])
def test_model_copy_field_modified_on_primary_field_change(deep: bool):
class M(Model):
f0: int
f1: int
f2: int
instance = M(f0=12, f1=5, f2=6)
object.__setattr__(instance, "__fields_modified__", set())
copied = instance.model_copy(deep=deep)
assert {"id", "f0", "f1", "f2"} == copied.__fields_modified__
INITIAL_FIRST_NAME, INITIAL_LAST_NAME = "INITIAL_FIRST_NAME", "INITIAL_LAST_NAME"
UPDATED_NAME = "UPDATED_NAME"
@pytest.fixture
def instance_to_update():
return PersonModel(first_name=INITIAL_FIRST_NAME, last_name=INITIAL_LAST_NAME)
def test_update_pydantic_model(instance_to_update):
class Update(BaseModel):
first_name: str
update_obj = Update(first_name=UPDATED_NAME)
instance_to_update.model_update(update_obj)
assert instance_to_update.first_name == UPDATED_NAME
assert instance_to_update.last_name == INITIAL_LAST_NAME
def test_update_dictionary(instance_to_update):
update_obj = {"first_name": UPDATED_NAME}
instance_to_update.model_update(update_obj)
assert instance_to_update.first_name == UPDATED_NAME
assert instance_to_update.last_name == INITIAL_LAST_NAME
def test_update_include(instance_to_update):
update_obj = {"first_name": UPDATED_NAME}
instance_to_update.model_update(update_obj, include=set())
assert instance_to_update.first_name == INITIAL_FIRST_NAME
assert instance_to_update.last_name == INITIAL_LAST_NAME
def test_update_exclude(instance_to_update):
update_obj = {"first_name": UPDATED_NAME}
instance_to_update.model_update(update_obj, exclude={"first_name"})
assert instance_to_update.first_name == INITIAL_FIRST_NAME
assert instance_to_update.last_name == INITIAL_LAST_NAME
def test_update_exclude_none(instance_to_update):
class Update(BaseModel):
first_name: Optional[str]
last_name: Optional[str]
update_obj = Update(first_name=UPDATED_NAME, last_name=None)
instance_to_update.model_update(update_obj, exclude_unset=False, exclude_none=True)
assert instance_to_update.first_name == UPDATED_NAME
assert instance_to_update.last_name == INITIAL_LAST_NAME
def test_update_exclude_defaults(instance_to_update):
initial_instance = instance_to_update.model_copy()
class Update(BaseModel):
first_name: Optional[str] = None
last_name: str = UPDATED_NAME
update_obj = Update()
instance_to_update.model_update(
update_obj, exclude_unset=False, exclude_defaults=True
)
assert instance_to_update == initial_instance
def test_update_exclude_over_include(instance_to_update):
update_obj = {"first_name": UPDATED_NAME}
instance_to_update.model_update(
update_obj, include={"first_name"}, exclude={"first_name"}
)
assert instance_to_update.first_name == INITIAL_FIRST_NAME
assert instance_to_update.last_name == INITIAL_LAST_NAME
def test_update_invalid():
class M(Model):
f: int
instance = M(f=12)
update_obj = {"f": "aaa"}
with pytest.raises(ValidationError):
instance.model_update(update_obj)
def test_update_model_undue_update_fields():
class M(Model):
f: int
instance = M(f=12)
update_obj = {"not_in_model": "aaa"}
instance.model_update(update_obj)
def test_update_pydantic_unset_update_fields():
UPDATEED_VALUE = 100
class P(BaseModel):
f: int = UPDATEED_VALUE
class M(Model):
f: int
instance = M(f=0)
update_obj = P()
instance.model_update(update_obj)
assert instance.f != UPDATEED_VALUE
def test_update_pydantic_unset_update_fields_include_unset():
UPDATEED_VALUE = 100
class P(BaseModel):
f: int = UPDATEED_VALUE
class M(Model):
f: int
instance = M(f=0)
update_obj = P()
instance.model_update(update_obj, exclude_unset=False)
assert instance.f == UPDATEED_VALUE
def test_update_embedded_model():
class E(EmbeddedModel):
f: int
instance = E(f=12)
instance.model_update({"f": 15})
assert instance.f == 15
def test_update_reference():
class R(Model):
f: int
class M(Model):
r: R = Reference()
r0 = R(f=0)
r1 = R(f=1)
instance = M(r=r0)
instance.model_update({"r": r1})
assert instance.r.f == r1.f
assert instance.r == r1
def test_update_type_coercion():
class M(Model):
f: int
instance = M(f=12)
update_obj = {"f": "12"}
instance.model_update(update_obj)
assert isinstance(instance.f, int)
def test_update_side_effect_field_modified():
class Rectangle(Model):
width: float
height: float
area: float = 0
@model_validator(mode="before")
def set_area(cls, v):
v["area"] = v["width"] * v["height"]
return v
r = Rectangle(width=1, height=1)
assert r.area == 1
r.__fields_modified__.clear()
r.model_update({"width": 5})
assert r.area == 5
assert "area" in r.__fields_modified__
@pytest.mark.filterwarnings(
"ignore: Pydantic V1 style `@root_validator` validators are deprecated"
)
def test_update_side_effect_field_modified_with_root_validator():
class Rectangle(Model):
width: float
height: float
area: float = 0
@root_validator(skip_on_failure=True)
def set_area(cls, v):
v["area"] = v["width"] * v["height"]
return v
r = Rectangle(width=1, height=1)
assert r.area == 1
r.__fields_modified__.clear()
r.model_update({"width": 5})
assert r.area == 5
assert "area" in r.__fields_modified__
def test_update_dict_id_exception():
class M(Model):
alternate_id: int = Field(primary_field=True)
f: int
m = M(alternate_id=0, f=0)
with pytest.raises(ValueError, match="Updating the primary key is not supported"):
m.model_update({"alternate_id": 1})
@pytest.mark.parametrize(
"update_kwargs",
(
{"include": set()},
{"exclude": {"alternate_id"}},
{"include": {"alternate_id"}, "exclude": {"alternate_id"}},
),
)
def test_update_dict_alternate_id_filtered(update_kwargs):
class M(Model):
alternate_id: int = Field(primary_field=True)
f: int
m = M(alternate_id=0, f=0)
m.model_update({"alternate_id": 1}, **update_kwargs)
assert m.f == 0 and m.alternate_id == 0, "instance should be unchanged"
def test_update_pydantic_id_exception():
class M(Model):
alternate_id: int = Field(primary_field=True)
f: int
m = M(alternate_id=0, f=0)
class UpdateObject(BaseModel):
alternate_id: int
with pytest.raises(ValueError, match="Updating the primary key is not supported"):
m.model_update(UpdateObject(alternate_id=1))
@pytest.mark.parametrize(
"update_kwargs",
(
{"include": set()},
{"exclude": {"alternate_id"}},
{"include": {"alternate_id"}, "exclude": {"alternate_id"}},
),
)
def test_update_pydantic_alternate_id_filtered(update_kwargs):
class M(Model):
alternate_id: int = Field(primary_field=True)
f: int
class UpdateObject(BaseModel):
alternate_id: int
m = M(alternate_id=0, f=0)
m.model_update(UpdateObject(alternate_id=1), **update_kwargs)
assert m.f == 0 and m.alternate_id == 0, "instance should be unchanged"
python-odmantic-1.0.2/tests/unit/test_model_type_validation.py 0000664 0000000 0000000 00000006017 14613034133 0024736 0 ustar 00root root 0000000 0000000 import sys
from typing import (
Callable,
Dict,
FrozenSet,
List,
Optional,
Sequence,
Set,
Tuple,
Type,
Union,
)
import bson
import pytest
from typing_utils import are_generics_equal
from odmantic.bson import (
_BSON_SUBSTITUTED_FIELDS,
Binary,
Decimal128,
Int64,
ObjectId,
Regex,
)
from odmantic.model import EmbeddedModel, Model, is_type_mutable, validate_type
@pytest.mark.parametrize("base, replacement", _BSON_SUBSTITUTED_FIELDS.items())
def test_validate_type_bson_substituted(base, replacement):
assert validate_type(base) == replacement
@pytest.mark.parametrize("base, replacement", _BSON_SUBSTITUTED_FIELDS.items())
def test_optional_bson_subst(base, replacement):
assert are_generics_equal(
validate_type(Optional[base]),
Optional[replacement],
)
@pytest.mark.parametrize("origin", (List, Set, FrozenSet, Sequence))
@pytest.mark.parametrize("base, replacement", _BSON_SUBSTITUTED_FIELDS.items())
def test_single_arg_type_bson_subst(origin, base, replacement):
assert are_generics_equal(validate_type(origin[base]), origin[replacement])
def test_forbidden_field():
with pytest.raises(TypeError, match="fields are not supported"):
validate_type(Callable) # type: ignore
def test_deep_nest_bson_subst():
assert are_generics_equal(
validate_type(
Union[ # type: ignore
Dict[List[bson.ObjectId], Dict[bson.ObjectId, bson.Decimal128]],
Dict[Dict[Set[bson.Int64], bson.Binary], Tuple[bson.Regex, ...]],
]
),
Union[
Dict[List[ObjectId], Dict[ObjectId, Decimal128]],
Dict[Dict[Set[Int64], Binary], Tuple[Regex, ...]],
],
)
class DummyEmbedded(EmbeddedModel):
field: str
class DummyModel(Model):
field: str
@pytest.mark.parametrize(
"t",
(
None,
bool,
int,
str,
Tuple,
Tuple[int, str, bool],
Tuple[int, ...],
FrozenSet[int],
Union[FrozenSet[int], Tuple[int, str]],
DummyModel,
),
)
def test_mutable_types_immutables(t: Type):
assert not is_type_mutable(t)
TEST_TYPES = [
List,
Set,
List[int],
Tuple[List[int]],
FrozenSet[Set[int]],
Dict[Tuple[int, ...], str],
DummyEmbedded,
Tuple[DummyEmbedded, ...],
Dict[str, DummyEmbedded],
FrozenSet[DummyEmbedded],
]
# if generic with builtin types are supported add them to the list
if sys.version_info >= (3, 9):
TEST_TYPES += [
list,
set,
list[int],
tuple[list[int]],
frozenset[set[int]],
dict[tuple[int, ...], str],
]
@pytest.mark.parametrize(
"t",
TEST_TYPES,
)
def test_mutable_types_mutables(t: Type):
assert is_type_mutable(t)
def test_mutable_types_unknown_type():
class T: ...
assert is_type_mutable(T)
def test_mutable_field_embedded_model():
class E(EmbeddedModel):
f: int
class M(Model):
e: E
assert "e" in M.__mutable_fields__
python-odmantic-1.0.2/tests/unit/test_query.py 0000664 0000000 0000000 00000002154 14613034133 0021526 0 ustar 00root root 0000000 0000000 from odmantic.query import QueryExpression, SortExpression, asc
from tests.zoo.book_embedded import Book, Publisher
from tests.zoo.tree import TreeKind, TreeModel
def test_embedded_eq():
pub = Publisher(name="O'Reilly Media", founded=1980, location="CA")
assert (Book.publisher == pub) == {
"publisher": {
"$eq": {"name": "O'Reilly Media", "founded": 1980, "location": "CA"}
}
}
def test_embedded_eq_on_subfield():
assert (Book.publisher.location == "EU") == {"publisher.location": {"$eq": "EU"}}
def test_eq_on_enum():
assert (TreeModel.kind == TreeKind.BIG) == {"kind": {"$eq": TreeKind.BIG.value}}
assert (TreeModel.kind == "big") == {"kind": {"$eq": "big"}}
def test_query_repr():
assert (
repr(TreeModel.name == "Spruce")
== "QueryExpression({'name': {'$eq': 'Spruce'}})"
)
def test_query_empty_repr():
assert repr(QueryExpression()) == "QueryExpression()"
def test_sort_repr():
assert repr(asc(TreeModel.name)) == "SortExpression({'name': 1})"
def test_sort_empty_repr():
assert repr(SortExpression()) == "SortExpression()"
python-odmantic-1.0.2/tests/unit/test_reference.py 0000664 0000000 0000000 00000001644 14613034133 0022322 0 ustar 00root root 0000000 0000000 import pytest
from odmantic.field import Field
from odmantic.model import Model
from odmantic.reference import Reference
def test_build_query_filter_across_reference():
class Referenced(Model):
a: int
class M(Model):
ref: Referenced = Reference()
with pytest.raises(
NotImplementedError, match="filtering across references is not supported"
):
M.ref.a == 12
def test_build_query_filter_across_reference_no_attribute():
class Referenced(Model):
a: int
class M(Model):
ref: Referenced = Reference()
with pytest.raises(AttributeError):
M.ref.does_not_exist # type: ignore
def test_reference_with_custom_primary_field():
class Referenced(Model):
key: int = Field(primary_field=True)
class M(Model):
ref: Referenced = Reference()
r = Referenced(key=1)
m = M(ref=r)
assert m.model_dump_doc()["ref"] == 1
python-odmantic-1.0.2/tests/unit/test_session.py 0000664 0000000 0000000 00000013525 14613034133 0022050 0 ustar 00root root 0000000 0000000 import sys
from unittest.mock import MagicMock, Mock
import pytest
from odmantic.session import AIOSession, AIOTransaction, SyncSession, SyncTransaction
from ..zoo.person import PersonModel
if sys.version_info >= (3, 8):
from unittest.mock import AsyncMock
pytestmark = [
pytest.mark.asyncio,
pytest.mark.skipif(
sys.version_info < (3, 8), reason="async mock testing requires python3.8+"
),
]
@pytest.fixture(scope="function")
def mocked_driver_session():
return Mock()
@pytest.fixture(scope="function")
def mocked_aio_engine(mocked_driver_session):
engine = AsyncMock()
engine._get_session = Mock(return_value=mocked_driver_session)
return engine
@pytest.fixture(scope="function", params=["session", "transaction"])
async def mocked_aio_session(request, mocked_aio_engine):
if request.param == "session":
return AIOSession(mocked_aio_engine)
else:
session = AIOSession(mocked_aio_engine)
await session.start()
return AIOTransaction(session)
@pytest.fixture(scope="function")
def mocked_sync_engine(mocked_driver_session):
engine = MagicMock()
engine._get_session = Mock(return_value=mocked_driver_session)
return engine
@pytest.fixture(scope="function", params=["session", "transaction"])
def mocked_sync_session(request, mocked_sync_engine):
if request.param == "session":
return SyncSession(mocked_sync_engine)
else:
session = SyncSession(mocked_sync_engine)
session.start()
return SyncTransaction(session)
async def test_session_find(
mocked_aio_session, mocked_aio_engine, mocked_driver_session
):
await mocked_aio_session.find(PersonModel)
mocked_aio_engine.find.assert_awaited_once()
assert mocked_aio_engine.find.call_args.kwargs["session"] == mocked_driver_session
async def test_session_find_one(
mocked_aio_session, mocked_aio_engine, mocked_driver_session
):
await mocked_aio_session.find_one(PersonModel)
mocked_aio_engine.find_one.assert_awaited_once()
assert (
mocked_aio_engine.find_one.call_args.kwargs["session"] == mocked_driver_session
)
async def test_session_count(
mocked_aio_session, mocked_aio_engine, mocked_driver_session
):
await mocked_aio_session.count(PersonModel)
mocked_aio_engine.count.assert_awaited_once()
assert mocked_aio_engine.count.call_args.kwargs["session"] == mocked_driver_session
async def test_session_save(
mocked_aio_session, mocked_aio_engine, mocked_driver_session
):
await mocked_aio_session.save(PersonModel(first_name="John", last_name="Doe"))
mocked_aio_engine.save.assert_awaited_once()
assert mocked_aio_engine.save.call_args.kwargs["session"] == mocked_driver_session
async def test_session_save_all(
mocked_aio_session, mocked_aio_engine, mocked_driver_session
):
await mocked_aio_session.save_all([PersonModel(first_name="John", last_name="Doe")])
mocked_aio_engine.save_all.assert_awaited_once()
assert (
mocked_aio_engine.save_all.call_args.kwargs["session"] == mocked_driver_session
)
async def test_session_delete(
mocked_aio_session, mocked_aio_engine, mocked_driver_session
):
await mocked_aio_session.delete(PersonModel(first_name="John", last_name="Doe"))
mocked_aio_engine.delete.assert_awaited_once()
assert mocked_aio_engine.delete.call_args.kwargs["session"] == mocked_driver_session
async def test_session_remove(
mocked_aio_session, mocked_aio_engine, mocked_driver_session
):
await mocked_aio_session.remove(PersonModel, PersonModel.first_name == "John")
mocked_aio_engine.remove.assert_awaited_once()
assert mocked_aio_engine.remove.call_args.kwargs["session"] == mocked_driver_session
def test_sync_session_find(
mocked_sync_session, mocked_sync_engine, mocked_driver_session
):
mocked_sync_session.find(PersonModel)
mocked_sync_engine.find.assert_called_once()
assert mocked_sync_engine.find.call_args.kwargs["session"] == mocked_driver_session
def test_sync_session_find_one(
mocked_sync_session, mocked_sync_engine, mocked_driver_session
):
mocked_sync_session.find_one(PersonModel)
mocked_sync_engine.find_one.assert_called_once()
assert (
mocked_sync_engine.find_one.call_args.kwargs["session"] == mocked_driver_session
)
def test_sync_session_count(
mocked_sync_session, mocked_sync_engine, mocked_driver_session
):
mocked_sync_session.count(PersonModel)
mocked_sync_engine.count.assert_called_once()
assert mocked_sync_engine.count.call_args.kwargs["session"] == mocked_driver_session
def test_sync_session_save(
mocked_sync_session, mocked_sync_engine, mocked_driver_session
):
mocked_sync_session.save(PersonModel(first_name="John", last_name="Doe"))
mocked_sync_engine.save.assert_called_once()
assert mocked_sync_engine.save.call_args.kwargs["session"] == mocked_driver_session
def test_sync_session_save_all(
mocked_sync_session, mocked_sync_engine, mocked_driver_session
):
mocked_sync_session.save_all([PersonModel(first_name="John", last_name="Doe")])
mocked_sync_engine.save_all.assert_called_once()
assert (
mocked_sync_engine.save_all.call_args.kwargs["session"] == mocked_driver_session
)
def test_sync_session_delete(
mocked_sync_session, mocked_sync_engine, mocked_driver_session
):
mocked_sync_session.delete(PersonModel(first_name="John", last_name="Doe"))
mocked_sync_engine.delete.assert_called_once()
assert (
mocked_sync_engine.delete.call_args.kwargs["session"] == mocked_driver_session
)
def test_sync_session_remove(
mocked_sync_session, mocked_sync_engine, mocked_driver_session
):
mocked_sync_session.remove(PersonModel, PersonModel.first_name == "John")
mocked_sync_engine.remove.assert_called_once()
assert (
mocked_sync_engine.remove.call_args.kwargs["session"] == mocked_driver_session
)
python-odmantic-1.0.2/tests/unit/test_typing.py 0000664 0000000 0000000 00000001464 14613034133 0021676 0 ustar 00root root 0000000 0000000 from typing import Dict, List, Set
from odmantic.model import EmbeddedModel
from odmantic.typing import get_first_type_argument_subclassing
def test_get_first_type_argument_subclassing():
class E(EmbeddedModel):
e: int
assert get_first_type_argument_subclassing(List[E], EmbeddedModel) == E
assert get_first_type_argument_subclassing(Set[E], EmbeddedModel) == E
assert get_first_type_argument_subclassing(Dict[int, E], EmbeddedModel) == E
def test_get_first_type_argument_subclassing_on_non_matching_generics():
class E(EmbeddedModel):
e: int
assert get_first_type_argument_subclassing(E, EmbeddedModel) is None
assert get_first_type_argument_subclassing(int, EmbeddedModel) is None
assert get_first_type_argument_subclassing(Dict[int, str], EmbeddedModel) is None
python-odmantic-1.0.2/tests/zoo/ 0000775 0000000 0000000 00000000000 14613034133 0016576 5 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/tests/zoo/__init__.py 0000664 0000000 0000000 00000000000 14613034133 0020675 0 ustar 00root root 0000000 0000000 python-odmantic-1.0.2/tests/zoo/book_embedded.py 0000664 0000000 0000000 00000000317 14613034133 0021714 0 ustar 00root root 0000000 0000000 from odmantic.model import EmbeddedModel, Model
class Publisher(EmbeddedModel):
name: str
founded: int
location: str
class Book(Model):
title: str
pages: int
publisher: Publisher
python-odmantic-1.0.2/tests/zoo/book_reference.py 0000664 0000000 0000000 00000000357 14613034133 0022125 0 ustar 00root root 0000000 0000000 from odmantic.model import Model
from odmantic.reference import Reference
class Publisher(Model):
name: str
founded: int
location: str
class Book(Model):
title: str
pages: int
publisher: Publisher = Reference()
python-odmantic-1.0.2/tests/zoo/deeply_nested.py 0000664 0000000 0000000 00000000446 14613034133 0022000 0 ustar 00root root 0000000 0000000 from odmantic.model import Model
from odmantic.reference import Reference
class NestedLevel3(Model):
field: int = 3
class NestedLevel2(Model):
field: int = 2
next_: NestedLevel3 = Reference()
class NestedLevel1(Model):
field: int = 1
next_: NestedLevel2 = Reference()
python-odmantic-1.0.2/tests/zoo/full_bson.py 0000664 0000000 0000000 00000000564 14613034133 0021140 0 ustar 00root root 0000000 0000000 from bson.binary import Binary
from bson.decimal128 import Decimal128
from bson.int64 import Int64
from bson.objectid import ObjectId
from bson.regex import Regex
from odmantic.model import Model
class FullBsonModel(Model):
"""Model used to test BSON types"""
objectId_: ObjectId
long_: Int64
decimal_: Decimal128
binary_: Binary
regex_: Regex
python-odmantic-1.0.2/tests/zoo/patron_embedded.py 0000664 0000000 0000000 00000000345 14613034133 0022266 0 ustar 00root root 0000000 0000000 from typing import List
from odmantic.model import EmbeddedModel, Model
class Address(EmbeddedModel):
street: str
city: str
state: str
zip: str
class Patron(Model):
name: str
addresses: List[Address]
python-odmantic-1.0.2/tests/zoo/person.py 0000664 0000000 0000000 00000000221 14613034133 0020451 0 ustar 00root root 0000000 0000000 from odmantic.model import Model
class PersonModel(Model):
model_config = {"collection": "people"}
first_name: str
last_name: str
python-odmantic-1.0.2/tests/zoo/player.py 0000664 0000000 0000000 00000000272 14613034133 0020445 0 ustar 00root root 0000000 0000000 from odmantic import Field, Model
class Player(Model):
"""This model has a custom primary key"""
name: str = Field(primary_field=True)
level: int = Field(default=1, ge=1)
python-odmantic-1.0.2/tests/zoo/tree.py 0000664 0000000 0000000 00000000655 14613034133 0020115 0 ustar 00root root 0000000 0000000 import enum
from typing import Dict, List
from odmantic.field import Field
from odmantic.model import Model
class TreeKind(str, enum.Enum):
BIG = "big"
SMALL = "small"
class TreeModel(Model):
name: str = Field(default="Acacia des montagnes")
average_size: float = Field(key_name="size")
discovery_year: int
kind: TreeKind
genesis_continents: List[str]
per_continent_density: Dict[str, float]
python-odmantic-1.0.2/tests/zoo/twitter_user.py 0000664 0000000 0000000 00000000317 14613034133 0021711 0 ustar 00root root 0000000 0000000 from typing import List
from bson import ObjectId
from odmantic import Model
class TwitterUser(Model):
"""Self referencing model with manual reference handling"""
following: List[ObjectId] = []
python-odmantic-1.0.2/tox.ini 0000664 0000000 0000000 00000002041 14613034133 0016135 0 ustar 00root root 0000000 0000000 [tox]
isolated_build = true
envlist =
py{37,38,39,310,311}-motor{21,22,23,24,25,30}-pydantic{17,18,19}
py{37,38,39}-motor{24}-pymongo{3_11,3_12}-pydantic{17,18,19}
skip_missing_interpreters=false
[testenv]
extras = test
deps =
motor21: motor ~= 2.1.0
motor22: motor ~= 2.2.0
motor23: motor ~= 2.3.0
motor24: motor ~= 2.4.0
motor25: motor ~= 2.5.0
motor30: motor ~= 3.0.0
pymongo3_11: pymongo ~= 3.11.0
pymongo3_12: pymongo ~= 3.12.0
# pymongo 4.0.0 is not supported by any version of motor
# pymongo4_0: pymongo ~= 4.0.0
# pymongo 4.1.0 is the the only version supported by motor 3.0.0, it's already covered
# pymongo4_1: pymongo ~= 4.1.0
pydantic16: pydantic ~= 1.6.2
pydantic17: pydantic ~= 1.7.4
pydantic18: pydantic ~= 1.8.2
pydantic19: pydantic ~= 1.9.0
pydantic110: pydantic ~= 1.10.0
whitelist_externals =
pytest
commands =
python -c "import motor; print(motor.version)" 1>&2
python -c "import pydantic; print(pydantic.VERSION)" 1>&2
python -m pytest -q -rs